W3docs

Framework Executor de Java

Soumettez des tâches à des pools de threads avec Executor et ExecutorService — hiérarchie de types, fabriques et règles de dimensionnement.

Le chapitre précédent décrivait ce qu'est un pool de threads. Ce chapitre porte sur la hiérarchie de types utilisée pour communiquer avec un tel pool — les interfaces Executor, ExecutorService et ScheduledExecutorService. Ensemble, elles forment le framework executor, introduit dans Java 5 pour découpler « le travail » des « threads qui l'exécutent ». Vous écrivez Callable<Result> et Runnable ; vous soumettez ; le framework gère l'allocation des threads, la mise en file d'attente et le retour du résultat.

La hiérarchie à trois niveaux

Executor          // execute(Runnable)
   |
ExecutorService   // + submit/invokeAll/invokeAny/shutdown/awaitTermination
   |
ScheduledExecutorService  // + schedule/scheduleAtFixedRate/scheduleWithFixedDelay

Vous programmez contre l'interface la plus générale qui possède ce dont vous avez besoin :

  • Executor — la base à une seule méthode. Utilisez-la quand vous avez seulement besoin de lancer sans attendre. Un paramètre de méthode typé Executor est le contrat le plus général : « donnez-moi n'importe quoi capable d'exécuter un Runnable ».
  • ExecutorService — le cheval de bataille. Presque tout le code de production utilise ce type. Il ajoute submit (avec un résultat Future), les opérations en bloc et le cycle de vie.
  • ScheduledExecutorService — quand vous avez besoin d'une exécution différée ou répétée.

Executor.execute — lancer et oublier

public interface Executor {
  void execute(Runnable command);
}

C'est toute l'interface. execute prend un Runnable, l'exécute à un moment futur et ne retourne rien. Si le travail lève une exception, vous n'en êtes pas informé — l'exception est transmise au gestionnaire d'exceptions non capturées du thread worker.

execute est le bon appel quand :

  • Le travail n'a pas de valeur de retour.
  • Vous n'avez pas besoin d'attendre qu'il se termine ou d'en obtenir le résultat.
  • Vous n'avez pas besoin de l'annuler.

Pour tout ce qui est plus riche, utilisez submit.

ExecutorService.submit, la version enrichie

public interface ExecutorService extends Executor {
  <T> Future<T> submit(Callable<T> task);
  Future<?> submit(Runnable task);
  <T> Future<T> submit(Runnable task, T result);
  // ... lifecycle, bulk ops
}

submit retourne un Future, ce qui vous permet de :

  • Attendre la fin de l'exécution (get() bloque).
  • Lire le résultat (get() retourne la valeur du Callable).
  • Annuler la tâche (cancel(boolean mayInterrupt)).
  • Capturer l'exception de la tâche (get() la relance).

Nous couvrons Future et Callable en détail dans le prochain chapitre ; pour l'instant, le contraste avec execute est l'essentiel. execute est unidirectionnel ; submit ouvre un canal de retour.

ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Integer> result = pool.submit(() -> {
  // Callable<Integer>; can throw, returns a value
  return expensive();
});

Integer value = result.get();                       // waits, throws ExecutionException if task failed

Opérations en bloc : invokeAll et invokeAny

Lorsque vous avez une collection de tâches :

List<Callable<Integer>> tasks = makeTasks();

List<Future<Integer>> futures = pool.invokeAll(tasks);          // run all, wait for all
Integer first = pool.invokeAny(tasks);                          // run all, return first success, cancel the rest

invokeAll(tasks, timeout, unit) les exécute mais abandonne après un délai ; les tâches qui ne se sont pas terminées reviennent sous forme de Future dont isDone() est vrai mais qui ont été annulées.

invokeAny est le bon outil pour les requêtes redondantes — interrogez trois serveurs DNS, prenez celui qui répond en premier, annulez les autres.

ScheduledExecutorService — délais et répétitions

Quand vous avez besoin d'un délai ou d'une planification périodique :

ScheduledExecutorService sched = Executors.newScheduledThreadPool(2);

sched.schedule(() -> log("once, after 5 seconds"), 5, TimeUnit.SECONDS);

sched.scheduleAtFixedRate(this::flush, 0, 1, TimeUnit.SECONDS);
// runs at t=0, t=1, t=2, ... — even if a run takes longer, the next one queues

sched.scheduleWithFixedDelay(this::poll, 0, 1, TimeUnit.SECONDS);
// runs at t=0, then 1 second AFTER the previous finished — back-to-back delay is what's fixed

La différence entre atFixedRate et withFixedDelay réside dans le fait que la période est mesurée entre les démarrages ou entre la fin et le prochain démarrage. Pour « je veux vider toutes les secondes sur l'horloge », utilisez atFixedRate ; pour « je veux un intervalle d'une seconde entre les exécutions quelle que soit leur durée », utilisez withFixedDelay.

Si une tâche planifiée lève une exception, les exécutions futures sont annulées silencieusement. Le planificateur ne journalise rien. Encapsulez toujours les tâches planifiées dans un try/catch de haut niveau pour qu'elles continuent à s'exécuter :

sched.scheduleAtFixedRate(() -> {
  try { flush(); }
  catch (Throwable t) { log.error("flush failed", t); }
}, 0, 1, TimeUnit.SECONDS);

Oublier cela est le bug de planificateur le plus courant en production Java.

Dimensionnement du pool

La bonne taille de pool dépend de ce que font les tâches.

Pour le travail lié au CPU, la règle empirique est N + 1 threads sur une machine à N cœurs. Chaque thread maintient un cœur occupé ; le +1 couvre le rare moment où un thread rencontre un blocage mémoire.

Pour le travail lié aux E/S, le bon nombre est bien plus élevé. La formule approximative :

