Pools de threads Java
Réutilisez des threads pour exécuter de nombreuses tâches efficacement grâce aux pools de threads Java et aux paramètres de ThreadPoolExecutor.
Créer un thread est coûteux. Chaque new Thread() alloue environ 1 Mo de pile native, demande au système d'exploitation de planifier un nouveau thread noyau et ajoute de la charge au GC. Un programme qui crée un thread par tâche convient pour dix tâches ; il s'effondre à dix mille. La solution est un pool de threads — un petit ensemble de threads travailleurs à longue durée de vie qui extraient des tâches d'une file d'attente. Le pool possède les threads ; vous possédez les tâches.
Ce chapitre est le chapitre conceptuel — ce qu'est un pool, les paramètres qui le configurent et les modes d'échec. Le chapitre suivant, Executor framework, présente les types Executor/ExecutorService que vous utilisez pour interagir avec un pool. Les deux sont liés ; ce chapitre se concentre sur le quoi et le pourquoi, le suivant sur le comment.
Pourquoi utiliser un pool ?
Trois problèmes qu'un pool résout :
- Coût de création des threads. Allouer une pile native et demander au système d'exploitation un nouveau thread prend de l'ordre de la milliseconde. Réutiliser des threads existants prend des microsecondes. À grande échelle, la différence est celle entre un serveur qui tient la charge et un qui ne la tient pas.
- Limites de ressources. Un thread de plateforme sur une JVM 64 bits consomme environ 1 Mo de pile —
64 Gode RAM représentent~64 000threads, et le système d'exploitation a ses propres surcharges par thread. La création illimitée de threads est une consommation illimitée de RAM. Un pool fixe une limite sur le nombre. - Parallélisme prévisible. Un pool avec
Ntravailleurs vous donne exactementNtâches parallèles. C'est bien plus adapté à "utiliser les 16 cœurs" que "créer un thread par requête et espérer."
Le coût du pooling : vous devez le dimensionner. Trop petit → les tâches s'accumulent et la latence augmente. Trop grand → la commutation de contexte domine et le débit baisse. Le chapitre sur le dimensionnement (executor framework) couvre les règles empiriques ; ce chapitre porte sur ce qu'est un pool.
L'anatomie d'un pool
Un pool de threads est essentiellement trois choses :
- Un ensemble borné de threads travailleurs. Les travailleurs exécutent une boucle : prendre une tâche dans la file, l'exécuter, prendre la suivante, recommencer. Ils vivent pendant toute la durée de vie du pool (ou jusqu'à ce qu'ils soient inactifs trop longtemps, selon la politique).
- Une file de tâches. Lorsque vous soumettez du travail et qu'aucun travailleur n'est libre, la tâche est placée ici. Le type de file —
LinkedBlockingQueue,ArrayBlockingQueue,SynchronousQueue— détermine comment le pool croît sous charge. - Une API de soumission.
execute(Runnable),submit(Callable),invokeAll(...)— les façons de soumettre du travail au pool.
En Java, tout cela est encapsulé dans java.util.concurrent.ThreadPoolExecutor, qui est la classe sous-jacente de presque tous les pools que vous rencontrerez.
Les sept paramètres de ThreadPoolExecutor
Construction directe (que vous faites rarement, mais les paramètres sont ceux que chaque fabrique passe en dessous) :
new ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime, TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler
);| Paramètre | Ce qu'il contrôle |
|---|---|
corePoolSize | Nombre minimum de travailleurs maintenus actifs même en cas d'inactivité. Les threads jusqu'à ce nombre ne sont pas supprimés. |
maximumPoolSize | Limite supérieure du nombre total de travailleurs. Le pool ne croît au-delà de core que lorsque la file est pleine. |
keepAliveTime | Durée pendant laquelle un travailleur inactif au-delà de la taille core attend avant de se terminer. |
workQueue | Où les tâches en attente sont stockées. LinkedBlockingQueue (non borné) vs ArrayBlockingQueue (borné) vs SynchronousQueue (sans tampon) détermine entièrement le comportement du pool. |
threadFactory | Comment les threads travailleurs sont construits. Utilisez ceci pour définir les noms, le statut démon, la priorité, les gestionnaires d'exceptions non interceptées. |
handler | Ce qui se passe lorsque les travailleurs et la file sont saturés. Par défaut : AbortPolicy. |
L'interaction non évidente : le pool préfère remplir la file avant de créer de nouveaux threads au-delà de core. Ainsi, une file non bornée signifie que le pool ne croît jamais au-delà de core — il met simplement en file indéfiniment. Une file bornée (ou SynchronousQueue) est ce qui rend le paramètre max significatif.
Les quatre politiques de rejet
Lorsque submit ne peut pas accepter une tâche (file pleine, tous les travailleurs max occupés), le RejectedExecutionHandler décide ce qui se passe :
| Politique | Comportement |
|---|---|
AbortPolicy (par défaut) | Lance une RejectedExecutionException. L'appelant sait que la tâche a été abandonnée. |
CallerRunsPolicy | Le thread appelant exécute la tâche lui-même. Ralentit l'appelant, créant une contre-pression. |
DiscardPolicy | Abandonne silencieusement la tâche. À utiliser uniquement pour les travaux de type télémétrie "au mieux". |
DiscardOldestPolicy | Abandonne la tâche la plus ancienne en file et soumet la nouvelle. Utile quand "seul le plus récent compte." |
Le lancement par défaut est généralement le choix sûr. CallerRunsPolicy est un mécanisme de contre-pression élégant — lorsque le pool est submergé, le soumetteur est ralenti pour s'adapter, ce qui limite naturellement le débit de la source.
Les méthodes de fabrique Executors — et pourquoi il faut surtout les éviter
java.util.concurrent.Executors fournit des fabriques pratiques :
Executors.newFixedThreadPool(n); // core = max = n, unbounded LinkedBlockingQueue
Executors.newCachedThreadPool(); // core = 0, max = Integer.MAX_VALUE, SynchronousQueue, 60s keep-alive
Executors.newSingleThreadExecutor(); // fixed pool with one thread
Executors.newScheduledThreadPool(n); // for delay/repeat scheduling
Executors.newVirtualThreadPerTaskExecutor(); // Java 21+: one virtual thread per taskDeux d'entre elles présentent des pièges bien connus :
newFixedThreadPoolutilise uneLinkedBlockingQueuenon bornée. Sous une surcharge soutenue, la file croît sans limite — finalement OOM. La taille du pool est fixe ; le travail qui s'accumule derrière elle ne l'est pas.newCachedThreadPoolamaximum = Integer.MAX_VALUE. Sous une rafale soutenue de travail, il crée des threads sans limite — finalement épuise la limite de threads par processus du système d'exploitation et plante la JVM.
Celles-ci conviennent pour de petits travaux, des démos et des scripts ponctuels. Pour le code de production, construisez un ThreadPoolExecutor directement avec une file bornée, un max raisonnable et une politique de rejet explicite.
L'exception : newVirtualThreadPerTaskExecutor (Java 21+) distribue des threads virtuels, qui sont suffisamment bon marché pour que "un par tâche" fonctionne réellement. Nous l'abordons dans le chapitre threads virtuels.
Cycle de vie : shutdown vs shutdownNow
Un pool continue de fonctionner jusqu'à ce que vous lui disiez de s'arrêter. Les deux modes d'arrêt :
pool.shutdown(); // stop accepting new work; let queued tasks finish
pool.shutdownNow(); // stop accepting; interrupt running threads; return queued tasks
boolean terminated = pool.awaitTermination(10, TimeUnit.SECONDS);shutdown est la version polie : aucune nouvelle soumission acceptée, le travail existant se termine, puis le pool s'arrête. shutdownNow est la version brutale : interrompre les travailleurs, retourner la file en attente. Utilisez shutdown pour une sortie propre ; utilisez shutdownNow après un délai shutdown + awaitTermination si le travail n'est pas terminé.
Le modèle d'arrêt combiné de la documentation JDK :
pool.shutdown();
try {
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
pool.shutdownNow();
pool.awaitTermination(5, TimeUnit.SECONDS);
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}Vous voulez presque toujours exactement cette forme dans tout code qui possède un pool. Sans shutdown, la JVM maintient les travailleurs actifs (non démon par défaut) et ne se termine pas.
Nommer les travailleurs via ThreadFactory
Le Executors.defaultThreadFactory() par défaut nomme les threads pool-1-thread-1, pool-1-thread-2, etc. C'est légèrement mieux que Thread-7 mais toujours insuffisant. Le code de production utilise une fabrique nommée :
ThreadFactory factory = r -> {
Thread t = new Thread(r, "image-worker-" + COUNTER.incrementAndGet());
t.setDaemon(false);
t.setUncaughtExceptionHandler((thr, ex) -> log.error("uncaught in " + thr.getName(), ex));
return t;
};
ExecutorService pool = new ThreadPoolExecutor(
4, 4, 0, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
factory,
new ThreadPoolExecutor.CallerRunsPolicy());La fabrique est votre occasion de définir chaque propriété par thread : nom, indicateur démon, priorité, gestionnaire d'exceptions non interceptées, groupe de threads. Dans un vidage de tas de 200 threads, un thread appelé image-worker-7 est un thread que vous pouvez trouver.
Exemple concret : construire un pool borné avec contre-pression
Le programme ci-dessous construit un ThreadPoolExecutor avec 4 travailleurs, une file bornée de 8 et le gestionnaire de rejet CallerRunsPolicy — ainsi le soumetteur est ralenti lorsque le pool est submergé au lieu de lever une exception.
Ce qu'il faut retenir de l'exécution :
- Le pool avait un strict plafond de 4 threads travailleurs. Avec 40 tâches à 50 ms chacune, le temps sérialisé idéalisé par pool est
40 * 50 / 4 = 500 ms. Le temps réel horloge murale était proche de cela — moins le coût du ralentissement deCallerRunsPolicychaque fois que la file se remplissait. - Certaines tâches rapportaient un nom de thread
main. C'estCallerRunsPolicyen action : lorsque la file était pleine et tous les travailleurs occupés,pool.executeexécutait la tâche sur le thread appelant au lieu de la mettre en file ou de lever une exception. Le soumetteur ralentissait ; le système restait borné. C'est la contre-pression correctement appliquée. pool.getLargestPoolSize()était 4 — le maximum est resté égal au core. Le pool n'a pas crû au-delà decoremême sous charge soutenue car la file bornée avait de la place pour les brèves rafales. Avec une file non bornée (le défaut deExecutors.newFixedThreadPool), la file aurait accepté chaque tâche etlargestPoolSizeserait resté à 4 — mais la mémoire aurait augmenté pendant l'accumulation des tâches.- La séquence d'arrêt est le modèle de production.
shutdown()a dit au pool d'arrêter d'accepter de nouvelles soumissions ;awaitTermination(5, SECONDS)a attendu jusqu'à 5 secondes pour le travail en cours ; si le travail n'avait pas terminé,shutdownNow()aurait interrompu les travailleurs restants. Sans ces appels, la JVM ne se termine pas — les travailleurs non démon la maintiennent en vie. - La fabrique de threads donnait à chaque travailleur un nom significatif (
worker-1...worker-4) et un gestionnaire d'exceptions non interceptées. Dans un vidage de threads ou un profileur de production, ces noms font la différence entre "je sais quel sous-système c'est" et "je n'en ai aucune idée." Définissez-les sur chaque pool que vous créez.
Et ensuite
Le chapitre suivant, Java Executor Framework, présente la hiérarchie de types que vous utilisez pour interagir avec les pools de threads — Executor, ExecutorService, ScheduledExecutorService — et comment dimensionner un pool pour des charges CPU-intensives et I/O-intensives.