Les threads virtuels dans Project Loom fonctionnent comme des continuations montées sur des threads porteurs tirés d'un ForkJoinPool. Lorsqu'un thread virtuel rencontre un bloc synchronisé ou exécute du code natif, il bloque son thread porteur sous-jacent, empêchant le planificateur de démonter le thread virtuel lors d'opérations I/O bloquantes. Cela réduit effectivement le degré de concurrence à la taille du pool porteur (généralement égal au nombre de cœurs de CPU), ce qui peut provoquer un effondrement du débit sous charge alors que les threads virtuels en concurrence monopolisent le pool porteur fixe.
Une société de services financiers a migré sa passerelle de traitement des commandes héritée d'un modèle traditionnel Tomcat à thread par requête (limité à 500 threads de plateforme) vers Jetty avec des threads virtuels, s'attendant à gérer 50,000 connexions WebSocket simultanées. Immédiatement après le déploiement, malgré l'adoption des threads virtuels, la latence a grimpé à plusieurs secondes et le débit s'est stabilisé à seulement 800 TPS lors de la volatilité à l'ouverture du marché. Les dumps de threads ont révélé que les 24 threads porteurs étaient bloqués dans un état BLOQUE à l'intérieur des blocs synchronisés, tandis que des milliers de threads virtuels en attente pour I/O ne pouvaient pas avancer.
La première solution envisagée était d'augmenter le parallélisme du ForkJoinPool via -Djdk.virtualThreadScheduler.parallelism à 1000. Cela fournirait plus de threads porteurs pour absorber la charge bloquée, revenant effectivement à un comportement de grand pool de threads de plateforme. Cependant, cette approche ne fait que masquer le défaut architectural sous-jacent en consommant des ressources excessives du système d'exploitation et annule les avantages d'efficacité mémoire promis par la virtualisation des threads virtuels.
La deuxième solution impliquait de refactoriser tous les blocs synchronisés protégeant les caches de limitation de débit partagés pour utiliser ReentrantLock à la place. Contrairement aux moniteurs intrinsèques, ReentrantLock s'intègre au planificateur de threads virtuels, permettant le démontage lors de la contention ou des opérations bloquantes sans bloquer le porteur. Cette approche préserve la légèreté des threads virtuels mais nécessite un audit systématique du code et un traitement soigneux de la sémantique d'interruption des verrous.
La troisième solution proposée consistait à remplacer les caches de cartes de hachage concurrentes par des structures de données exclusivement sans verrou, comme les méthodes compute de ConcurrentHashMap ou StampedLock pour les lectures optimistes. Bien que cela élimine le blocage pour de nombreux chemins de lecture, cela ne résout pas les scénarios nécessitant un accès exclusif à des ressources externes d'état comme les séquences de vérification de connexion à la base de données qui nécessitent intrinsèquement une exclusion mutuelle.
L'équipe a choisi la deuxième solution, en priorisant une migration ciblée de cinquante sections critiques synchronisées vers ReentrantLock après avoir identifié, grâce au profilage, celles qui étaient des points chauds de blocage. Ce choix a directement abordé la cause profonde en permettant au planificateur de démonter les threads virtuels pendant la contention, sans modifier la logique métier de l'application sous-jacente ou augmenter l'empreinte mémoire.
Après la refactorisation et le redéploiement, le système a atteint l'objectif de 50,000 connexions simultanées avec une latence stable inférieure à 100 ms p99. Le pool de threads porteurs est resté à la taille par défaut de 24 (correspondant aux cœurs de CPU), démontrant que les threads virtuels offrent une véritable évolutivité seulement lorsque le code évite de bloquer les porteurs par la synchronisation intrinsèque.
// Avant : Bloquant le thread porteur synchronized (rateLimiter) { // Le thread virtuel ne peut pas se démonter s'il est bloqué ici externalApi.call(); } // Après : Permet le démontage rateLimiter.lock(); try { // Le thread virtuel se démonte, libérant le porteur externalApi.call(); } finally { rateLimiter.unlock(); }
Pourquoi le blocage se produit-il spécifiquement avec les blocs synchronisés et les méthodes natives, alors que ReentrantLock permet le démontage ?
Le blocage survient parce que la JVM implémente des moniteurs intrinsèques (synchronisés) en utilisant des enregistrements de moniteurs basés sur la pile des threads et des structures internes de la VM au niveau C++ qui sont intrinsèquement liées au contexte d'exécution du thread OS physique. Lorsqu'un thread virtuel entre dans un bloc synchronisé, la JVM ne peut pas migrer en toute sécurité la continuation vers un autre porteur sans corrompre l'état du moniteur ou enfreindre les garanties de happens-before au niveau natif. En revanche, ReentrantLock est implémenté uniquement en Java sur AbstractQueuedSynchronizer, qui utilise des primitives VarHandle et LockSupport.park que le planificateur de threads virtuels interpose, permettant un démontage et un remontage sûrs à travers les porteurs sans dépendance à l'état des threads natifs.
Comment le blocage des threads porteurs interagit-il avec le vol de travail du ForkJoinPool pour créer des scénarios de famine potentiels ?
En fonctionnement normal, le ForkJoinPool suppose que les tâches sont liées au CPU ou non-bloquantes ; lorsqu'un thread de travail est bloqué, il compense en lançant ou en activant des travailleurs supplémentaires jusqu'à la limite de parallélisme. Cependant, un thread virtuel bloqué bloque son porteur sans signaler efficacement le mécanisme de compensation du pool. Par conséquent, si vingt threads virtuels bloquent simultanément vingt porteurs (par exemple, en entrant dans des blocs synchronisés), aucun porteur ne reste pour exécuter les milliers de threads virtuels prêts en file d'attente dans le planificateur. Cela crée une inversion de priorité où le travail non bloqué ne peut pas progresser malgré les tâches disponibles, réduisant effectivement la taille du pool utilisable de manière dynamique et catastrophique.
Une utilisation agressive des variables ThreadLocal peut-elle provoquer un blocage des threads porteurs dans des environnements de threads virtuels ?
Les variables ThreadLocal ne provoquent pas de blocage car l'implémentation des threads virtuels migre la carte de thread-local entre les porteurs lors des opérations de montage et de démontage. Cependant, les candidats négligent souvent que ThreadLocal pose une catastrophe distincte de gestion de la mémoire : avec des millions de threads virtuels à court terme touchant des thread-locals, chaque thread porteur accumule des entrées dans son ThreadLocalMap pour chaque thread virtuel qu'il a jamais hébergé. Étant donné que ces cartes ne sont nettoyées qu'après suppression explicite ou collecte des ordures de la clé (le thread virtuel), cela génère une croissance de mémoire illimitée dans les threads porteurs de longue durée. Cela constitue effectivement une fuite de mémoire sans relation avec le blocage mais également fatale pour les déploiements massifs de threads virtuels, nécessitant une migration vers ScopedValue (JEP 446) pour un nettoyage approprié.