Les virtual threads en production : ce qui casse vraiment

Les virtual threads sont arrivés en Java 21. Ils sont stables, ils tiennent leurs promesses, et il n’y a plus grand-chose à en dire.

Le problème est ailleurs. Presque tout ce qui est écrit sur eux date de leurs débuts. Ces articles mettent en garde contre un défaut qui a été corrigé depuis. On refactore donc du code pour rien. Pendant ce temps, les vrais pièges passent inaperçus.

Cet article fait le point sur Java 25. Ce qu’il faut oublier, ce qui reste vrai, et ce qui casse pour de bon en production. Tout a été vérifié sur une 25.0.1, sorties réelles à l’appui.

D’abord, le carrier

Un virtual thread ne s’exécute pas tout seul. Pour tourner, il lui faut un vrai thread du système d’exploitation. Ce vrai thread s’appelle le carrier.

La JVM en garde un petit stock, dans un ForkJoinPool réservé à ça. Par défaut, il y en a autant que de processeurs disponibles. Dix processeurs, dix carriers. Pas un de plus.

Le va-et-vient est simple. Quand un virtual thread a du travail, la JVM le monte sur un carrier libre. Il s’exécute. Dès qu’il bloque, sur un appel réseau ou une requête SQL, la JVM le démonte : sa pile est recopiée sur la heap, et le carrier retourne au stock. Un autre virtual thread prend sa place aussitôt.

C’est là tout le truc. Un carrier ne reste jamais planté à attendre. Dix carriers suffisent alors à faire tourner des centaines de milliers de virtual threads, à condition que ceux-ci passent leur temps à attendre.

Un détail qui compte : ce nombre de processeurs, en conteneur, c’est la JVM qui le déduit du cgroup. Le parallélisme de vos virtual threads dépend donc directement de ce que raconte Régler la JVM en conteneur.

Et le pinning

Le pinning, c’est quand le démontage n’a pas lieu. Le virtual thread bloque, mais il garde son carrier. Le carrier ne travaille plus, et il ne peut porter personne d’autre. Il est immobilisé pour rien.

Un carrier perdu, ce n’est pas grave. Le souci, c’est qu’il n’y en a que dix. Et le scheduler ne compense pas le pinning : il ne fabrique pas de carrier de remplacement. Il sait le faire pour certaines opérations bloquantes, jusqu’à un plafond de 256 threads par défaut. Mais pas pour celle-là.

Les carriers épinglés s’accumulent donc contre un mur, sans soupape. Quand il n’en reste plus un seul de libre, l’application ne ralentit pas : elle s’arrête. Plus aucun virtual thread ne peut tourner. C’est de la starvation. Et si le travail épinglé attend lui-même un virtual thread, c’est un deadlock.

Voilà pourquoi le pinning fait peur. Jusqu’à JDK 23, synchronized provoquait exactement ça. D’où le conseil qu’on lit partout : remplacez vos blocs synchronized par des ReentrantLock.

Ce conseil a été juste pendant deux ans. Il ne l’est plus.

Ce que le JEP 491 a changé

Le JEP 491 est arrivé dans JDK 24, pas dans la 25. La nuance compte. Ceux qui migrent de LTS en LTS le découvrent en passant à 25, et l’attribuent à la 25. Le travail a été fait un cran avant.

Pourquoi synchronized épinglait-il ? À cause d’un détail de plomberie. La JVM notait quel thread plateforme détenait un moniteur. Le carrier, donc, pas le virtual thread. Démonter un virtual thread au milieu d’un bloc synchronized aurait alors passé le moniteur au virtual thread suivant. L’exclusion mutuelle serait tombée. Plutôt que de risquer ça, la JVM gardait tout le monde en place.

Le JEP 491 a repris les moniteurs de HotSpot. Ils appartiennent maintenant au virtual thread, pas à son carrier. Le démontage devient sûr. Trois cas s’en trouvent réglés : bloquer à l’intérieur d’un synchronized, bloquer pour entrer dans un synchronized déjà pris, et Object.wait().

Vérifions plutôt que de croire sur parole. Le programme suivant lance un virtual thread qui dort deux secondes à l’intérieur d’un bloc synchronized. Puis, 200 ms plus tard, un second virtual thread qui ne fait qu’afficher le moment où il a réussi à tourner :

public class Pinning {
    static final Object lock = new Object();

