🔝 Retour au Sommaire
Chapitre 8 — Concurrence et parallélisme · Retour au chapitre
Prérequis : sections 8.1 à 8.3.
Les deux sections précédentes décrivaient des façons de contourner le coût des threads : mutualiser (les pools, 8.2) ou éviter de bloquer (l'asynchrone, 8.3). Les threads virtuels, issus du Project Loom et finalisés dans Java 21 (JEP 444), prennent le problème à la racine : ils rendent les threads si bon marché qu'on peut de nouveau écrire du code bloquant, simple et linéaire — un thread par tâche — tout en montant à des centaines de milliers de tâches concurrentes. C'est l'évolution la plus marquante de la concurrence Java depuis vingt ans, et la raison pour laquelle ce chapitre porte l'indicateur 🔄.
Un thread plateforme est une fine enveloppe autour d'un thread du système d'exploitation. Il porte une pile fixe (de l'ordre du méga-octet), occupe sa ressource OS pendant toute sa vie, et son nombre est limité par le système : quelques milliers au maximum sur une machine ordinaire. Dans un serveur en modèle « un thread par requête », c'est le nombre de threads qui devient le goulet d'étranglement, bien avant le processeur ou le réseau.
Historiquement, deux réponses :
- Les pools (8.2) — réutiliser un nombre borné de threads. Mais il faut deviner la bonne taille, et une file d'attente peut s'engorger.
- L'asynchrone / réactif (8.3) — ne jamais bloquer. Mais le code devient un enchaînement de continuations, plus difficile à lire et à déboguer.
Les threads virtuels offrent une troisième voie : garder le modèle simple « un thread par tâche, qui bloque quand il attend », mais rendre la création et le blocage négligeables.
Un thread virtuel est une instance de Thread ordonnancée par la JVM, et non par le système. Le modèle est dit M:N : un grand nombre (M) de threads virtuels sont multiplexés sur un petit nombre (N) de threads porteurs (carriers) — des threads plateforme, regroupés dans un ForkJoinPool dimensionné par défaut sur le nombre de cœurs (configurable via des propriétés système jdk.virtualThreadScheduler.*).
flowchart TD
subgraph App["Threads virtuels — des milliers, voire des millions"]
VT1[VT]
VT2[VT]
VT3[VT]
VTn[...]
end
App --> Sched["Ordonnanceur de la JVM"]
Sched --> Carriers
subgraph Carriers["Threads porteurs — ≈ nombre de cœurs"]
CT1[Thread plateforme]
CT2[Thread plateforme]
end
Carriers --> OS["Threads du système d'exploitation"]
OS --> CPU["Cœurs du processeur"]
Le mécanisme clé est le montage / démontage (mount / unmount) :
- pour s'exécuter, un thread virtuel est monté sur un porteur ;
- dès qu'il rencontre une opération bloquante connue de la JVM (E/S réseau,
Thread.sleep, attente de verrou…), il se démonte : sa pile est mise de côté et le porteur est libéré pour exécuter un autre thread virtuel ; - quand l'opération se termine, le thread virtuel est remonté — éventuellement sur un porteur différent — et reprend où il s'était arrêté.
D'où la double économie qui fait toute la différence : un thread virtuel est bon marché à créer (petite pile redimensionnable sur le tas, pas de thread OS dédié) et bon marché à bloquer (bloquer ne mobilise plus un thread coûteux, juste un peu de mémoire). Détails à connaître : un thread virtuel est toujours démon et n'a pas de priorité significative.
Trois manières, déjà entrevues en 8.2 :
// Démarrage direct
Thread.startVirtualThread(() -> traiter(requete));
// Via le builder (nommage, configuration)
Thread vt = Thread.ofVirtual()
.name("requete-", 0)
.start(() -> traiter(requete));
// L'exécuteur recommandé côté serveur : UN thread virtuel par tâche
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var requete : requetes) {
executor.submit(() -> traiter(requete));
}
}Tout l'intérêt apparaît dans le corps des tâches : on écrit du code bloquant et séquentiel, sans aucune machinerie asynchrone, et il passe à l'échelle parce que chaque blocage libère le porteur.
// Code bloquant, simple et linéaire — mais porté par un thread virtuel
void traiter(Requete r) {
var utilisateur = bd.chargerUtilisateur(r.id()); // bloque : le porteur est libéré
var profil = api.chargerProfil(utilisateur); // bloque : idem
repondre(r, new Vue(utilisateur, profil));
}C'est exactement le point soulevé en section 8.3 : pour un enchaînement séquentiel d'appels bloquants, ce style remplace avantageusement une chaîne thenCompose.
Les threads virtuels brillent pour le travail dominé par les attentes (I/O-bound) et à forte concurrence. Ils n'apportent rien au travail gourmand en calcul : une tâche qui occupe réellement le processeur n'a aucune opération bloquante pour se démonter, donc elle monopolise son porteur de bout en bout — on reste plafonné par le nombre de cœurs. Pour ce cas, on garde les threads plateforme et les streams parallèles (chapitre 4).
| Privilégier les threads virtuels | Privilégier les threads plateforme |
|---|---|
| Appels réseau, requêtes en base | Calcul intensif, agrégations |
| E/S fichiers, appels d'API | Traitement d'images / vidéo |
| Tâches qui bloquent souvent | Tâches qui occupent le CPU en continu |
Deux réflexes opposés à garder en tête.
D'abord, on ne met jamais des threads virtuels dans un pool (rappel de 8.2) : ils sont conçus pour être créés en abondance, un par tâche. Un pool de taille fixe réintroduit le goulet qu'on cherchait à supprimer.
Ensuite — et c'est plus subtil — les threads virtuels suppriment la limite sur les threads, pas les limites des ressources en aval. Si un million de threads virtuels frappent simultanément un pool de 10 connexions de base de données, il faut brider cet accès, par exemple avec un Semaphore :
Semaphore connexions = new Semaphore(10); // borne l'accès au pool de 10 connexions
void requeteBd() throws InterruptedException {
connexions.acquire();
try {
// ... requête bloquante ...
} finally {
connexions.release();
}
}Le seul vrai piège des threads virtuels porte un nom : le pinning (« épinglage »). Un thread virtuel est épinglé lorsqu'il se bloque sans pouvoir se démonter : il garde alors son porteur immobilisé. Tant que cela arrive dans du code rarement exécuté, c'est sans conséquence. Mais sur un chemin chaud (exécuté à chaque requête), le pinning dégrade le débit — et, dans le pire cas, si tous les porteurs finissent épinglés, on aboutit à une famine, voire à un interblocage où plus aucun thread virtuel ne progresse.
Jusqu'à Java 23, le cas d'épinglage le plus fréquent venait du mot-clé synchronized. La JVM suivait la propriété d'un moniteur au niveau du porteur, pas du thread virtuel : se bloquer à l'intérieur d'un bloc synchronized épinglait donc le porteur. C'est la mauvaise surprise qui a frappé bien des équipes — passées aux threads virtuels, elles voyaient leur débit baisser, souvent à cause d'un synchronized enfoui dans une bibliothèque (pilote JDBC, client HTTP, adaptateur de journalisation…). Le contournement de l'époque consistait à remplacer synchronized par un ReentrantLock.
Java 24 a réglé le problème via JEP 491, en réécrivant l'implémentation des moniteurs pour suivre la propriété au niveau du thread virtuel. Désormais, se bloquer dans un bloc synchronized (ou dans Object.wait()) ne provoque plus d'épinglage : le porteur est rendu à l'ordonnanceur. C'est un changement interne à la JVM, sans aucune modification de code à faire.
Conséquence directe sur les bonnes pratiques : il n'est plus nécessaire de remplacer synchronized par ReentrantLock pour éviter le pinning. On choisit l'un ou l'autre sur ses mérites (voir section 8.6). Il n'est pas utile de revenir sur les anciens contournements, mais il faut cesser d'arracher les synchronized du code en 2026.
Quelques cas, étrangers à synchronized, continuent d'épingler — ils sont rares mais à connaître :
- Code natif : un appel à une méthode native (JNI) ou via la Foreign Function & Memory API, lorsque ce code natif rappelle du Java qui se bloque. C'est une limite fondamentale de la frontière Java / natif.
- Chargement de classe : un blocage pendant la résolution d'une référence symbolique ou l'initialisation d'une classe (cadre natif sur la pile).
Attention à un changement pratique souvent ignoré : l'ancienne option -Djdk.tracePinnedThreads (introduite avec les threads virtuels en Java 21) a été supprimée par JEP 491. Beaucoup de tutoriels la mentionnent encore : sur Java 24 et au-delà, elle n'existe plus. Le bon outil aujourd'hui est l'événement JDK Flight Recorder jdk.VirtualThreadPinned, étendu pour indiquer la raison de l'épinglage et l'identité du porteur. On l'observe via JFR / JDK Mission Control (voir section 13.3), en se concentrant sur les chemins critiques.
🔧 Astuce Eclipse. Avec des milliers de threads virtuels, la liste de la vue Debug devient ingérable. En pratique, on ne déboge pas ce type de charge thread par thread : on s'appuie sur le nommage des threads et sur JFR plutôt que sur le pas-à-pas. Approche détaillée en section 8.8.
Un dernier point lié à l'échelle. Les variables ThreadLocal stockent une copie par thread : avec des millions de threads virtuels, un usage intensif peut faire exploser la mémoire. L'alternative moderne est ScopedValue (valeurs de portée, finalisé dans Java 25 via JEP 506) : une valeur immuable, partagée le long d'une chaîne d'appels pour une durée bornée, conçue précisément pour bien se marier avec les threads virtuels et la concurrence structurée (section 8.5).
Bonne nouvelle pour le quotidien : l'écosystème a largement rattrapé son retard. Spring Boot (depuis la 3.2) et les serveurs Jakarta EE savent traiter chaque requête sur un thread virtuel, généralement via un simple drapeau de configuration — sujet repris au chapitre 12. Les bibliothèques d'E/S courantes ont par ailleurs été adaptées pour éviter les anciens points de pinning.
Les threads virtuels suppriment une contrainte — le coût du thread —, pas la difficulté de fond de la concurrence. Parce qu'il devient trivial d'en lancer des milliers, il est aussi plus facile de faire accéder des milliers de tâches au même état mutable sans protection. Les race conditions et les problèmes de visibilité mémoire restent entiers : la synchronisation (8.6) et les collections concurrentes (8.7) demeurent indispensables.
- Un thread virtuel est un
Threadordonnancé par la JVM, multiplexé sur quelques porteurs (modèle M:N). Il est bon marché à créer et à bloquer. - En se bloquant, il se démonte et libère son porteur : on peut donc écrire du code bloquant, linéaire, qui monte à des centaines de milliers de tâches.
- On les crée avec
Thread.ofVirtual(),Thread.startVirtualThread(...)ouExecutors.newVirtualThreadPerTaskExecutor(); on en crée un par tâche (jamais de pool), mais on bride l'accès aux ressources en aval (ex.Semaphore). - Ils sont faits pour les E/S à forte concurrence, pas pour le calcul intensif (toujours plafonné par les cœurs).
- Le pinning est le point de vigilance : le cas
synchronizedest résolu depuis Java 24 (JEP 491) ; il reste rare et lié au code natif et au chargement de classe. On le détecte via l'événement JFRjdk.VirtualThreadPinned(l'option-Djdk.tracePinnedThreadsa disparu). - Préférer
ScopedValue(Java 25) àThreadLocalà grande échelle.⚠️ Les threads virtuels ne dispensent pas de protéger l'état partagé (cf. 8.6 et 8.7).
⬅️ 8.3 — CompletableFuture et composition asynchrone · Sommaire · 8.5 — Concurrence structurée (StructuredTaskScope) ➡️