1. Threads classiques : comment cela fonctionne et où ça coince
Commençons par rappeler comment fonctionnent les threads classiques en Java (on les appelle aussi threads de plateforme ou native threads). Ceux qui sont créés via new Thread(...).
Quand vous appelez new Thread(() -> { ... }).start();, la JVM ne se contente pas de lancer un bout de code. Elle demande au système d’exploitation de créer un véritable thread d’exécution. L’OS lui alloue une pile dédiée (généralement plusieurs mégaoctets) et réserve d’autres ressources systèmes.
Un tel thread vit tant que sa tâche s’exécute et, pendant tout ce temps, occupe une entrée dans la table des threads du système d’exploitation. Plus il y a de threads, plus la mémoire consacrée à leurs piles augmente et plus la charge sur l’OS est élevée. C’est précisément pourquoi, avec un grand nombre de threads simultanés, l’application peut commencer à « s’étouffer » — le système passe trop de temps à les basculer.
Exemple : thread classique
Thread thread = new Thread(() -> {
System.out.println("Bonjour depuis un thread !");
});
thread.start();
Ça paraît simple, non ? Mais essayez d’en créer non pas un ou deux, mais disons dix mille — et votre programme va rapidement s’essouffler. La mémoire s’épuisera, ou bien le système indiquera que la limite de threads est atteinte. Ce n’est pas une erreur de Java, mais une conséquence naturelle de l’architecture : les threads sont lourds et coûteux.
Pourquoi cela arrive‑t‑il ? Chaque thread obtient sa propre pile (généralement 1–2 Mo), plus tout un ensemble de structures système. Et l’OS n’aime pas non plus qu’on lui présente des dizaines de milliers de threads — il a des limites, parfois assez strictes.
Et même si la mémoire ne manque pas, un autre problème apparaît : le changement de contexte. Quand il y a trop de threads, le système saute constamment de l’un à l’autre, sauvegardant et restaurant leur état. Tout cela prend du temps et grignote les performances, si bien que le « massivement parallèle » ne paie pas.
Problème « un thread — une requête »
Dans les anciens serveurs d’applications (par exemple Tomcat ou Jetty), on utilisait souvent le modèle « thread‑per‑request » : un thread distinct pour chaque requête entrante. C’est pratique, mais si vous avez 10_000 utilisateurs, il vous faut 10_000 threads ! Le serveur souffre, et on part en guerre contre la mémoire plutôt que pour la vitesse de traitement.
Conclusion :
Les threads classiques conviennent à un petit nombre de tâches parallèles, mais ne se mettent pas à l’échelle jusqu’à des dizaines ou des centaines de milliers.
2. Qu’est-ce que les threads virtuels (Virtual Threads) ?
Voici le héros du jour : les threads virtuels. Ce n’est pas « juste un autre thread », mais une idée d’architecture différente.
Les threads virtuels sont des threads gérés non pas par le système d’exploitation, mais par la JVM elle‑même. Ils sont entièrement implémentés en Java et peuvent être créés en très grande quantité (des dizaines voire des centaines de milliers) sans faire exploser la mémoire ni provoquer de ralentissements.
En bref :
- Platform Thread (thread de plateforme) : un thread classique mappé directement sur un thread de l’OS.
- Virtual Thread (thread virtuel) : un thread léger géré par la JVM, et non par l’OS.
Comment est‑ce conçu ?
Les threads virtuels sont des threads « légers » qui vivent non pas dans l’OS, mais à l’intérieur de la JVM. Ils s’exécutent au‑dessus d’un petit pool de vrais threads, appelés platform threads. On peut imaginer que la JVM les dirige comme un chef d’orchestre : il a un nombre limité de musiciens (threads réels), mais répartit habilement les parties (threads virtuels).
Schéma d’architecture :
+-------------------+ +-------------------+
| Virtual Thread 1 |---\ | Platform Thread |
| Virtual Thread 2 |---->====> | (Carrier Thread) |
| Virtual Thread 3 |---/ +-------------------+
... (Système d’exploitation)
Un Carrier Thread est un thread OS classique sur lequel la JVM exécute de nombreux threads virtuels. Si un thread virtuel se bloque — par exemple, en attendant des données du disque ou du réseau — la JVM le « met en pause », libérant le carrier thread pour d’autres tâches.
Pourquoi est‑ce révolutionnaire ?
Parce qu’on peut écrire du code linéaire habituel — sans callbacks à n’en plus finir, sans CompletableFuture ni chaînes « infernales » de thenApply — tout en mettant l’application à l’échelle vers des milliers d’opérations simultanées.
Les threads virtuels n’occupent que quelques dizaines de Ko (contre des Mo pour les threads classiques) et se créent quasiment instantanément. On peut donc en lancer et en détruire des milliers sans craindre de faire s’effondrer l’OS sous leur poids. Cela rend enfin la programmation parallèle en Java simple et naturelle.
3. Avantages des threads virtuels
Scalabilité
Avec les threads virtuels, vous pouvez vous permettre le luxe de lancer des dizaines, voire des centaines de milliers de tâches parallèles. Par exemple, traiter chaque requête réseau dans un thread dédié — sans craindre que le serveur « explose ».
Démonstration : 100 000 threads virtuels
for (int i = 0; i < 100_000; i++) {
Thread.ofVirtual().start(() -> {
// Ici, n'importe quelle logique
try {
Thread.sleep(1000); // Simulation de traitement
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
System.out.println("Tous les threads sont démarrés !");
Ce code s’exécute sans problème sur un ordinateur portable ordinaire !
Essayez de faire la même chose avec des threads classiques — vous verrez un OutOfMemoryError ou vous transformerez votre machine en « brique ».
Simplicité de programmation
Les threads virtuels permettent d’écrire du code « bloquant » habituel, sans le transformer en « spaghetti » d’appels asynchrones. Par exemple, vous pouvez utiliser sereinement Thread.sleep, InputStream.read, Socket.accept — la JVM veille à ne pas bloquer tout le carrier thread.
Lisibilité et maintenance améliorées
Au lieu de schémas complexes avec des callbacks et des CompletableFuture, vous écrivez du code linéaire et lisible. Cela réduit les bugs et facilite la maintenance.
Pas besoin de réinventer la roue
Autrefois, pour traiter des milliers de requêtes en parallèle, il fallait utiliser des frameworks asynchrones, des bibliothèques réactives (Netty, Vert.x, Project Reactor) qui imposent un style de programmation particulier. Aujourd’hui, on peut s’en passer — tout en obtenant la scalabilité.
4. Architecture : comment les threads virtuels fonctionnent « sous le capot »
Mappage sur les carrier threads
La JVM crée un petit pool de vrais threads (carrier threads) — en général autant que de cœurs CPU. Tous les threads virtuels « circulent » sur ces carrier threads, comme des passagers dans des bus.
- Quand un thread virtuel se bloque (par exemple, en attendant une réponse réseau), la JVM le « décharge » du carrier thread et le place en file d’attente.
- Dès que le thread peut reprendre, la JVM le « remonte » sur un carrier thread libre.
Analogie :
Imaginez que vous avez 4 taxis (carrier threads) pour 10 000 clients (virtual threads). Dès qu’un client arrive à destination et descend, le taxi en prend immédiatement un autre. Personne ne reste inactif, et les taxis ne cassent pas sous le poids des passagers.
Ordonnancement et bascule
La JVM décide elle‑même quel thread virtuel exécuter à chaque instant. Si un thread se bloque sur des I/O, il ne gêne pas les autres.
5. Limitations et particularités des threads virtuels
Tout ce qui est virtuel n’est pas or
Pas pour les calculs longs : si vous avez une tâche qui monopolise le CPU (fortement CPU‑bound), le thread virtuel n’apportera aucun gain de performance. Les carrier threads restent limités par le nombre de cœurs.
Certaines synchronisations sont inefficaces : les anciens mécanismes de synchronisation (par exemple, la synchronisation via synchronized sur des objets avec mutex natifs) peuvent empêcher la JVM de « geler » le thread virtuel. Dans ce cas, le carrier thread attendra avec le thread virtuel, réduisant la scalabilité.
Toutes les bibliothèques ne sont pas compatibles avec les threads virtuels : si une bibliothèque fait des appels natifs ou utilise des verrous spécifiques, le comportement des threads virtuels peut ne pas être celui attendu.
Exemple : quand il ne faut pas utiliser les Virtual Threads
Si vous avez une tâche qui tourne dans une boucle infinie et calcule des nombres, le thread virtuel n’apporte aucun bénéfice. On reste limité par le nombre de cœurs.
Thread.ofVirtual().start(() -> {
while (true) {
// On compte à l’infini
}
});
Résultat :
Un carrier thread sera monopolisé par ce thread virtuel, et les autres tâches attendront leur tour.
6. Comparaison : Platform Thread vs Virtual Thread
| Caractéristique | Platform Thread (classique) | Virtual Thread (virtuel) |
|---|---|---|
| Géré par | Le système d’exploitation | JVM |
| Mémoire par thread | Mégaoctets | Dizaines de Ko |
| Nombre de threads | Généralement < 10_000 | Des milliers, des centaines de milliers |
| Coût de création | Cher | Bon marché |
| Scalabilité | Limitée | Quasi illimitée |
| Adapté à | Tâches longues, CPU‑bound | Tâches courtes, I/O‑bound |
| Commutation des threads | OS | JVM |
| Compatibilité | 100% | Presque toujours, mais avec des nuances |
7. Exemple : à quoi ressemblerait un serveur avant et après les Virtual Threads
Avant (Platform Threads)
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket client = serverSocket.accept();
new Thread(() -> handleClient(client)).start();
}
Problème :
Au bout de 5 000 connexions, le serveur commence à « s’étouffer ».
Après (Virtual Threads, Java 21+)
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket client = serverSocket.accept();
Thread.ofVirtual().start(() -> handleClient(client));
}
Magie :
On peut désormais traiter des dizaines de milliers de connexions — sans se soucier des limites de threads !
9. Erreurs typiques lors du passage aux threads virtuels
Erreur n° 1 : attendre une accélération pour des tâches de calcul. Les threads virtuels n’accélèrent pas les tâches qui saturent entièrement le processeur. Pour ces tâches, on reste limité par le nombre de cœurs.
Erreur n° 2 : utiliser d’anciens mécanismes de synchronisation bloquants. Si vous utilisez d’anciens verrous (par exemple synchronized sur des objets susceptibles d’être verrouillés nativement), les threads virtuels peuvent ne pas être déchargés d’un carrier thread, perdant ainsi leurs avantages.
Erreur n° 3 : ignorer le comportement des bibliothèques tierces. Certaines bibliothèques tierces peuvent ne pas être prêtes pour les threads virtuels (par exemple si elles utilisent JNI ou des verrous natifs).
Erreur n° 4 : attendre une croissance magique des performances. Les threads virtuels ne sont pas une panacée. Ils n’accélèrent pas tout, mais rendent le parallélisme peu coûteux et pratique pour les tâches I/O‑bound.
GO TO FULL VERSION