    public static void main(String[] args) throws Exception {
        long t0 = System.currentTimeMillis();

        Thread holder = Thread.ofVirtual().name("holder").start(() -> {
            synchronized (lock) {
                try { Thread.sleep(2000); } catch (InterruptedException e) {}
            }
        });

        Thread.sleep(200);

        Thread other = Thread.ofVirtual().name("other").start(() ->
            System.out.println("other a tourné à t+" + (System.currentTimeMillis() - t0) + " ms"));

        other.join();
        holder.join();
    }
}

Le tout avec un seul carrier, pour ne laisser aucune échappatoire :

java -Djdk.virtualThreadScheduler.parallelism=1 \
     -Djdk.virtualThreadScheduler.maxPoolSize=1 Pinning.java

Sur JDK 25 :

other a tourné à t+210 ms

other tourne tout de suite. Le carrier a été libéré pendant le sleep, alors que holder tenait toujours le moniteur. Sur un JDK 21, le même code aurait attendu la fin du sleep, soit deux secondes pleines : l’unique carrier était épinglé, et other ne pouvait même pas démarrer.

La conséquence est simple. Refactorer du synchronized en ReentrantLock pour cause de pinning n’a plus d’objet sur un JDK récent. Le JEP le dit lui-même : inutile de revenir en arrière si vous l’avez déjà fait, mais la migration n’est plus nécessaire. ReentrantLock garde ses atouts propres, le lock avec timeout, l’équité, le lock interruptible. Le pinning n’en fait plus partie.

Au passage, la propriété jdk.tracePinnedThreads n’a pas survécu au JEP 491. Elle est sans effet sur JDK 25, et sans le dire, y compris quand il y a du vrai pinning. Deux raisons à son retrait. Elle n’avait plus grand-chose à tracer. Et elle imprimait ses piles depuis du code critique, ce qui a valu une belle série de bugs de gel. Si votre runbook la mentionne, il est à jour comme le reste du web.

Ce qui épingle encore en Java 25

Le pinning n’a pas disparu, il s’est réduit à des cas plus rares, tous liés à la présence d’un frame natif ou d’un blocage dans la VM elle-même :

Les trois derniers tournent tous autour du chargement et de l’initialisation des classes. Le plus facile à reproduire, c’est le blocage dans un class initializer. Dix lignes suffisent. Une classe dont le bloc static bloque, et le virtual thread qui déclenche l’initialisation épingle son carrier pendant tout ce temps :

static class Lente {
    static {
        try { Thread.sleep(2000); } catch (InterruptedException e) {}
    }
    static void touch() {}
}

Avec le même montage que plus haut, un seul carrier, un virtual thread qui touche Lente et un autre qui veut juste tourner :

other a tourné à t+2011 ms

Cette fois other a attendu les deux secondes pleines. Le carrier était bien épinglé.

Le cas paraît théorique. Il l’est beaucoup moins quand on tombe sur un bloc static qui lit un fichier de configuration, ouvre une connexion ou appelle un service de découverte. Ça n’arrive qu’une fois, au premier chargement. Mais c’est précisément au démarrage que toutes les requêtes arrivent en même temps.

Un seul de ces quatre cas est en cours de correction, et la nuance compte. JDK 26 démonte le virtual thread qui attend qu’un autre thread exécute un class initializer. Sur les chemins interprétés les plus courants seulement : invokestatic, new, getstatic, putstatic.

C’est de ce cas-là que parle le rapport de bug OpenJDK quand il va jusqu’au deadlock. Tous les carriers sont épinglés à attendre une initialisation. Et le thread qui initialise est bloqué sur un virtual thread que plus personne ne peut exécuter. Personne n’avance.

Attention à ne pas s’y tromper : le cas démontré ici, bloquer à l’intérieur d’un <clinit>, tient à un frame natif. Celui-là épingle toujours sur 26.

Quant au cas frère, « I/O réseau pendant le chargement de classe dans un virtual thread », il est toujours ouvert à ce jour. Autrement dit, sur Java 25, c’est le pinning que vous avez le plus de chances de croiser sans avoir écrit une seule ligne de code natif.

Le voir, avec JFR

Puisque jdk.tracePinnedThreads est morte, le pinning se regarde avec Java Flight Recorder. L’événement jdk.VirtualThreadPinned est activé par défaut, avec un seuil de 20 ms. Autrement dit, sur n’importe quel enregistrement JFR, le pinning qui dure assez pour vous nuire est déjà dedans. Vous n’avez rien à activer.

java -XX:StartFlightRecording=filename=pin.jfr Clinit.java
jfr print --events jdk.VirtualThreadPinned pin.jfr

Sur le cas du class initializer, la sortie ne laisse pas de place au doute :

