ExecutorService et thread pool : choisir le bon type
1. Aperçu
Créer new Thread() pour chaque tâche, c’est facile à écrire, mais difficile à contrôler quand la charge augmente. ExecutorService résout ce problème en réutilisant les threads et en mettant les tâches en file d’attente. La classe Executors fournit plusieurs types de pools différents, et choisir le mauvais type peut causer une OutOfMemoryError ou bloquer l’application.
Cet article parcourt chaque type de pool, comment ThreadPoolExecutor traite les tâches en interne, comment choisir la taille du pool et comment le configurer dans Spring Boot 3.x. Après la lecture, vous saurez quel pool utiliser pour une tâche CPU-bound, I/O-bound ou une tâche périodique.
2. ExecutorService de base
L’exemple suivant utilise un pool simple pour exécuter une tâche qui retourne un résultat :
import java.util.concurrent.*;
public class OrderReportApp {
public static void main(String[] args) throws Exception {
try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
Future<Integer> total = executor.submit(() -> countOrders("2026-10"));
System.out.println("Total des commandes : " + total.get());
}
}
static int countOrders(String month) {
return 128; // simuler une requête en base de données
}
}
Total des commandes : 128
Le code fonctionne comme suit :
submit()reçoit uneCallableet retourneFuture<Integer>immédiatement. La tâche s’exécute sur un thread du pool.total.get()bloque le thread actuel jusqu’à ce qu’il y ait un résultat.- Depuis Java 19,
ExecutorServiceimplémenteAutoCloseable. En quittant le bloctry,close()appelleshutdown()puis attend que toutes les tâches se terminent.
Attention : Si vous ne fermez pas l’executor, les threads non-daemon garderont la JVM en vie. Le programme
mainne s’arrêtera pas.
3. Les différents types de pool dans Executors
3.1. newFixedThreadPool(n)
Ce pool a exactement n threads. Les tâches supplémentaires sont mises dans une LinkedBlockingQueue sans limite.
ExecutorService invoiceExecutor = Executors.newFixedThreadPool(8);
Ce type est approprié quand vous voulez limiter le nombre de tâches s’exécutant en parallèle, par exemple pour une tâche CPU-bound. Le risque se situe au niveau de la file d’attente. Si le producteur ajoute des tâches plus vite qu’elles ne sont traitées, la queue s’agrandit indéfiniment jusqu’à épuiser la heap.
3.2. newCachedThreadPool()
Ce pool n’a pas de nombre fixe de threads. Il utilise une SynchronousQueue, c’est-à-dire qu’il n’y a pas vraiment de file d’attente. Chaque nouvelle tâche est confiée à un thread inactif s’il y en a un, sinon le pool crée un nouveau thread. Un thread inactif pendant plus de 60 secondes est supprimé.
ExecutorService notificationExecutor = Executors.newCachedThreadPool();
Ce pool convient pour beaucoup de tâches courtes et une charge faible, instable. Quand la charge augmente soudainement, il peut créer des milliers de platform threads. Chaque thread occupe environ 1 MB de stack par défaut, donc l’application peut facilement rencontrer OutOfMemoryError: unable to create native thread.
3.3. newSingleThreadExecutor()
Ce pool n’a qu’un seul thread, et les tâches s’exécutent séquentiellement dans l’ordre d’envoi.
ExecutorService auditLogExecutor = Executors.newSingleThreadExecutor();
auditLogExecutor.submit(() -> System.out.println("Commande #1 créée"));
auditLogExecutor.submit(() -> System.out.println("Commande #1 payée"));
Commande #1 créée
Commande #1 payée
Les deux lignes de log s’affichent toujours dans l’ordre correct. Ce type est utile quand vous avez besoin de journaliser, d’écrire des fichiers ou de mettre à jour l’état dans le bon ordre sans vouloir utiliser un verrou. Si le thread meurt à cause d’une exception, le pool créera un nouveau thread pour le remplacer. Sa file d’attente est aussi sans limite.
3.4. newScheduledThreadPool(n)
Ce type est utilisé pour les tâches planifiées ou répétées :
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(
() -> System.out.println("Synchronisation de l'inventaire à " + java.time.LocalTime.now()),
0, 5, TimeUnit.SECONDS);
scheduleAtFixedRate exécute la tâche chaque 5 secondes, en comptant à partir du début de l’exécution précédente. À l’inverse, scheduleWithFixedDelay compte 5 secondes à partir de la fin de l’exécution précédente. Vous devriez choisir scheduleWithFixedDelay quand la durée d’exécution de la tâche n’est pas stable.
Attention : Si la tâche périodique lève une exception, toutes les exécutions ultérieures seront silencieusement annulées, sans aucun log. Enveloppez toujours le corps de la tâche dans un
try/catch.
3.5. newWorkStealingPool()
Cette méthode retourne un ForkJoinPool avec un nombre de threads égal au nombre de CPU. Chaque thread a sa propre file d’attente. Un thread inactif va “voler” du travail à la file d’attente d’un autre thread.
ExecutorService pricingExecutor = Executors.newWorkStealingPool();
Ce type convient pour les tâches CPU-bound qui peuvent être divisées récursivement, par exemple le calcul parallèle sur un grand ensemble de données. Les threads du pool sont des daemon, et le pool ne garantit pas l’ordre d’exécution.
3.6. newVirtualThreadPerTaskExecutor() (Java 21)
Avec ce type, chaque tâche s’exécute sur un nouveau virtual thread. Les virtual threads sont très bon marché, donc vous pouvez en créer des centaines de milliers.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int customerId = i;
executor.submit(() -> {
Thread.sleep(100); // simuler un appel HTTP vers CustomerService
return customerId;
});
}
}
Le code ci-dessus exécute 10 000 tâches bloquant 100 ms et se termine en quelques centaines de ms. Avec un pool fixe de 10 threads, le même volume de travail prendrait environ 100 secondes.
C’est le bon choix par défaut pour les tâches I/O-bound comme les appels HTTP, les requêtes en base de données ou la lecture de fichiers. Vous devez retenir trois choses :
- Ne pool pas les virtual threads. Ils sont créés pour être utilisés une fois puis abandonnés.
- Pour limiter la charge sur une ressource externe, utilisez
Semaphore. Par exemple, si la base de données accepte seulement 20 connexions, ne comptez pas sur la taille du pool pour limiter. - En Java 21, un bloc
synchronizedavec une opération bloquante va “épingler” le virtual thread au carrier thread. Dans ce cas, les avantages des virtual threads sont perdus. PréférezReentrantLockdans ces situations.
Exemple de limitation du nombre de requêtes simultanées avec Semaphore :
Semaphore dbPermits = new Semaphore(20);
executor.submit(() -> {
dbPermits.acquire();
try {
return orderRepository.findByCustomerId(customerId);
} finally {
dbPermits.release();
}
});
4. Configurer soi-même ThreadPoolExecutor
Les méthodes factory dans Executors cachent souvent un paramètre dangereux : une queue sans limite ou un nombre de threads sans limite. Pour une application en production, vous devez initialiser vous-même ThreadPoolExecutor pour tout contrôler :
ThreadFactory factory = Thread.ofPlatform().name("payment-", 0).factory();
ThreadPoolExecutor paymentExecutor = new ThreadPoolExecutor(
4, // corePoolSize
8, // maximumPoolSize
60, TimeUnit.SECONDS, // keepAlive pour les threads au-delà du core
new ArrayBlockingQueue<>(100), // queue avec une limite
factory, // nommage : payment-0, payment-1...
new ThreadPoolExecutor.CallerRunsPolicy());
Quand une nouvelle tâche arrive, ThreadPoolExecutor la traite dans l’ordre exact suivant :
- Si le nombre de threads est inférieur à
corePoolSize, le pool crée un nouveau thread. - Sinon, le pool met la tâche dans la queue.
- Si la queue est pleine et le nombre de threads est inférieur à
maximumPoolSize, le pool crée un thread supplémentaire. - Si les deux limites sont atteintes, le pool appelle
RejectedExecutionHandler.
La conséquence est que maximumPoolSize n’a d’effet que si la queue est pleine. Si vous utilisez une queue sans limite, le pool ne dépassera jamais corePoolSize. C’est une source de confusion très courante.
Java fournit quatre rejection policies :
| Policy | Comportement |
|---|---|
AbortPolicy (par défaut) |
Lève une RejectedExecutionException |
CallerRunsPolicy |
Le thread qui appelle execute() exécute lui-même la tâche, ce qui ralentit le producteur (back-pressure) |
DiscardPolicy |
Jette la tâche, sans notification |
DiscardOldestPolicy |
Jette la tâche la plus ancienne de la queue puis essaie à nouveau |
Nommer les threads via ThreadFactory rend les thread dumps et les logs beaucoup plus lisibles. Comparer payment-3 avec pool-7-thread-3 montre immédiatement la différence.
5. Choisir le bon type
5.1. Taille du pool
Pour une tâche CPU-bound, le nombre de threads devrait être approximativement le nombre de cores :
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuExecutor = Executors.newFixedThreadPool(cores);
Plus de threads que de cores augmente seulement le coût du context switching.
Pour une tâche I/O-bound s’exécutant sur des platform threads, vous pouvez utiliser une formule approximative :
nombre de threads ≈ nombre de cores × (1 + temps d'attente / temps de calcul)
Exemple : une machine avec 8 cores, chaque requête attend 90 ms en I/O et calcule 10 ms. Le résultat est 8 × (1 + 9) = 80 threads. C’est juste un point de départ. Vous devez vérifier avec un test de charge réel.
5.2. Tableau de décision
| Problème | Choix |
|---|---|
| Calcul lourd CPU | newFixedThreadPool(cores) ou ForkJoinPool |
| Appels API, requêtes DB en grand nombre | newVirtualThreadPerTaskExecutor() + Semaphore |
| Besoin d’ordre séquentiel | newSingleThreadExecutor() |
| Tâches périodiques | newScheduledThreadPool(n) |
| Production, besoin de back-pressure | ThreadPoolExecutor avec queue limitée |
| Pics de charge, sans limite | Éviter newCachedThreadPool() |
6. Thread pool dans Spring Boot 3.x
D’abord, vous avez besoin de la dépendance Maven :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Ensuite, déclarez un executor séparé pour l’envoi d’emails :
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "emailExecutor")
public ThreadPoolTaskExecutor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("email-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
return executor;
}
}
@Service
public class EmailService {
@Async("emailExecutor")
public void sendOrderConfirmation(String customerEmail) {
System.out.println(Thread.currentThread().getName() + " envoie mail à " + customerEmail);
}
}
email-1 envoie mail à an.nguyen@example.com
Quelques points à noter :
- Le
queueCapacitypar défaut deThreadPoolTaskExecutorestInteger.MAX_VALUE. Si vous ne définissez pas cette valeur,maxPoolSizesera inutile, comme expliqué à la section 4. setWaitForTasksToCompleteOnShutdown(true)garantit que les emails en cours d’envoi ne seront pas interrompus à l’arrêt de l’application.- Si vous voulez utiliser des virtual threads pour
@Async, Tomcat et@Scheduled, vous avez juste besoin d’ajouter une ligne de configuration :
spring.threads.virtual.enabled=true
7. Tester le comportement du pool
Le test suivant confirme l’ordre exact de traitement décrit à la section 4 : core threads d’abord, ensuite la queue, puis les threads supplémentaires, finalement la rejection.
import org.junit.jupiter.api.Test;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
class PaymentExecutorTest {
@Test
void shouldRejectWhenThreadsAndQueueAreFull() throws Exception {
CountDownLatch release = new CountDownLatch(1);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 2, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1),
new ThreadPoolExecutor.AbortPolicy());
Runnable blockingPayment = () -> {
try {
release.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
try {
executor.execute(blockingPayment); // 1 : crée le core thread
executor.execute(blockingPayment); // 2 : va dans la queue
executor.execute(blockingPayment); // 3 : queue pleine -> crée 2e thread
assertThat(executor.getPoolSize()).isEqualTo(2);
assertThat(executor.getQueue()).hasSize(1);
assertThatThrownBy(() -> executor.execute(blockingPayment))
.isInstanceOf(RejectedExecutionException.class);
} finally {
release.countDown();
executor.shutdown();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
}
CountDownLatch garde toutes les tâches à l’état bloqué, donc l’état du pool reste stable pendant la vérification. La quatrième tâche n’a nulle part où aller, donc AbortPolicy lève une exception. Le bloc finally libère le latch et ferme le pool, donc le test ne laisse pas de threads derrière.
8. Erreurs courantes
- Utiliser
Executors.newFixedThreadPoolavec un producteur non contrôlé. La queue sans limite va s’agrandir puis causer une OOM. Utilisez uneArrayBlockingQueuelimitée. - Créer un nouveau pool dans chaque requête. Le pool doit être partagé, par exemple déclaré comme bean ou comme
static final. Le créer puis l’oublier provoquera une fuite de threads. - Avaler les exceptions. Avec
submit(), l’exception est stockée dans laFuture. Si vous n’appelez pasget(), vous ne verrez jamais l’erreur. Appelezget()ou attrapez l’erreur dans la tâche. - Deadlock quand des tâches attendent des sous-tâches sur un petit pool. Par exemple : pool de 2 threads, les deux tâches parent appellent
get()sur une tâche enfant qui attend dans la queue. Aucun thread ne peut exécuter la tâche enfant, donc tout s’arrête. - Mettre des
ThreadLocallourds dans les virtual threads. Vous pouvez avoir des millions de virtual threads, chacun gardant une copie, donc la mémoire augmente très vite. - Appeler
shutdownNow()sans que la tâche gère l’interrupt. La tâche continuera alors à s’exécuter. Les tâches longues doivent vérifierThread.currentThread().isInterrupted().
Attention : N’utilisez pas
ExecutorServicepour des workflows complexes avec plusieurs étapes dépendantes. Dans ce cas, utilisezCompletableFuture, qui peut toujours s’exécuter sur un executor que vous configurez.
9. Conclusion
Chaque type de thread pool résout un problème différent :
- Fixed pool ou
ForkJoinPoolpour les tâches CPU-bound. - Virtual thread avec
Semaphorepour les tâches I/O-bound en Java 21. - Single thread executor quand l’ordre est nécessaire.
- Scheduled pool pour les tâches périodiques.
En production, configurez vous-même ThreadPoolExecutor ou ThreadPoolTaskExecutor avec une queue limitée, une rejection policy claire et des noms de threads reconnaissables. Enfin, vérifiez la taille du pool avec un test de charge réel plutôt que de vous fier uniquement à une formule.