W3docs

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 :

  1. 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.
  2. Limites de ressources. Un thread de plateforme sur une JVM 64 bits consomme environ 1 Mo de pile — 64 Go de RAM représentent ~64 000 threads, 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.
  3. Parallélisme prévisible. Un pool avec N travailleurs vous donne exactement N tâ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 :

  1. 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).
  2. 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.
  3. 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ètreCe qu'il contrôle
corePoolSizeNombre minimum de travailleurs maintenus actifs même en cas d'inactivité. Les threads jusqu'à ce nombre ne sont pas supprimés.
maximumPoolSizeLimite supérieure du nombre total de travailleurs. Le pool ne croît au-delà de core que lorsque la file est pleine.
keepAliveTimeDurée pendant laquelle un travailleur inactif au-delà de la taille core attend avant de se terminer.
workQueueOù 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.
threadFactoryComment 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.
handlerCe 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 :

PolitiqueComportement
AbortPolicy (par défaut)Lance une RejectedExecutionException. L'appelant sait que la tâche a été abandonnée.
CallerRunsPolicyLe thread appelant exécute la tâche lui-même. Ralentit l'appelant, créant une contre-pression.
DiscardPolicyAbandonne silencieusement la tâche. À utiliser uniquement pour les travaux de type télémétrie "au mieux".
DiscardOldestPolicyAbandonne 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 task

Deux d'entre elles présentent des pièges bien connus :

  • newFixedThreadPool utilise une LinkedBlockingQueue non 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.
  • newCachedThreadPool a maximum = 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.

java— editable, runs on the server

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 de CallerRunsPolicy chaque fois que la file se remplissait.
  • Certaines tâches rapportaient un nom de thread main. C'est CallerRunsPolicy en action : lorsque la file était pleine et tous les travailleurs occupés, pool.execute exé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à de core mê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 de Executors.newFixedThreadPool), la file aurait accepté chaque tâche et largestPoolSize serait 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.

Pratique

Pratique
Vous appelez `Executors.newFixedThreadPool(8)` et soumettez des tâches plus vite que le pool ne peut les traiter. Le pool a 8 threads. Quel est le mode d'échec pathologique sous surcharge soutenue ?
Vous appelez `Executors.newFixedThreadPool(8)` et soumettez des tâches plus vite que le pool ne peut les traiter. Le pool a 8 threads. Quel est le mode d'échec pathologique sous surcharge soutenue ?
Was this page helpful?