jdk.VirtualThreadPinned {
  startTime = 16:47:54.658 (2026-07-16)
  duration = 2.01 s
  blockingOperation = "LockSupport.park"
  pinnedReason = "VM call to Clinit$Lente.<clinit> on stack"
  carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 31)
  eventThread = "init" (javaThreadId = 30, virtual)
  stackTrace = [
    java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 826
    ...
  ]
}

Tout y est : la durée, la raison, le carrier bloqué, le virtual thread coupable et la pile. Le champ pinnedReason nomme la classe et son <clinit>. Ces trois champs, blockingOperation, pinnedReason et carrierThread, ont été ajoutés par le JEP 491, justement pour que l’événement remplace la propriété disparue.

L’autre événement à connaître est jdk.VirtualThreadSubmitFailed, également activé par défaut, qui signale qu’un virtual thread n’a pas pu être démarré ou réveillé. Il est rare, et toujours mauvais signe. En revanche jdk.VirtualThreadStart et jdk.VirtualThreadEnd sont désactivés par défaut, et c’est tant mieux : à un événement par tâche, ça chiffre vite.

Dans un thread dump, ils sont presque invisibles

Voilà un piège autrement plus fréquent que le pinning, et qui se découvre au pire moment.

Un thread dump classique, jstack ou jcmd Thread.print, ne liste pas les virtual threads. C’est assumé : le dump classique est une liste plate de threads plateforme, et personne ne veut lire une liste plate d’un million d’entrées. Il signale quand même le virtual thread actuellement monté sur chaque carrier, et depuis JDK 24 il en imprime aussi la pile, ce qui change tout :

"ForkJoinPool-1-worker-1" #27 [32771] daemon prio=5 cpu=4177.76ms elapsed=4.18s
   Carrying virtual thread #26
	at jdk.internal.vm.Continuation.run(java.base@25.0.1/Continuation.java:251)
	at java.lang.VirtualThread.runContinuation(java.base@25.0.1/VirtualThread.java:293)
	...
   Mounted virtual thread #26
	at Mounted.lambda$main$0(Mounted.java:4)

Notez le détail qui coûte cher : le virtual thread est identifié par son numéro, #26, jamais par son nom. Et surtout, seuls les montés apparaissent.

Or un virtual thread monté est un virtual thread qui travaille. Ceux qui attendent sont démontés, donc absents du dump. C’est-à-dire l’écrasante majorité. C’est-à-dire, très exactement, ceux que vous cherchez quand ça coince. Le réflexe décrit dans Lire un thread dump avec jstack devient donc aveugle là où on en a le plus besoin.

Le nouveau dump prend le relais :

jcmd <pid> Thread.dump_to_file -format=json dump.json

Il sort un JSON, groupé par « thread container », c’est-à-dire par executor, et il n’interrompt pas l’application. Chaque virtual thread y est, avec son nom, son état et sa pile :

{
  "tid": "30",
  "virtual": true,
  "name": "commande-0",
  "state": "TIMED_WAITING",
  "stack": [
    "java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:780)",
    ...
  ]
}

Deux choses à retenir. Le champ virtual, d’abord, qui distingue les deux mondes.

