Virtual Threads

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.