W3docs

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.

AspectThread de plateformeThread virtuel
Supporté parUn thread du système d'exploitationUn thread porteur dans un pool
Coût mémoire~1 Mo de pile fixeQuelques centaines d'octets, croît à la demande
Nombre pratiqueMilliersMillions
Idéal pourTravail lié au CPUTravail lié aux E/S, forte concurrence
Quand il se bloqueGaspille le thread du système d'exploitationSe démonte ; le porteur est réutilisé
Cycle de vieCréer un pool et réutiliserCré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 finish

Ne 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'épinglagePourquoi cela se produitRemède
Dans un bloc/méthode synchronizedLe moniteur est lié au porteurRemplacer 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.

java— editable, runs on the server

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 threads correspond à Available cores) : des milliers de threads virtuels sont multiplexés sur ce petit groupe de threads de plateforme.
  • Thread.sleep dé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() retourne true et isDaemon() retourne true — 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 synchronized qui encapsulent des appels bloquants et migrez-les vers ReentrantLock pour é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.

Exercice

Pratique
Vous encapsulez des threads virtuels dans Executors.newFixedThreadPool(200) pour exécuter 10 000 tâches liées aux E/S. Pourquoi est-ce une erreur ?
Vous encapsulez des threads virtuels dans Executors.newFixedThreadPool(200) pour exécuter 10 000 tâches liées aux E/S. Pourquoi est-ce une erreur ?
Was this page helpful?