Le nom, ensuite. Les virtual threads sont anonymes par défaut. Thread.ofVirtual().start(...) et Executors.newVirtualThreadPerTaskExecutor() produisent des threads dont le nom est la chaîne vide. Ils s’affichent en VirtualThread[#43]/runnable. Et votre dump devient dix mille lignes de "name": "", ce qui ne vous avancera pas beaucoup. Nommez-les, comme on nomme un pool de threads plateforme. Le builder sait numéroter tout seul :

Thread.ofVirtual().name("commande-", 0).start(task);

Bonne nouvelle au passage, et là encore le web est en retard : Java 25 a ajouté les verrous au dump JSON. C’était la grande limite des versions précédentes. On y trouve maintenant blockedOn, monitorsOwned et parkBlocker :

{
  "tid": "26", "virtual": true, "name": "vt-1", "state": "BLOCKED",
  "blockedOn": "java.lang.Object@149c6999",
  "monitorsOwned": [ { "depth": 0, "locks": ["java.lang.Object@6f9e29f6"] } ]
},
{
  "tid": "28", "virtual": true, "name": "vt-2", "state": "BLOCKED",
  "blockedOn": "java.lang.Object@6f9e29f6",
  "monitorsOwned": [ { "depth": 0, "locks": ["java.lang.Object@149c6999"] } ]
}

Un deadlock de manuel, deux virtual threads qui prennent deux moniteurs en ordre inverse. Chacun est bloqué sur l’objet que l’autre détient, et les adresses se recoupent à l’œil nu. Avec une réserve de taille : il n’y a pas de détection automatique. Le Found one Java-level deadlock de jstack n’existe pas ici, et ThreadMXBean.findDeadlockedThreads() ne voit pas les cycles de virtual threads. Le dump vous donne la matière, le recoupement reste à votre charge.

Ne poolez pas des virtual threads

C’est le contresens le plus courant après le pinning, et il vient d’un bon réflexe.

Un pool de threads plateforme existe parce qu’un thread plateforme coûte cher. Comptez de l’ordre du mégaoctet de stack réservée, 1 Mo sur Linux x64, 2 Mo sur macOS/aarch64. Plus un thread OS, plus un appel système. On les recycle parce qu’ils sont rares.

Un virtual thread, lui, est un simple objet sur la heap. Pas d’appel système. Sa pile grandit et rétrécit avec le besoin. Comptez de l’ordre du kilo-octet : 200 000 virtual threads parqués sur une pile courte tiennent dans 196 Mo.

Le mettre dans un pool, c’est donc payer la complexité d’un pool pour économiser mille fois moins qu’on ne le croit. Le JEP 444 y consacre une section dont le titre ne s’embarrasse pas de nuance : « Do not pool virtual threads ».

Un virtual thread est fait pour une tâche, du début à la fin, puis il meurt. D’où le nom de l’executor du JDK, qui dit exactement ce qu’il fait, et dont le nombre de threads est non borné :

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var commande : commandes) {
        executor.submit(() -> traiter(commande));
    }
}

Reste la question légitime derrière le réflexe du pool : limiter la concurrence. Une API en aval ne supporte pas dix mille appels en parallèle, et le pool de 20 threads servait aussi de limiteur. La réponse n’est pas de brider les threads, c’est de brider l’accès à la ressource :

private final Semaphore limite = new Semaphore(20);

public Reponse appeler(Requete r) throws InterruptedException {
    limite.acquire();
    try {
        return client.send(r);
    } finally {
        limite.release();
    }
}

Vingt appels en vol au maximum, autant de virtual threads que vous voulez, et la limite est posée là où elle a du sens. Et ce n’est pas un compromis : les deux constructions sont la même chose vue de deux côtés. Un pool fait la queue des tâches en attente d’un thread, le sémaphore fait la queue des threads en attente d’un permis. Comme un virtual thread est la tâche, la structure obtenue est équivalente. Vous ne perdez rien, vous déplacez la limite là où elle se comprend.

Le vrai plafond est en aval

C’est le point qui gâche le plus de migrations. Les virtual threads suppriment le coût du thread. Ils ne suppriment aucune autre limite.

Le cas classique : on active les virtual threads sur une application Spring Boot, une ligne suffit,

spring.threads.virtual.enabled=true

et le pool HTTP cesse d’être le goulot. Dix mille requêtes concurrentes arrivent maintenant jusqu’au code. Elles y trouvent un pool HikariCP à 10 connexions. Avant, le pool HTTP à 200 threads limitait la casse en amont. Maintenant, les dix mille se mettent en file sur getConnection, et la latence explose alors que le CPU dort et que la base ne transpire pas.

Le goulot n’a pas été supprimé, il a été déplacé, et il est devenu moins visible. Le symptôme est exactement celui décrit dans l’article sur les thread dumps : tout le monde attend getConnection. Sauf que cette fois, il faut le dump JSON pour le voir.

La bonne nouvelle, c’est que ce plafond-là est sain. Une base de données a un nombre de connexions utiles limité. Le gonfler ne la rend pas plus rapide. La bonne réaction n’est donc pas de passer Hikari à 500 connexions. C’est de choisir la limite en conscience, avec un timeout d’acquisition qui rejette proprement au lieu de faire attendre. Les virtual threads rendent explicites les limites que le pool de threads gardait implicites.

Et n’ajoutez pas de Semaphore par-dessus le pool de connexions. Un pool de connexions est déjà un sémaphore : limité à dix connexions, il bloque le onzième qui en demande une. La limite est là, elle fait son travail, il suffit de la régler.

Deux détails qui piquent sur Spring Boot, tant qu’on y est. D’abord, les propriétés qui dimensionnaient vos pools de threads cessent de s’appliquer, sans prévenir. Normal : il n’y a plus de pool dédié.

