Threads virtuels Java
Threads légers planifiés par la JVM (Java 21+) pour des applications concurrentes à haut débit — ce qu'ils corrigent et comment dimensionner.
Chaque chapitre de cette partie du livre jusqu'ici décrivait un thread de plateforme — un Thread Java qui s'associe un à un à un thread du système d'exploitation. Les threads de plateforme sont puissants mais coûteux : chacun occupe environ 1 Mo de pile native, et le système d'exploitation limite un processus à quelques dizaines de milliers de threads. Pour les tâches liées au CPU, c'est largement suffisant. Pour les tâches liées aux entrées/sorties — un serveur web avec un thread par requête qui attend principalement une base de données — c'est un plafond rigide qui est au cœur des tensions dans la conception des serveurs Java depuis deux décennies.
Java 21 a introduit les threads virtuels pour résoudre exactement ce cas. Un thread virtuel est un Thread Java planifié par la JVM (et non par le système d'exploitation) sur un petit pool de threads porteurs au niveau du système d'exploitation. Ils sont légers — des millions par JVM sont courants — et bloquer sur des E/S suspend le thread virtuel sans suspendre le porteur. Le code est identique à avant ; le modèle de coût est différent.
Ce qui change (et ce qui ne change pas)
Les threads virtuels sont des java.lang.Thread. La classe est la même ; les méthodes sont les mêmes ; Thread.currentThread() fonctionne toujours. Ce qui diffère, c'est la façon dont ils sont planifiés et leur coût :
- Un thread de plateforme coûte environ 1 Mo de pile native et est planifié par le système d'exploitation.
- Un thread virtuel coûte environ 1 Ko au départ (et croît selon les besoins) et est planifié par la JVM.
- Bloquer un thread de plateforme bloque le thread sous-jacent du système d'exploitation.
- Bloquer un thread virtuel suspend le thread virtuel ; le thread porteur du système d'exploitation va exécuter un autre thread virtuel.
Ce quatrième point est l'essentiel. Quand un thread virtuel appelle Socket.read(), Thread.sleep(), BlockingQueue.take(), Lock.lock(), ou globalement n'importe quelle API JDK bloquante, la JVM le décroche de son porteur et le porteur prend un autre thread virtuel à exécuter. Le thread virtuel bloqué ne coûte presque rien pendant qu'il attend.
Créer des threads virtuels
Trois façons :
// 1. Direct
Thread t = Thread.ofVirtual().start(() -> doWork());
// 2. Builder
Thread t2 = Thread.ofVirtual().name("vt-", 0).start(this::work); // names "vt-0", "vt-1", ...
// 3. Executor — the production form
try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
es.submit(() -> handleRequest());
}
}La forme avec executor est celle qu'utilise presque tout serveur. Elle attribue un thread virtuel par tâche soumise ; il n'y a pas de pool à dimensionner car le pool de porteurs se dimensionne lui-même.
Vous pouvez également obtenir un thread de plateforme lorsque vous en avez spécifiquement besoin :
Thread t = Thread.ofPlatform().name("compute").start(() -> doCpu());Utile pour les tâches réellement liées au CPU, où le mappage un à un avec le système d'exploitation est ce que vous souhaitez.
Quand les threads virtuels sont avantageux
La forme que les threads virtuels sont optimisés pour :
- De nombreuses tâches concurrentes (centaines, milliers, millions).
- Chaque tâche passe la majeure partie du temps bloquée sur des E/S, des files d'attente ou des verrous.
- Le travail n'est pas dominé par le CPU.
C'est exactement la forme d'un serveur web : chaque requête est une tâche qui attend principalement une base de données, un service en amont ou le client. Avec des threads de plateforme, un serveur avec 1000 requêtes lentes simultanées a besoin de 1000 threads de plateforme — 1 Go de pile native et une charge significative sur le planificateur du système d'exploitation. Avec des threads virtuels, la même charge de travail s'exécute sur 8 porteurs environ ; les 1000 threads virtuels coûtent quelques Mo au total.
Le modèle mental : arrêtez de penser aux pools de threads pour les tâches d'E/S. Soumettez un thread virtuel par requête et laissez le runtime gérer le reste.
Quand les threads virtuels ne sont pas avantageux
Quelques cas où ils n'aident pas ou nuisent activement :
- Tâches liées au CPU. Un thread virtuel effectuant du calcul pur ne peut pas être suspendu — il doit s'exécuter sur un porteur en permanence. Vous ne serez pas plus rapide que le nombre de porteurs, qui correspond à votre nombre de cœurs CPU. Pour les tâches CPU, les threads de plateforme (et fork/join) restent le bon outil.
- Blocs synchronized autour des E/S. Un thread virtuel à l'intérieur de
synchronized (obj) { blockingIO(); }se fixe à son porteur — la JVM ne peut pas le démonter pendant l'appel bloquant car le moniteur est lié au thread du système d'exploitation. C'est un vrai piège : un serveur qui utilisesynchronizedpour protéger un appel de base de données ne passera pas à l'échelle avec les threads virtuels. La correction consiste à utiliserReentrantLockà la place (que le mécanisme des threads virtuels gère correctement). - Stockage
ThreadLocalavec de nombreux threads. Les threads virtuels prennent en chargeThreadLocal, mais le nombre peut exploser — des millions de threads virtuels × N thread-locals × taille de la valeur = beaucoup de mémoire. Java 21 a ajouté les valeurs de portée (ScopedValue) comme alternative structurée. - Code qui suppose qu'un thread est rare (par exemple, qui crée une connexion par thread). Une connexion par thread virtuel correspond à une connexion par requête, ce que la base de données n'apprécie pas. Utilisez un vrai pool de connexions.
En résumé : les threads virtuels rendent la concurrence liée aux E/S peu coûteuse, mais ils ne transforment pas les tâches liées au CPU et ils exposent les chemins de code qui se fixent aux porteurs.
Fixation : le seul élément à surveiller
Un thread virtuel fixé ne peut pas être démonté. Les deux causes de fixation :
- Les blocs
synchronizedqui incluent un appel bloquant. - Les appels de méthodes natives qui bloquent dans JNI.
Vous pouvez détecter la fixation via la propriété système :
java -Djdk.tracePinnedThreads=full ...Si un thread virtuel se bloque alors qu'il est fixé, la JVM affiche une trace de pile. En production, la correction consiste à remplacer synchronized par ReentrantLock autour de la région bloquante. Les futurs JDK travaillent sur la dé-fixation de synchronized (JEP 491 en cours) ; pour l'instant, considérez tout synchronized autour d'un appel E/S comme un anti-pattern pour les threads virtuels.
Qu'en est-il de wait, notify et join ?
Tous fonctionnent — les threads virtuels peuvent attendre sur des moniteurs intrinsèques, être notifiés et être joints. Le runtime gère correctement la suspension et le démontage. La contrainte porte uniquement sur les blocs synchronisés : maintenir le moniteur à travers un appel bloquant à l'intérieur du bloc provoque une fixation ; appeler wait() pour libérer le moniteur et se suspendre est correct.
synchronized (lock) {
lock.wait(); // OK — releases monitor, parks, no pin
}
synchronized (lock) {
socket.read(buf); // BAD — holds monitor through blocking read; pins
}Dimensionner le pool — il n'y a pas de pool
Le changement conceptuel que permettent les threads virtuels : arrêter de dimensionner. Chaque executor que vous avez configuré dans ce livre avait un paramètre de nombre de threads. Avec newVirtualThreadPerTaskExecutor, le nombre est « autant que de requêtes en cours ». Le pool de porteurs (que vous ne configurez pas directement) se dimensionne lui-même en fonction du nombre de cœurs CPU ; les threads virtuels sont de la comptabilité.
Dans un serveur utilisant des threads virtuels :
- Les pools de connexions comptent toujours. Un thread virtuel qui attend une connexion, c'est bien ; en lancer 10 000 qui veulent tous un pool de 5 connexions ne fait que rendre visible le goulot d'étranglement.
- Les limites de débit comptent toujours. Les threads virtuels suppriment la limite de threads, pas la limite du service en aval.
- La mémoire compte toujours. Chaque thread virtuel a une pile et des
ThreadLocals. Des millions de threads, c'est des millions de piles.
Les threads virtuels suppriment le plafond du nombre de threads ; ils ne suppriment pas les contraintes sous-jacentes que ce plafond cachait.
Un exemple concret : un million de threads virtuels contre un thread de plateforme
Le programme ci-dessous met 100 000 tâches en veille pendant 200 ms chacune, en parallèle. Avec des threads de plateforme (limités à un nombre raisonnable), cela prend beaucoup de temps et utilise beaucoup de RAM. Avec des threads virtuels, cela se termine à peine plus longtemps que la veille par tâche elle-même.
Ce que l'on retient de l'exécution :
- Les 100 000 threads virtuels se sont terminés en environ une seconde de temps réel — proche d'une seule veille de 200 ms plus le coût de création et de planification de 100 000 threads, et non 100 000 × 200 ms. C'est tout l'intérêt des threads virtuels : la concurrence (combien de choses sont en cours) est découplée du parallélisme (combien de cœurs exécutent des tâches CPU). Le nombre exact varie selon la machine, mais il reste dans la même plage de quelques secondes quel que soit le nombre de tâches.
- L'exécution du pool de 5 000 tâches sur des threads de plateforme, avec 100 threads de travail, a pris environ
5000 / 100 * 200 = ~10 secondes— les tâches s'accumulaient car le pool ne pouvait en exécuter que 100 à la fois. Pour terminer dans le même temps réel que la version avec threads virtuels, le pool de plateforme aurait besoin de 100 000 threads, ce qui est proche ou au-delà de la limite du système d'exploitation sur la plupart des systèmes. Thread.currentThread().isVirtual()distingue les deux types de threads à l'exécution. Les noms diffèrent aussi — les threads virtuels ont généralement une représentation générique plutôt qu'un nom défini par l'utilisateur, sauf si vous en définissez un via le builder. Utile pour la journalisation quand vous mélangez les deux types.- L'avertissement de fixation est la mise en garde la plus importante pour les threads virtuels en production. Un bloc
synchronizedautour de tout appel bloquant (E/S de base de données, E/S de fichier, réseau) annule la plupart des avantages car le porteur ne peut pas être libéré pendant l'attente. RemplacersynchronizedparReentrantLockmaintient le thread virtuel suspendable. - La forme
try (ExecutorService vexec = ...)a fait la bonne chose à la fermeture — elle a exécutéshutdown()et attendu que chaque tâche soumise se termine. Avec 100 000 tâches en cours qui attendent, cette attente était réelle (200 ms chacune, toutes suspendues ensemble, toutes se terminant presque simultanément). Sans le try-with-resources, l'executor serait resté actif avec des threads non-daemon et le programme se serait bloqué.
Fin de la partie 15
C'est le dernier chapitre de la partie Multithreading et Concurrence. Nous sommes partis de « un thread est une chose au niveau du système d'exploitation » en passant par les verrous, les atomiques et les collections concurrentes que vous utilisez pour rendre l'état partagé correct, puis par le framework d'executors qui cache la gestion des threads, ensuite CompletableFuture et ForkJoinPool pour la composition, et enfin les threads virtuels pour la charge de travail intensive en E/S à laquelle font face les serveurs modernes.
Le schéma tout au long de tout cela : choisissez le plus petit outil qui résout votre problème spécifique. Un compteur ? AtomicInteger. Un indicateur ? volatile. Un producteur/consommateur ? BlockingQueue. De nombreux appels E/S en parallèle ? Threads virtuels. Le mot-clé synchronized est toujours adapté quand il l'est ; Lock est pour quand il ne l'est pas ; les executors et les futures de haut niveau sont pour quand vous avez dépassé les deux. Ne descendez dans la pile que lorsque l'abstraction au-dessus ne fait pas ce dont vous avez besoin.
La partie suivante du livre est Annotations — ce que les marqueurs @ attachés aux classes, méthodes et champs font réellement, les éléments intégrés dans java.lang, et les règles pour écrire les vôtres.