Un Virtual Thread est une version allégée de thread, dont la technique a été introduite en preview dans le JDK 19 et en version finale dans le JDK 21.
La promesse est de permettre une scalabilité bien plus importante qu’avec les threads classiques sans payer le surcoût du développement réactif, comme avec Vert.x ou RxJava.
Introduction
Un thread classique, ou platform thread, est essentiellement géré par le système d’exploitation. Du point de vue Java, on a juste une enveloppe autour du thread système.
Le principal défaut des platform threads, c’est que ça consomme pas mal de ressources, en particulier en mémoire stack. Pour contourner ce défaut, on a pris l’habitude de gérer des pools de threads.
La programmation réactive a poussé encore plus loin l’économie de ressource, en permettant une grande scalabilité avec peu de threads. Malheureusement, ça se fait au détriment de la facilité de développement. La programmation réactive est plus complexe que la programmation impérative.
Le virtual thread est une sorte de compromis pour permettre la programmation impérative concurrente en économisant des ressources.
API
java.lang.Thread
Il y a plusieurs façons de créer un thread avec la classe Thread
:
-
directement avec la méthode statique
Thread.startVirtualThread(runnable)
, -
via
Thread.Builder.OfPlatform
pour plus de souplesse.
-
Thread
-
+ startVirtualThread(Runnable task): Thread
-
+ ofVirtual(): Thread.Builder.OfVirtual
-
+ ofPlatform(): Thread.Builder.OfPlatform
-
+ isVirtual(): boolean
-
-
Thread.Builder.OfVirtual
-
+ name(String name): OfVirtual
-
+ name(String prefix, long start): OfVirtual
-
+ factory(): ThreadFactory
-
+ start(Runnable task): Thread
-
+ unstarted(Runnable task): Thread
-
Exemple pour un thread:
ThreadFactory factory = Thread.ofVirtual().name("virtual-exec").unstarted(task);
Exemple via une fabrique de threads:
ThreadFactory factory = Thread.ofVirtual().name("virtual-exec-", 0).factory();
Thread thread = factory.newThread(task);
java.util.concurrent.Executor
Les applications utilisent fréquemment un Executor
ou un ExecutorService
avec un pool de threads.
Or il n’est pas question de placer un virtual thread dans un pool, c’est contraire à leur nature.
Le JDK 21 a donc introduit des nouvelles méthodes qui construisent des executors avec un nouveau thread pour chaque tâche.
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()
-
Executors
-
+ newVirtualThreadPerTaskExecutor(): ExecutorService
-
+ newThreadPerTaskExecutor(ThreadFactory factory): ExecutorService
-
Je préfère généralement la variante longue à la méthode, avec une fabrique car avec la méthode newVirtualThreadPerTaskExecutor()
on construit des threads sans nom.
La fabrique permet d’avoir des noms de threads avec un préfixe et un compteur.
ThreadFactory factory = Thread.ofVirtual().name("virtual-exec-", 0).factory();
ExecutorService executor = Executors.newThreadPerTaskExecutor(factory);
Frameworks
Tomcat
Le support des virtual threads a été ajouté dans Tomcat 11 et backporté dans Tomcat 9 et 10.
Il peut être configuré pour un connector.
<Service name="Catalina">
<Connector port="8080" useVirtualThreads="true"/>
...
</Service>
Il peut aussi être utilisé via un executor.
<Service name="Catalina">
<Executor name="virtualThreadsExecutor"
className="org.apache.catalina.core.StandardVirtualThreadExecutor" />
<Connector executor="virtualThreadsExecutor"
port="8080" />
...
</Service>
Avec cette configuration, les threads ne sont plus préfixés par http-nio-exec
, avec tomcat-virt
.
Spring Boot
Depuis Spring Boot 3.2, on active le support des virtual threads avec une seule propriété,
spring
threads
virtual
enabled: true
Ça fait passer Tomcat ou Jetty embarqué sur des virtual threads. Pour Undertow, ça semble plus complexe à cause de fuites de mémoire et l’implémentation est repoussée (au moins après 3.3).
Dans le cas de Tomcat, les threads ne sont plus préfixés par http-nio-exec
, mais par tomcat-handler
.
La propriété touche aussi les threads pour les méthodes asynchrone (@Async
), le scheduling, Kafka, Redis et AMQP.
Evidemment, si on fait des configurations personnalisées pour ces modules, on risque d’écraser la nouvelle configuration par défaut.
Il y a d’ailleurs une nouvelle annotation conditionnelle @ConditionalOnThreading(Threading.VIRTUAL)
qu’on peut aussi utiliser dans nos configurations personnalisées.
Inspection
Ce sujet semble être le point faible des virtual threads.
J’imagine que des améliorations vont arriver. Les notes de ce paragraphe ont été faites avec le JDK 21, et peuvent devenir obsolètes.
Thread dump
Par défaut, un thread dump n’inclut pas les virtual threads.
C’est valable le bean JMX ThreadMXBean
, ainsi que pour les outils jstack
, jconsole
et visualvm
.
Je suppose que c’est parce que ces outils passent par ThreadMXBean
dont l’implémentation sun.management.ThreadImpl
appelle une méthode native.
Et qui dit natif, dit uniquement platform threads.
// Get an array of platform threads
ThreadInfo[] threads = ManagementFactory.getThreadMXBean().dumpAllThreads(true, true)
Avec jcmd, les threads virtuels n’apparaissent pas avec la commande Thread.print
, même avec l’option -e
(extended), mais uniquement avec Thread.dump_to_file
(qui n’est dans la documentation de référence qu’à partir du JDK 22).
~$ jcmd <pid> Thread.dump_to_file dump.txt
Cette nouvelle commande utilise le bean JMX HotSpotDiagnosticMXBean
qui conserve des informations de virtual threads.
Dommage que la méthode dumpThreads(…)
ne fonctionne qu’avec une écriture fichier.
Le endpoint /threaddump
de l’actuator de Spring Boot ne renvoie que les platform threads.
Flight recorder
Il y a 4 nouveaux événements dans JFR:
-
jdk.VirtualThreadStart
,jdk.VirtualThreadEnd
-
jdk.VirtualThreadSubmitFailed
-
jdk.VirtualThreadPinned
System properties
La propriété système jdk.trackAllThreads
influence le résultat de HotSpotDiagnosticMXBean.dumpThreads(…)
, et donc de la commande jcmd <pid> Thread.dump_to_file
.
Quand sa valeur est false
, les virtual threads
créés directement n’apparaissent plus dans le dump.
Par contre, les threads créés via un executor ne sont pas impactés.
La propriété n’est pas prise en compte si elle est modifiée en cours d’exécution avec System.setProperty("jdk.trackAllThreads", "false")
.
Il faut obligatoirement la renseigner en passant l’option à la VM java -Djdk.trackAllThreads=false …
.
La différence entre les threads créés directement et ceux par executor peut s’expliquer par une mécanique interne. Chaque thread est rattaché à un container (jdk.internal.vm.ThreadContainer
), soit racine (RootContainer
ou TrackingRootContainer
) pour les threads créés directement, soit pour un executor. Or le dump est configuré au niveau du conteneur.
La racine est exposée en fonction de la propriété, alors que les threads d'executor sont toujours exposés.
Mécanique interne
Carrier thread
En introduction, on a vu la différence entre un virtual thread et un platform thread.
Pour fonctionner, un virtual thread s’appuie sur un platform thread qui s’appelle alors un carrier thread. Dans sa vie, un virtual thread peut être attaché (mount) et détaché (unmount) plusieurs fois d’un carrier thread. Le détachement se fait à chaque fois qu’il y a des entrées/sorties bloquantes.
L’ensemble des carrier thread est géré sous la forme d’un ForkJoinPool
qui est dimensionné en fonction des processeurs disponibles.
Ce pool peut être configuré avec les propriétés système suivants:
-
jdk.virtualThreadScheduler.parallelism
-
jdk.virtualThreadScheduler.maxPoolSize
-
jdk.virtualThreadScheduler.minRunnable
Pinned thread
Il y a des cas particuliers à cette mécanique de détachement:
-
lorque l’entrée/sortie est dans un bloc
synchronized
, -
lors de l’appel d’une méthode native.
Dans une telle condition, on dit que le virtual thread est pinned.
Il faut l’éviter car le carrier thread est bloqué et on perd l’avantage des virtual threads.
Autant que possible, il faut remplacer les blocs synchronized
par d’autres familles de verrous.
Le diagnostic peut être fait avec la propriété système jdk.tracePinnedThreads
(full
/short
).
ThreadLocal
La mécanique de ThreadLocal
fonctionne avec les virtual thread comme avec les platform thread.
La propriétés système jdk.traceVirtualThreadLocals
permet de détecter la mise en ThreadLocal
de données.
Thread pools
Du fait de sa nature même, la spécification interdit de mettre des virtual threads en pool.
Daemon Thread
Un virtual thread est toujours de type daemon. Si un JVM n’a que des threads virtuels, elle s’arrête.