Ensuite, les virtual threads sont des threads daemon. Une application dont il ne reste que des @Scheduled s’arrête donc toute seule, faute d’un thread non-daemon pour la maintenir en vie. Le remède tient en une ligne : spring.main.keep-alive=true.

Rien à gagner sur le CPU

Les virtual threads servent au code qui attend. Un appel HTTP, une requête SQL, une lecture de fichier. Le gain vient de ce qu’un thread qui attend ne mobilise plus de thread OS.

Pour du calcul, il n’y a rien à gagner, et on peut le montrer chiffres en main. Le VirtualThreadSchedulerMXBean, arrivé en JDK 24, expose enfin l’état du scheduler. Sur une machine à 10 processeurs, après avoir lancé 2000 virtual threads qui calculent en boucle :

parallelism               = 10
poolSize                  = 10
mountedVirtualThreadCount = 10
queuedVirtualThreadCount  = 1990

Dix montés, 1990 en file. Un virtual thread qui calcule occupe son carrier du début à la fin, exactement comme un thread plateforme. Un million de virtual threads sur dix cœurs, ça reste dix cœurs. Pire : le scheduler ne fait pas de time sharing, il ne préempte pas un virtual thread qui calcule. Les 1990 attendent que les 10 aient fini.

Ce MXBean mérite d’ailleurs sa place dans vos métriques. Guettez queuedVirtualThreadCount qui monte pendant que mountedVirtualThreadCount colle au parallelism. C’est la signature d’un scheduler saturé. Et c’est le signal qu’on n’avait pas jusqu’ici.

Il est exposé sous jdk.management:type=VirtualThreadScheduler. Côté JMX, les attributs prennent une majuscule : QueuedVirtualThreadCount, MountedVirtualThreadCount. Utile si vous le branchez sur JConsole ou un exporter.

Le corollaire fâche : sur une application déjà rapide et peu concurrente, les virtual threads n’apportent rien de mesurable. Ils changent le plafond de concurrence, pas la latence unitaire.

Le reste de la famille

Trois sujets gravitent autour, en bref.

ThreadLocal fonctionne toujours et n’est pas déprécié. La nuance est plus fine qu’on ne le dit. Y ranger du contexte reste parfaitement légitime : l’utilisateur courant, l’identifiant de transaction. Ça, personne ne vous le reprochera.

Ce qui ne passe plus, c’est l’autre usage : y cacher des objets coûteux pour les réutiliser. Ce cache ne tenait que parce que les threads étaient rares et recyclés. Avec un thread par tâche, chaque virtual thread démarre avec un ThreadLocal vide. L’objet coûteux est donc recréé à chaque tâche au lieu d’être partagé. Le cache ne cache plus rien.

Le reste est une question de volume. Un million de virtual threads, c’est un million de copies.

Scoped values, définitif en Java 25, répond au besoin de contexte autrement, en lecture seule et avec une portée délimitée :

private static final ScopedValue<Utilisateur> UTILISATEUR = ScopedValue.newInstance();

ScopedValue.where(UTILISATEUR, utilisateur).run(() -> traiter(commande));

La valeur est visible de tout le code appelé dedans et disparaît à la sortie. Pas de fuite, pas de nettoyage à faire.

Structured concurrency reste en preview en Java 25. Son API a encore bougé : StructuredTaskScope.open() a remplacé le constructeur, et ShutdownOnFailure a laissé la place aux Joiner. Elle est excellente. Elle bougera encore. Ne la posez pas dans du code que vous n’avez pas envie de retoucher à chaque version.

En résumé

synchronized n’épingle plus depuis JDK 24. Le conseil de le remplacer par ReentrantLock est périmé. jdk.tracePinnedThreads est morte avec.

Le pinning qui reste tient au code natif et à l’initialisation de classe. Il se lit dans JFR, avec jdk.VirtualThreadPinned, activé par défaut au-delà de 20 ms.

Côté diagnostic, jstack ne voit que les virtual threads montés. Prenez l’habitude de jcmd Thread.dump_to_file -format=json, qui donne maintenant les verrous. Et nommez vos virtual threads, sinon le dump ne vous apprendra rien.

Ne les poolez pas. Pour limiter la concurrence, un Semaphore suffit. Surveillez queuedVirtualThreadCount.

Et surtout, regardez en aval. Quand on enlève le goulot des threads, c’est le pool de connexions qui devient le mur.

Les virtual threads sont une très bonne fonctionnalité. Stable, et sans piège majeur. Le plus gros risque qu’ils font courir, c’est de suivre des conseils d’il y a deux ans.