Les threads virtuels Java en profondeur
Approfondissement des threads virtuels Java : épinglage, ordonnancement et migration depuis les threads de plateforme.
Les threads de plateforme — le seul type que Java connaissait jusqu'au JDK 21 — ont un mappage un-à-un avec les threads du système d'exploitation. Ils sont coûteux : chacun réserve environ un mégaoctet de pile et le planificateur du système d'exploitation ne peut en gérer que quelques milliers avant que les changements de contexte n'épuisent le CPU. Les threads virtuels, livrés par Project Loom, brisent ce plafond. Ce sont des threads légers gérés par la JVM, et non par le système d'exploitation, ce qui permet à un seul programme d'en exécuter des millions. Ce chapitre va au-delà de l'introduction : comment ils sont ordonnancés, ce qu'est l'épinglage, comment la concurrence structurée lie leurs durées de vie, et dans quels cas ils aident (et dans quels cas ils n'aident pas).
Si vous débutez sur ce sujet, lisez d'abord l'introduction aux threads virtuels ; ce chapitre suppose que vous savez déjà comment en démarrer un. Une base solide en multithreading Java et sur le framework executor sera également utile.
Threads de plateforme vs threads virtuels
Un thread virtuel est toujours un java.lang.Thread — la même API, le même Runnable. La différence réside dans ce qui le supporte. Un thread de plateforme est un thread du système d'exploitation pendant toute sa durée de vie. Un thread virtuel s'exécute sur un petit pool de threads de plateforme appelés carriers (porteurs) : lorsqu'il se bloque sur une E/S, la JVM le démonte de son porteur, libère ce porteur pour un autre thread virtuel, puis remonte le thread virtuel lorsque l'E/S se termine. Bloquer un thread virtuel est peu coûteux ; bloquer un thread de plateforme gaspille une ressource rare.
| Aspect | Thread de plateforme | Thread virtuel |
|---|---|---|
| Supporté par | Un thread du système d'exploitation | Un thread porteur dans un pool |
| Coût mémoire | ~1 Mo de pile fixe | Quelques centaines d'octets, croît à la demande |
| Nombre pratique | Milliers | Millions |
| Idéal pour | Travail lié au CPU | Travail lié aux E/S, forte concurrence |
| Quand il se bloque | Gaspille le thread du système d'exploitation | Se démonte ; le porteur est réutilisé |
| Cycle de vie | Créer un pool et réutiliser | Créer un par tâche, jetable |
Le modèle mental est inversé. Avec les threads de plateforme, vous dimensionnez soigneusement un pool et réutilisez les threads. Avec les threads virtuels, vous en créez un par tâche et le laissez mourir — ils sont suffisamment bon marché pour être jetables.
Créer des threads virtuels
Il existe trois points d'entrée idiomatiques. Pour une tâche unique, utilisez le builder Thread.ofVirtual() ou le raccourci Thread.startVirtualThread ; pour de nombreuses tâches, utilisez un executor un-thread-virtuel-par-tâche.
// One-off, started immediately.
Thread t = Thread.startVirtualThread(() ->
System.out.println("hi from " + Thread.currentThread()));
t.join();
// Builder: configure before starting.
Thread named = Thread.ofVirtual().name("worker-", 0).unstarted(() -> doWork());
named.start();
// Many tasks: the executor creates a fresh virtual thread per submitted task.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
executor.submit(() -> handleRequest());
}
} // close() waits for every task to finishNe créez jamais de pool de threads virtuels. Un pool de threads à taille fixe traditionnel limite la concurrence intentionnellement ; encapsuler des threads virtuels dans Executors.newFixedThreadPool(...) supprime tout leur avantage. Le bon outil est newVirtualThreadPerTaskExecutor(), qui n'impose aucune limite de taille.
Ordonnancement, porteurs et épinglage
Les threads virtuels sont ordonnancés par un ForkJoinPool dédié dont le nombre de workers correspond par défaut au nombre de cœurs CPU. Ces workers sont les threads porteurs. Lorsqu'un thread virtuel atteint un appel bloquant dans le JDK — Thread.sleep, lectures de socket, BlockingQueue.take — le runtime le démonte afin que le porteur puisse exécuter autre chose.
Parfois, un thread virtuel ne peut pas être démonté et reste attaché à son porteur. C'est l'épinglage, et cela annule l'objectif : un thread virtuel bloqué mais épinglé retient un porteur en otage. Deux situations en sont la cause :
| Cause de l'épinglage | Pourquoi cela se produit | Remède |
|---|---|---|
Dans un bloc/méthode synchronized | Le moniteur est lié au porteur | Remplacer par ReentrantLock |
| Dans un appel natif (JNI) | Le runtime ne peut pas capturer la pile native | Éviter les appels bloquants dans le code natif |
// Pins the carrier while sleeping — bad.
synchronized (lock) {
Thread.sleep(1000); // the virtual thread cannot unmount here
}
// Does not pin — good.
lock.lock();
try {
Thread.sleep(1000); // the virtual thread unmounts freely
} finally {
lock.unlock();
}Vous pouvez diagnostiquer l'épinglage en exécutant avec -Djdk.tracePinnedThreads=full, qui imprime une trace de pile chaque fois qu'un thread virtuel épingle son porteur.
Concurrence structurée
Créer des threads de manière ad hoc entraîne des fuites : si une sous-tâche échoue, ses sœurs continuent de s'exécuter et vous devez vous souvenir de les annuler. La concurrence structurée (StructuredTaskScope, une API en préversion) fait en sorte qu'un groupe de sous-tâches se comporte comme une seule unité de travail — elles sont forkées ensemble, jointes ensemble et annulées ensemble. Lorsque le scope parent se termine, l'exécution de tous les enfants est garantie.
import java.util.concurrent.StructuredTaskScope;
Response handle() throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> fetchUser()); // subtask 1
var order = scope.fork(() -> fetchOrder()); // subtask 2
scope.join(); // wait for both
scope.throwIfFailed(); // propagate the first failure, cancel the rest
return new Response(user.get(), order.get());
} // both subtasks are guaranteed finished or cancelled here
}ShutdownOnFailure annule les sous-tâches restantes dès qu'une lève une exception ; ShutdownOnSuccess retourne dès que la première sous-tâche réussit (pratique pour des appels redondants en course). Dans les deux cas, il n'y a aucun thread orphelin. Pour l'API complète et plus de patterns, consultez le chapitre sur la concurrence structurée.
Exemple concret : dix mille tâches simultanées
Le programme ci-dessous soumet 10 000 tâches liées aux E/S — chacune attend simplement 50 ms pour simuler un appel réseau — à un executor un-thread-virtuel-par-tâche. Il compte le nombre de threads porteurs distincts qui ont réellement exécuté le travail et compare le temps réel par rapport à l'exécution des mêmes tâches les unes après les autres.
Ce qu'il faut retenir de l'exécution :
- 10 000 tâches se terminent, et l'exécution complète se finit en bien moins d'une seconde — loin des ~500 000 ms que prendraient les mêmes attentes exécutées séquentiellement, car toutes les attentes se chevauchent.
- Le nombre de threads porteurs est égal au nombre de cœurs CPU (
Carrier threadscorrespond àAvailable cores) : des milliers de threads virtuels sont multiplexés sur ce petit groupe de threads de plateforme. Thread.sleepdémonte le thread virtuel de son porteur, ce qui explique précisément pourquoi si peu de porteurs peuvent servir autant de tâches à la fois — le porteur n'attend jamais au ralenti.- La fermeture de
newVirtualThreadPerTaskExecutor()dans un bloc try-with-resources bloque jusqu'à ce que chaque tâche soumise soit terminée, de sorte que le compteur de tâches terminées atteint toujours 10 000 avant l'affichage du temps. isVirtual()retournetrueetisDaemon()retournetrue— les threads virtuels sont toujours des threads daemon, ils ne maintiennent donc jamais la JVM en vie par eux-mêmes.
Quand utiliser les threads virtuels (et quand ne pas le faire)
Les threads virtuels sont un avantage lorsque vos tâches passent la plupart de leur temps à attendre — sur le réseau, une base de données, un fichier ou un service en aval. C'est la forme habituelle du travail côté serveur, donc le conseil typique est simple : utilisez un thread virtuel par requête et écrivez du code bloquant classique.
Ils ne constituent pas une accélération pour le travail lié au CPU. Une tâche qui effectue des calculs ne se bloque jamais, donc elle ne se démonte jamais ; en exécuter un million ne fait qu'ajouter une surcharge d'ordonnancement. Pour du calcul pur, dimensionnez plutôt un pool à votre nombre de cœurs. Deux autres points à garder à l'esprit :
- Vérifiez les chemins critiques à la recherche de blocs
synchronizedqui encapsulent des appels bloquants et migrez-les versReentrantLockpour éviter l'épinglage. - Ne mettez pas en cache ni ne créez de pool de threads virtuels, et ne vous appuyez pas sur l'état thread-local pour limiter la concurrence — utilisez un sémaphore ou un autre limiteur explicite si vous devez limiter le débit.