Virtual Threads Java : la fin du réactif obligatoire ?
· 9 min de lecture
Depuis Java 21, les threads virtuels permettent d’écrire du code bloquant qui passe à l’échelle. Faut-il abandonner WebFlux pour autant ?
En bref
Un serveur Java passe beaucoup de temps à attendre, par exemple une base de données. Depuis Java 21, il peut attendre pour des milliers de clients à la fois, avec un code simple. L’article explique comment, et les pièges à connaître.
Un mot technique vous échappe ? Le glossaire du développement web l’explique simplement.
Pendant des années, tenir des dizaines de milliers de connexions simultanées en Java imposait la programmation réactive : Mono, Flux, chaînes d’opérateurs et stack traces illisibles. Les virtual threads du projet Loom changent la donne.
Le principe
Un thread virtuel est géré par la JVM et non par l’OS. Quand il bloque sur une I/O, la JVM le détache de son thread porteur, qui peut aussitôt exécuter un autre thread virtuel. On peut en créer des millions pour un coût mémoire de quelques kilo-octets chacun.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
})
);
} // 100,000 tasks completed in ~1 secondAvec Spring Boot
Depuis Spring Boot 3.2, une seule propriété suffit pour que Tomcat traite chaque requête dans un thread virtuel :
spring.threads.virtual.enabled=trueLes pièges
- Le pinning : un bloc synchronized autour d’une I/O bloque le thread porteur (largement corrigé depuis Java 24).
- Les pools de connexions restent la vraie limite : 10 000 threads virtuels pour 10 connexions JDBC, c’est 9 990 threads en attente.
- Les ThreadLocal coûteux se multiplient : préférer les ScopedValue.
Ce que les virtual threads ne font pas
Les threads virtuels ne rendent pas le code plus rapide : ils permettent d’attendre à moindre coût. Une application qui passe son temps à calculer, sans entrées-sorties, n’y gagne rien, car le nombre de cœurs du processeur reste la vraie limite. Le gain se mesure sur les applications qui attendent beaucoup : base de données, API externes, fichiers.
Limiter la concurrence autrement
Avec des threads système, la taille du pool limitait naturellement le nombre d’appels simultanés vers un service fragile. Avec un thread virtuel par tâche, cette protection disparaît. Un Semaphore la rétablit explicitement :
private final Semaphore permits = new Semaphore(20);
public Quote fetchQuote(String id) throws InterruptedException {
permits.acquire();
try {
return partnerClient.getQuote(id);
} finally {
permits.release();
}
}ScopedValue plutôt que ThreadLocal
Stables depuis Java 25, les ScopedValue transmettent une donnée de contexte, comme l’utilisateur courant, à tout le code appelé. Il n’y a pas de copie par thread, et la donnée a une durée de vie limitée :
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
ScopedValue.where(CURRENT_USER, user).run(() -> handle(request));
// Further down the call stack
User user = CURRENT_USER.get();Avant Java 24, il faut savoir repérer le pinning. L’événement JFR jdk.VirtualThreadPinned le signale : il indique chaque thread virtuel bloqué sur son thread porteur, avec la pile d’appels en cause.
Ce n’est donc pas la fin du réactif, mais la fin du réactif obligatoire : il garde son intérêt pour le streaming et la gestion fine de la backpressure, mais pour une API REST classique, le bon vieux code impératif redevient le choix le plus simple et le plus performant. Reste ensuite à conteneuriser l’application avec une image légère (voir « Dockerfile Java : une image légère, sûre et rapide »).