Les virtual threads (Project Loom) sont passés stable dans Java 21 LTS en septembre 2023, puis ont été substantiellement améliorés dans Java 25 LTS sorti en septembre 2025 — notamment via JEP 491 qui résout le défaut de pinning sur les sections synchronisées, considéré comme la limitation la plus opérationnellement significative depuis l’introduction. En 2026, après deux ans de retours de production, virtual threads sont devenus le défaut pour tout service Java I/O-bound : API REST, microservices, ETL, gateways. Ce tutoriel reprend l’usage en production : structured concurrency, scope, observabilité, pièges du pinning, et migration depuis un pool de threads classique.
Prérequis
- Java 25 LTS recommandé (Java 21 LTS minimum)
- Notions de threads, executors, exécution asynchrone
- Un projet Spring Boot 3.5+/4.0+ ou Java SE (cf. Première app Spring Boot)
- Temps estimé : 90 minutes
Étape 1 — Comprendre la différence platform vs virtual thread
Un platform thread (l’ancien Thread historique) consomme ~1 Mo de stack et est mappé 1:1 à un thread OS. Sur une JVM de 8 Go, on plafonne à ~8000 platform threads simultanés avant de manquer de mémoire. Un virtual thread, lui, consomme < 1 Ko, vit dans la heap, et est multiplexé sur un petit pool de carrier platform threads par le scheduler JVM. On en crée des millions sans souci.
// Platform thread classique
Thread platform = Thread.ofPlatform()
.name("worker-1")
.start(() -> System.out.println("hello"));
// Virtual thread (Java 21+)
Thread virtual = Thread.ofVirtual()
.name("vthread-1")
.start(() -> System.out.println("hello"));
// Vérifier le type
System.out.println(Thread.currentThread().isVirtual());
// Créer un million de virtual threads en démonstration
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 1_000_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return null;
});
});
} // attend toutes les tâches
La clé conceptuelle : un virtual thread bloqué sur un I/O (lecture socket, requête HTTP) libère son carrier platform thread, qui peut servir un autre virtual thread. C’est ce mécanisme qui permet à un service de gérer 100 000 connexions concurrentes avec seulement quelques dizaines de threads OS. L’API Thread.sleep(), Socket.read(), HttpClient.send() sont rendues compatibles Loom : elles déparkent automatiquement le virtual thread sans bloquer le carrier.
Étape 2 — Quand utiliser virtual threads
Virtual threads brillent pour le code I/O-bound : appels réseau, base de données, fichiers, attentes sur queues. Ils n’apportent rien pour le CPU-bound (calcul intensif) où vous restez limité par le nombre de cœurs physiques. La règle pratique : si votre profil de production passe > 30 % du temps en Thread.sleep, attente réseau, ou attente base, virtual threads vont vous transformer.
// Cas idéal : agréger des appels HTTP indépendants
public List<UserData> agreger(List<String> userIds) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = userIds.stream()
.map(id -> executor.submit(() -> {
// appel HTTP bloquant — libère le carrier !
return httpClient.send(
HttpRequest.newBuilder(URI.create(api + id)).build(),
HttpResponse.BodyHandlers.ofString()
).body();
}))
.toList();
return futures.stream()
.map(f -> { try { return f.get(); } catch (Exception e) { throw new RuntimeException(e); } })
.map(this::parse)
.toList();
}
}
L’Executors.newVirtualThreadPerTaskExecutor() crée un virtual thread par tâche soumise, sans pool fixe. Le try-with-resources attend la terminaison de toutes les tâches en sortie. Cette ergonomie remplace les CompletableFuture.thenCompose().exceptionally() qui sont devenus inutiles avec les virtual threads : on peut écrire du code séquentiel naturel qui scale comme du async.
Étape 3 — Structured concurrency avec StructuredTaskScope
Java 25 livre la 5e preview de structured concurrency (JEP 505) via la classe StructuredTaskScope, équivalent du TaskGroup Python. L’API reste en preview avec stabilisation prévue JDK 27 — les sous-classes ShutdownOnFailure/ShutdownOnSuccess historiques sont remplacées par un Joiner passé à StructuredTaskScope.open(...). Le principe : un bloc try-with-resources qui garantit qu’aucune sous-tâche ne survit au-delà de sa portée. Si une lève, les sœurs sont annulées proprement.
import java.util.concurrent.StructuredTaskScope;
public AgregatResultat agreger() throws Exception {
try (var scope = StructuredTaskScope.open(Joiner.allSuccessfulOrThrow())) {
var profil = scope.fork(() -> userClient.getProfile(userId));
var commandes = scope.fork(() -> commandesClient.getRecentes(userId));
var notifs = scope.fork(() -> notifClient.getNonLues(userId));
scope.join(); // attend tous ; lève StructuredTaskScope.FailedException si un fork échoue
return new AgregatResultat(profil.get(), commandes.get(), notifs.get());
}
}
Trois disciplines clés. Joiner.allSuccessfulOrThrow() annule les sœurs dès qu’une tâche lève (court-circuit sur erreur, équivalent de l’ancien ShutdownOnFailure). Joiner.anySuccessfulResultOrThrow() arrête dès le premier succès (utile pour race entre cache et source de vérité). Le scope.join() respecte automatiquement le InterruptedException remontant depuis le code appelant. Avec le Joiner allSuccessfulOrThrow, scope.join() propage la première exception fille via une FailedException dont la cause est l’exception originale (plus besoin de throwIfFailed() séparé, supprimé en JEP 505).
Étape 4 — Activer virtual threads dans Spring Boot
Spring Boot 3.2+ et 4.x intègrent virtual threads via une seule propriété. Toutes les requêtes HTTP arrivantes, les tâches @Async, et les jobs scheduled tournent alors sur virtual threads, sans changement de code applicatif.
# application.yml
spring:
threads:
virtual:
enabled: true
# Vérifier dans /actuator/metrics
# - tomcat.threads.busy (devrait être très bas avec vthreads)
# - jvm.threads.live (compte les vthreads aussi)
Pour Tomcat 11+ (par défaut dans Spring Boot 4.0), cela bascule l’Executor sur VirtualThreadPerTaskExecutor. Sur charge réelle, l’effet est immédiat : si votre API faisait 500 req/s avec 200 platform threads, elle monte typiquement à 2000-5000 req/s avec virtual threads, en utilisant 2-4× moins de RAM. Le bénéfice est proportionnel au temps passé en I/O dans chaque requête.
Étape 5 — Pièges du pinning et JEP 491
Le grand défaut historique de Loom était le pinning : si un virtual thread entrait dans un bloc synchronized et y bloquait sur un I/O, il pinnait son carrier platform thread (qui ne pouvait pas servir d’autres vthreads). Sur des bases de code utilisant largement synchronized (anciens drivers JDBC, vieux pools de connexions), virtual threads perdaient leur intérêt.
// AVANT Java 25 : ceci PINNAIT le carrier
public synchronized String getData() {
return blockingHttpCall(); // bloque le carrier !
}
// Workaround historique : remplacer synchronized par ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public String getData() {
lock.lock();
try {
return blockingHttpCall(); // ne pinne pas
} finally {
lock.unlock();
}
}
// Java 25 (JEP 491) : synchronized ne pinne PLUS
public synchronized String getData() {
return blockingHttpCall(); // libère le carrier proprement
}
JEP 491, livré dans JDK 24 (mars 2025) et présent dans Java 25 LTS, élimine ce problème : un virtual thread bloqué dans un synchronized libère désormais son carrier. C’est l’amélioration qui justifie à elle seule la migration Java 21 → 25 LTS pour les services à fort I/O. Si vous restez sur Java 21, l’audit du code pour bannir les synchronized sur I/O reste impératif.
Étape 6 — Observabilité et debugging
Les virtual threads apparaissent dans les outils standards : jstack, jcmd, JFR (Java Flight Recorder), profilers. JFR est la voie de référence pour diagnostiquer en production : il enregistre les événements de scheduling, les pinning, les chevauchements.
# Démarrer JFR au runtime
jcmd <pid> JFR.start name=vthread settings=profile duration=60s filename=vthread.jfr
# Événements pertinents :
# - jdk.VirtualThreadStart
# - jdk.VirtualThreadEnd
# - jdk.VirtualThreadPinned (KEY : à zéro idéalement)
# - jdk.VirtualThreadSubmitFailed
# Analyser avec JDK Mission Control
jmc vthread.jfr
L’événement VirtualThreadPinned est le canari : il signale chaque pinning > 20 ms (configurable). Sur Java 25, vous ne devriez en voir aucun en charge normale. Sur Java 21, ils trahissent un synchronized sur I/O à refactoriser. Pour le logging applicatif, ajouter %X{traceId} dans le pattern Logback fonctionne identiquement avec virtual threads — le contexte MDC suit naturellement.
Étape 7 — ScopedValue : remplace ThreadLocal
Les ThreadLocal coûtent cher avec virtual threads (entrée dans une map par thread), et leur sémantique d’héritage par InheritableThreadLocal est cassée dans le modèle Loom. ScopedValue (preview Java 21, stable Java 25) est l’alternative officielle : valeur immutable, scopée à un bloc, héritée naturellement par les sous-tâches.
public class RequestContext {
public static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
}
// Au point d'entrée d'une requête
ScopedValue.where(RequestContext.USER_ID, currentUserId)
.where(RequestContext.TRACE_ID, traceId)
.run(() -> serviceMethod());
// Dans le service
public void serviceMethod() {
var userId = RequestContext.USER_ID.get(); // accède au scope ambient
log.info("Traitement pour user={}", userId);
}
Avantage majeur : ScopedValue est immuable dans son scope (pas de mutation accidentelle), automatiquement nettoyé à la sortie (pas de fuite mémoire), et 5-10× plus rapide en lecture qu’un ThreadLocal dans un virtual thread. Pour la propagation de contexte traceId, userId, tenantId, c’est le bon outil en 2026.
Étape 8 — Migration depuis pool de threads
Un service Java legacy basé sur ExecutorService avec pool fixe peut migrer progressivement. La règle d’or : remplacer un par un les pools I/O-bound par newVirtualThreadPerTaskExecutor, garder les pools CPU-bound tels quels.
// AVANT : pool fixe avec 50 threads, bottleneck à fort débit
private final ExecutorService pool = Executors.newFixedThreadPool(50);
// APRÈS : virtual threads, illimité, gratuits
private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
// Code appelant identique
public CompletableFuture<String> fetch(String url) {
return CompletableFuture.supplyAsync(() -> doHttpGet(url), pool);
}
Avant migration, mesurer le profil charge (CPU vs IO wait) avec vmstat, top, ou JFR. Si IO wait dépasse 30 %, virtual threads vont libérer du débit gratuitement. Si le service est CPU-bound (transformation, parsing, calcul), basculer ne change rien — voire pénalise marginalement à cause du contexte switching JVM additionnel. Toujours benchmarker avant/après.
Erreurs fréquentes
| Symptôme | Cause | Solution |
|---|---|---|
| Pas de gain de débit avec vthreads | Code CPU-bound ou synchronized partout (Java 21) | Profil avec JFR ; migrer Java 25 ; remplacer synchronized par ReentrantLock |
| OutOfMemoryError : Java heap space | Trop de virtual threads créent trop d’objets | Borner avec semaphore ou rate limiter, garder backpressure |
| ThreadLocal cassé en sous-tâche | InheritableThreadLocal non transmis | Utiliser ScopedValue |
| Latence inattendue ajoutée | Trop de vthreads sur peu de carriers | Augmenter jdk.virtualThreadScheduler.parallelism |
| VirtualThreadPinned massif en JFR | Driver JDBC ou bibliothèque vieille | Mettre à jour drivers (HikariCP 5.1+, JDBC 4.4+, etc.) |
| Pile d’appel tronquée | Stack trace virtual thread plus court | JFR ou jcmd pour analyse approfondie |
Foire aux questions
Java 21 ou 25 LTS ?
Java 25 si nouveau projet ou migration prévue. Java 21 reste valide jusqu’à septembre 2026 pour les MAJ Oracle gratuites. Java 25 corrige le pinning synchronized — fortement recommandé pour services à I/O.
Virtual threads et réactif (WebFlux, RxJava) ?
Virtual threads rendent le réactif moins nécessaire pour la plupart des cas. WebFlux reste utile pour back-pressure explicite ou interopérabilité avec stack réactive existante. Pour un nouveau service simple : MVC + vthreads suffit.
Limites de scale ?
Millions de virtual threads simultanés sur une JVM 8 Go (testé jusqu’à 5M). Le bottleneck devient la base de données ou le service appelé, plus la JVM.
Compatibilité avec Spring/Hibernate ?
Spring Boot 3.2+ ✓, Hibernate 6.3+ ✓, HikariCP 5.1+ ✓, Tomcat 11+ ✓. Drivers JDBC : tous sauf très anciens (Oracle 12c et antérieurs).
Combien de carriers par défaut ?
Nombre de cœurs CPU. Configurable via -Djdk.virtualThreadScheduler.parallelism=N. Rarement utile de modifier.
Pour aller plus loin
Virtual threads en place, l’étape suivante consiste à compiler en native via GraalVM pour des démarrages instantanés, ou structurer la résilience avec Resilience4j. Vue panoramique : Java Enterprise moderne.