threads = cores * (1 + (wait_time / compute_time))

Si vos tâches attendent 90% du temps sur la base de données, le multiplicateur est 10x — 80 threads sur 8 cœurs. Le nombre exact dépend du schéma d'E/S spécifique ; profilez et ajustez.

En pratique, faites tourner deux pools : un petit pour le travail CPU et un grand pour les E/S. Ne les mélangez pas — un appel lent à la base de données dans un thread du pool CPU bloque un cœur qui devrait calculer.

Les threads virtuels de Java 21 changent fondamentalement ce calcul : bloquer sur des E/S ne gaspille plus de thread de plateforme, vous pouvez donc utiliser un exécuteur un-thread-virtuel-par-tâche et arrêter de dimensionner. Nous couvrons cela à la fin de la partie.

Fabriques Executors — référence rapide

Les méthodes de fabrique retournent toutes ExecutorService (ou une sous-interface). Chacune est un ThreadPoolExecutor avec des valeurs de paramètres spécifiques :

FabriqueConfiguration sous-jacenteQuand l'utiliser
newFixedThreadPool(n)core=max=n, LinkedBlockingQueue non bornéeParallélisme prévisible ; la file non bornée est le piège
newCachedThreadPoolcore=0, max=MAX_VALUE, SynchronousQueue, keep-alive 60sTâches courtes en rafale ; le nombre de threads non borné est le piège
newSingleThreadExecutorIdentique à newFixedThreadPool(1), mais le pool n'est pas reconfigurableSérialiser un seul worker ordonné
newScheduledThreadPool(n)n threads core, file planifiéeTâches périodiques
newWorkStealingPoolJava 8+ : un ForkJoinPool avec parallélisme = cœursTravail lié au CPU, sous-tâches récursives
newVirtualThreadPerTaskExecutorJava 21+ : un thread virtuel par tâcheTravail lié aux E/S, serveurs web

Évitez newFixedThreadPool et newCachedThreadPool pour les chemins de surcharge en production — les deux ont des axes de croissance non bornés. Utilisez directement new ThreadPoolExecutor(...) avec une file bornée.

La séquence d'arrêt standard

Un pool qui n'est jamais arrêté maintient ses threads workers non-daemon en vie, empêchant la sortie de la JVM. Chaque pool que vous créez nécessite le même schéma de nettoyage :

ExecutorService pool = Executors.newFixedThreadPool(4);
try {
  // ... submit work, gather results ...
} finally {
  pool.shutdown();
  try {
    if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
      pool.shutdownNow();
      pool.awaitTermination(5, TimeUnit.SECONDS);
    }
  } catch (InterruptedException e) {
    pool.shutdownNow();
    Thread.currentThread().interrupt();
  }
}

Ou, depuis Java 19, la même chose via try-with-resources :

try (var pool = Executors.newFixedThreadPool(4)) {
  pool.submit(...);
  pool.submit(...);
}                                                    // close() runs shutdown + awaitTermination

La méthode ExecutorService.close() de Java 19 effectue l'arrêt poli puis attend indéfiniment ; combinez-la avec un watchdog si vous ne pouvez pas vous permettre une attente infinie.

Un exemple concret : le framework de bout en bout

Le programme ci-dessous utilise chacune des trois interfaces — Executor pour le lancement sans attente, ExecutorService pour les résultats, et ScheduledExecutorService pour le périodique — le tout en un seul programme.

java— editable, runs on the server

Ce qu'il faut retenir de l'exécution :

  • La section 2 a utilisé try (ExecutorService pool = ...) — le schéma Java 19 de fermeture à la sortie du bloc. La méthode close() du pool exécute shutdown() puis attend. C'est la forme d'arrêt la plus propre ; pour le code plus ancien ou les délais plus stricts, revenez à la séquence shutdown + awaitTermination + shutdownNow.
  • La section 3 a exécuté trois tâches de 50/80/20 ms sur 4 workers. invokeAll n'a retourné qu'après la fin de la plus lente — environ 80 ms. C'est le contrat « attendre toutes ». La sum sur les futures était la somme des valeurs qu'elles ont retournées, dans l'ordre de soumission.
  • La section 4 a exécuté la même structure avec invokeAny. La tâche la plus rapide (50 ms) est retournée en premier ; les autres ont été annulées. invokeAny est exactement le bon schéma pour les patterns « première réponse réussie » — recherches DNS sur plusieurs serveurs, téléchargements miroir, courses de latence.
  • La section 5 a utilisé scheduleAtFixedRate avec une période de 60 ms. Chaque tick s'est déclenché sur un thread du pool planifié. Le wrapper try/catch à l'intérieur du corps est le schéma de production — si une tâche planifiée lève une exception, le planificateur annule silencieusement les exécutions futures. Encapsuler chaque corps dans un catch de haut niveau empêche cela.
  • La tâche planifiée a été explicitement cancel(false)'d avant la sortie du programme. Annuler et arrêter le planificateur est ce qui permet à la JVM de se terminer ; sans cela, le planificateur maintient des threads non-daemon et le programme se bloque. Il en va de même pour chaque exécuteur que vous créez.

La suite

Le prochain chapitre, Java Callable and Future, plonge dans le côté gestion des résultats de submitCallable<V>, Future<V>, l'annulation et les idiomes standard pour obtenir une valeur d'une tâche asynchrone.

Pratique

Pratique
Vous planifiez une tâche avec `scheduleAtFixedRate` et elle lève une `RuntimeException` à la troisième exécution. Que se passe-t-il pour les exécutions suivantes ?
Vous planifiez une tâche avec `scheduleAtFixedRate` et elle lève une `RuntimeException` à la troisième exécution. Que se passe-t-il pour les exécutions suivantes ?
Was this page helpful?