1. L’essentiel en bref
Vous connaissez déjà un peu les classes du paquet java.util.concurrent, en particulier ExecutorService. C’est un « gestionnaire de tâches » : vous lui soumettez du travail (par exemple via submit()), et il décide quand et avec quel thread l’exécuter. En général, sous le capot, un pool de threads de taille fixe économise les ressources et évite de créer un nouveau thread pour chaque tâche.
Avec les threads virtuels, tout change ! On peut désormais se permettre le luxe d’avoir un thread par tâche, sans craindre que la JVM « explose » d’indigestion.
Nouvelle approche : Executors.newVirtualThreadPerTaskExecutor()
Depuis Java 21, il existe une nouvelle façon de créer un ExecutorService qui exécute chaque tâche dans un thread virtuel distinct :
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Différence importante :
- Les anciens pools de threads (Executors.newFixedThreadPool, Executors.newCachedThreadPool) limitaient le nombre de tâches simultanées en raison du coût élevé des threads du système d’exploitation.
- Le nouvel exécuteur virtuel est quasiment sans limite : une tâche → un thread virtuel léger.
Exemple simple
Envoyons 10 tâches à l’exécuteur virtuel :
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10; i++) {
int taskId = i; // on capture la variable pour la lambda
executor.submit(() -> {
System.out.println("Task " + taskId + " is running in thread: " +
Thread.currentThread());
});
}
executor.shutdown();
}
}
Que se passe-t-il ?
Chaque tâche sera exécutée dans son propre thread virtuel, et vous verrez des lignes du type :
Task 1 is running in thread: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1
...
2. Parallélisme massif : des milliers de tâches — pas un problème !
Pour mesurer toute la puissance des threads virtuels, essayons d’envoyer au ExecutorService non pas 10 mais, disons, 100_000 tâches. Avec des pools classiques, ce serait comme tenter de mettre un éléphant dans un réfrigérateur : la JVM épuiserait vite la mémoire ou deviendrait très lente. Avec les threads virtuels, c’est différent !
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorMassiveDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 100_000; i++) {
int taskId = i;
executor.submit(() -> {
// Pour l'exemple, on dort 1 ms
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// System.out.println("Task " + taskId + " done."); // Ne pas afficher, sinon il y aura trop de lignes!
});
}
executor.shutdown();
}
}
Attention : afficher 100_000 lignes à l’écran est une mauvaise idée : la console « sature » bien plus vite que les threads virtuels. Mieux vaut ne pas écrire dans la console, ou n’afficher que les premières tâches.
3. Fonctionnement de newVirtualThreadPerTaskExecutor
En bref : cet ExecutorService crée un nouveau thread virtuel pour chaque tâche que vous lui soumettez. Contrairement à un pool fixe, il n’y a pas de file d’attente des tâches ni de limites strictes sur le nombre de threads simultanés (en dehors des limites de votre JVM et du matériel).
Sur le plan architectural :
- Les threads virtuels sont mappés sur un petit pool de threads « réels » (carrier) du système d’exploitation.
- La JVM décide elle-même quand exécuter, suspendre et reprendre un thread virtuel.
- Si un thread se bloque (par exemple sur une lecture de fichier ou en attente réseau), la JVM peut « geler » le thread virtuel et libérer le thread porteur pour d’autres tâches.
4. Exemple : traitement des résultats avec Future
ExecutorService renvoie un objet de type Future si la tâche renvoie un résultat. Tout fonctionne comme avec les threads classiques :
import java.util.concurrent.*;
public class VirtualExecutorWithResult {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(500);
return "Hello from virtual thread!";
});
System.out.println("Result: " + future.get()); // On attend le résultat
executor.shutdown();
}
}
Tout est classique : on peut soumettre des tâches qui renvoient une valeur, attendre le résultat via get(), et les exceptions sont gérées de manière standard.
5. Bien arrêter un Executor
Il est très important de ne pas oublier d’arrêter ExecutorService, afin que le programme ne se bloque pas (même si les threads sont virtuels et non « réels »).
shutdown() et awaitTermination
executor.shutdown(); // On indique qu'on n'accepte plus de tâches
executor.awaitTermination(1, TimeUnit.MINUTES); // On attend la fin de toutes les tâches (1 minute max)
Pourquoi est-ce important ?
Si vous n’appelez pas shutdown(), les threads virtuels peuvent continuer à vivre, et le programme ne se terminera pas même après l’exécution de main(). C’est une erreur typique des débutants.
6. Points utiles
Comparaison : exécuteur virtuel vs pool de threads classique
| Pool classique (newFixedThreadPool) | Exécuteur virtuel (newVirtualThreadPerTaskExecutor) | |
|---|---|---|
| Nombre de threads | Limité par la taille du pool | Un thread virtuel par tâche, quasi illimité |
| Tâches en file d’attente | Oui, si tous les threads sont occupés | En général non : la tâche obtient immédiatement un thread |
| Coût d’un thread | Élevé (pile, ressources du système) | Très faible (ordonnancement pris en charge par la JVM) |
| Scalabilité | Limitée | Presque illimitée |
| Cas d’usage adaptés | Tâches CPU-bound, parallélisme limité | Tâches I/O-bound, parallélisme massif |
Intégration avec les serveurs web
Les serveurs web modernes (par exemple Tomcat, Jetty, Undertow) commencent déjà à prendre en charge les threads virtuels. Cela signifie qu’on peut traiter chaque requête HTTP dans un thread virtuel distinct, sans craindre d’être submergé lors des pics d’affluence.
Avantage : pas besoin d’imaginer des schémas asynchrones complexes avec des callbacks et CompletableFuture ; le code devient plus simple — on peut écrire du code bloquant habituel, et l’application se met tout de même à l’échelle.
Tests massifs et simulation de charge
Les threads virtuels conviennent parfaitement aux tests où il faut « simuler » des milliers d’utilisateurs, de requêtes ou d’opérations simultanés. Par exemple, un test qui envoie 10_000 requêtes parallèles à un serveur, chacune dans son propre thread virtuel.
Traitement parallèle des fichiers et des connexions réseau
Si l’application manipule un grand nombre de fichiers ou de connexions réseau, on peut traiter chaque connexion dans un thread virtuel distinct, sans se soucier d’une gestion manuelle des pools.
7. Erreurs fréquentes avec les exécuteurs virtuels
Erreur n° 1 : vous avez oublié d’appeler shutdown(). Si vous ne fermez pas l’exécuteur, le programme ne se terminera pas — les threads virtuels attendront encore de nouvelles tâches. Si nécessaire, ajoutez awaitTermination(...).
Erreur n° 2 : utiliser des threads virtuels pour des calculs lourds. Les threads virtuels n’accélèrent pas les tâches qui saturent le CPU. Pour du CPU‑bound, mieux vaut utiliser un pool fixe (Executors.newFixedThreadPool) et ajuster soigneusement la taille.
Erreur n° 3 : ignorer les exceptions dans les tâches. Si une tâche lève une exception, elle n’arrivera pas dans le thread principal — gérez‑la via Future (méthode get()) ou avec try/catch à l’intérieur de la lambda.
Erreur n° 4 : confondre l’ancienne syntaxe ou la version du JDK. Vérifiez que vous utilisez une version de JDK compatible (Java 21+) et que l’IDE est configurée pour prendre en charge les threads virtuels. La méthode précise est Executors.newVirtualThreadPerTaskExecutor().
Erreur n° 5 : se reposer sur ThreadLocal pour véhiculer le contexte. Les threads virtuels sont souvent créés et détruits ; ThreadLocal peut se comporter différemment de ce que vous attendez. Pour transmettre du contexte, utilisez ScopedValue (Scoped Values ; plus de détails dans la prochaine leçon).
GO TO FULL VERSION