Java Virtual Threads : guide pratique
1. Aperçu
Les virtual threads sont une fonctionnalité officielle de Java 21 (JEP 444), vous permettant d’écrire du code synchrone (style blocking) tout en atteignant un débit aussi élevé que celui des I/O non-bloquantes. Cet article couvre les concepts, la création de virtual threads, une comparaison des performances avec les platform threads, et l’intégration dans Spring Boot. Après sa lecture, vous saurez quand utiliser les virtual threads et quand les éviter.
2. Le problème avec les Platform Threads traditionnels
Chaque platform thread en Java correspond 1-1 à un OS thread. Un OS thread consomme beaucoup de mémoire (généralement 1 MB de stack) et la création/commutation de contexte (context switch) a un coût élevé. Par conséquent, les applications traitant des dizaines de milliers de requêtes simultanées (par exemple, un serveur attendant des I/O) sont souvent limitées par le nombre de threads que le pool peut créer.
ExecutorService executor = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
try {
Thread.sleep(1000); // simuler un appel I/O
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
Avec un pool de 200 threads, 10 000 tâches doivent attendre leur tour, le temps total de complétion s’allonge car chaque thread ne peut traiter qu’une tâche à la fois, alors qu’une grande partie du temps est passée en attente (bloquée), et non en calcul.
3. Qu’est-ce qu’un Virtual Thread
Un virtual thread est un thread géré par la JVM (pas par l’OS), « monté » sur un platform thread (appelé carrier thread) uniquement lorsqu’il exécute du code. Lorsqu’un virtual thread appelle une opération bloquante (I/O, Thread.sleep, lock…), la JVM le « démonte » automatiquement du carrier thread, libérant le carrier thread pour traiter d’autres virtual threads.
En résultat : vous pouvez créer des millions de virtual threads sans craindre d’épuiser les ressources, tandis que le code reste écrit dans le style blocking habituel — pas besoin de CompletableFuture, pas besoin de programmation réactive.
3.1. Créer un virtual thread simplement
Thread vThread = Thread.ofVirtual()
.name("order-processor")
.start(() -> {
System.out.println("Exécution sur : " + Thread.currentThread());
});
vThread.join();
Thread.ofVirtual() retourne un builder pour configurer le thread virtuel. .start() crée et l’exécute immédiatement. L’affichage du thread montrera quelque chose comme :
Exécution sur : VirtualThread[#21,order-processor]/runnable@ForkJoinPool-1-worker-1
Notez la partie @ForkJoinPool-... — c’est le carrier thread qui « porte » actuellement ce virtual thread.
4. Executor pour Virtual Threads
La façon la plus courante d’utiliser les virtual threads dans une application réelle est via ExecutorService.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
int id = i;
futures.add(executor.submit(() -> {
Thread.sleep(1000); // simuler un appel I/O (BD, HTTP…)
return "Tâche " + id + " terminée";
}));
}
for (Future<String> f : futures) {
f.get();
}
}
newVirtualThreadPerTaskExecutor() crée un virtual thread nouveau pour chaque tâche, sans réutiliser les threads comme un pool de threads traditionnel. Puisque le coût de création d’un virtual thread est très faible (quelques centaines d’octets, pas 1 MB), c’est tout à fait raisonnable. Le bloc try-with-resources garantit que l’executor est fermé et attend que les tâches se terminent à la sortie du bloc.
Contrairement à l’exemple de la section 2 (pool de 200 platform threads), ce code termine les 10 000 tâches en environ 1 seconde (car il n’y a plus de limite de pool), au lieu d’attendre dans la queue à plusieurs reprises.
5. Comparaison des performances avec un test réel
class VirtualThreadBenchmarkTest {
@Test
void virtualThreadsShouldHandleManyBlockingTasksFast() throws InterruptedException {
int taskCount = 10_000;
CountDownLatch latch = new CountDownLatch(taskCount);
Instant start = Instant.now();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
}
latch.await();
Duration elapsed = Duration.between(start, Instant.now());
assertThat(elapsed).isLessThan(Duration.ofSeconds(5));
}
}
Ce test crée 10 000 tâches, chaque tâche dort pendant 100 ms. Si exécuté séquentiellement, cela prendrait 1000 secondes, mais avec les virtual threads, tout se termine en quelques secondes car les tâches s’exécutent réellement en parallèle. CountDownLatch est utilisé pour attendre que toutes les tâches se terminent avant de faire l’assertion.
Remarque : Un benchmark réel devrait mesurer également le CPU et la mémoire avec JFR (Java Flight Recorder) au lieu de juste compter le temps, car le temps dépend aussi de la machine exécutant le test.
6. Écueil : Pinning lors de l’utilisation de synchronized
Un virtual thread ne se démonte pas du carrier thread s’il se trouve dans un bloc synchronized — ce phénomène s’appelle pinning. Dans ce cas, une I/O bloquante à l’intérieur de synchronized occupera le carrier thread en permanence, éliminant les bénéfices des virtual threads.
public class InventoryService {
private final Object lock = new Object();
public void reserveStock(String sku) {
synchronized (lock) {
callExternalInventoryApi(sku); // I/O bloquante à l'intérieur de synchronized -> pinning !
}
}
}
La ligne synchronized (lock) maintient le carrier thread occupé pendant toute la durée de l’appel callExternalInventoryApi, même si c’est une opération I/O. Si des milliers de virtual threads se bloquent simultanément, le pool de carrier threads (par défaut égal au nombre de cœurs CPU) s’épuisera rapidement.
Solution : remplacez synchronized par java.util.concurrent.locks.ReentrantLock, qui est bien compatible avec les virtual threads (ne cause pas de pinning).
public class InventoryService {
private final ReentrantLock lock = new ReentrantLock();
public void reserveStock(String sku) {
lock.lock();
try {
callExternalInventoryApi(sku);
} finally {
lock.unlock();
}
}
}
ReentrantLock permet au virtual thread de se démonter normalement lorsqu’il rencontre une I/O bloquante, préservant l’essence « légère » des virtual threads.
7. Intégration des Virtual Threads dans Spring Boot
À partir de Spring Boot 3.2, il vous suffit d’activer une propriété pour que Spring Web (Tomcat) utilise des virtual threads pour chaque requête.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
spring.threads.virtual.enabled=true
Cette propriété active virtualThreadExecutor pour Tomcat (et d’autres executors supportés par Spring Boot). Chaque requête HTTP entrante sera traitée sur un virtual thread dédié, au lieu de partager un pool de platform threads fixe (200 threads par défaut avec Tomcat).
@RestController
@RequestMapping("/orders")
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
return orderService.fetchOrderWithDetails(id); // appel BD + autre API, style blocking normal
}
}
Le code du contrôleur n’a besoin d’aucune modification — vous écrivez toujours dans le style blocking habituel. Lors du test de charge avec des outils comme ab ou k6, vous verrez une augmentation notable du débit pour les endpoints avec une latence I/O élevée (appels BD, appels à d’autres services), car Tomcat n’est plus limité par les 200 threads par défaut.
8. Quand NE PAS utiliser les Virtual Threads
- CPU-bound task : Si le travail est principalement du calcul (pas d’I/O à attendre), les virtual threads n’apportent aucun bénéfice — le nombre de carrier threads est toujours limité par le nombre de cœurs CPU. Utilisez plutôt
ForkJoinPoolouparallelStream(). - Code utilisant beaucoup de
ThreadLocal: Les virtual threads sont compatibles avecThreadLocal, mais si vous créez des millions de virtual threads, chacun stockant de grandsThreadLocalconsommera beaucoup de mémoire — considérez l’utilisation deScopedValue(version préliminaire en Java 21) à la place. - Anciennes bibliothèques utilisant
synchronizedpartout : Comme expliqué à la section 6, le pinning peut silencieusement réduire les performances de manière que vous ne remarquez pas immédiatement. - Connection pool limité : Les virtual threads permettent de créer de nombreuses requêtes en parallèle, mais si le pool de connexions BD n’en contient que 20, vous serez toujours limité là-bas — vous devrez augmenter la taille du pool en conséquence ou utiliser un connection pool supportant bien la concurrence élevée.
Remarque : Les virtual threads résolvent le problème du nombre de threads, non le problème de la vitesse de traitement de chaque tâche. Ne vous attendez pas à ce qu’ils rendent le code CPU-bound plus rapide.
9. Conclusion
Les virtual threads vous permettent d’écrire du code blocking simple tout en gérant bien des milliers de requêtes simultanées, particulièrement adapté aux applications I/O-bound comme les web services et les microservices appelant de nombreuses API/BD. Vous n’avez besoin que de changer l’executor ou d’activer une propriété dans Spring Boot, sans réécrire la logique selon un style réactif. Cependant, faites attention au piège du pinning avec synchronized et rappelez-vous que les virtual threads n’aident pas pour les tâches CPU-bound.