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/scheduleWithFixedDelayVous 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éExecutorest le contrat le plus général : « donnez-moi n'importe quoi capable d'exécuter unRunnable».ExecutorService— le cheval de bataille. Presque tout le code de production utilise ce type. Il ajoutesubmit(avec un résultatFuture), 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 duCallable). - 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 failedOpé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 restinvokeAll(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 fixedLa 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 :
| Fabrique | Configuration sous-jacente | Quand l'utiliser |
|---|---|---|
newFixedThreadPool(n) | core=max=n, LinkedBlockingQueue non bornée | Parallélisme prévisible ; la file non bornée est le piège |
newCachedThreadPool | core=0, max=MAX_VALUE, SynchronousQueue, keep-alive 60s | Tâches courtes en rafale ; le nombre de threads non borné est le piège |
newSingleThreadExecutor | Identique à newFixedThreadPool(1), mais le pool n'est pas reconfigurable | Sérialiser un seul worker ordonné |
newScheduledThreadPool(n) | n threads core, file planifiée | Tâches périodiques |
newWorkStealingPool | Java 8+ : un ForkJoinPool avec parallélisme = cœurs | Travail lié au CPU, sous-tâches récursives |
newVirtualThreadPerTaskExecutor | Java 21+ : un thread virtuel par tâche | Travail 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 + awaitTerminationLa 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.
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éthodeclose()du pool exécuteshutdown()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équenceshutdown+awaitTermination+shutdownNow. - La section 3 a exécuté trois tâches de 50/80/20 ms sur 4 workers.
invokeAlln'a retourné qu'après la fin de la plus lente — environ 80 ms. C'est le contrat « attendre toutes ». Lasumsur 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.invokeAnyest 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é
scheduleAtFixedRateavec une période de 60 ms. Chaque tick s'est déclenché sur un thread du pool planifié. Le wrappertry/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 submit — Callable<V>, Future<V>, l'annulation et les idiomes standard pour obtenir une valeur d'une tâche asynchrone.