Virtuelle Threads in Project Loom funktionieren als Fortsetzungen, die auf Träger-Threads basieren, die aus einem ForkJoinPool entnommen werden. Wenn ein virtueller Thread auf einen synchronisierten Block trifft oder nativen Code ausführt, pinnt er seinen zugrunde liegenden Träger-Thread, wodurch der Scheduler daran gehindert wird, den virtuellen Thread während blockierender I/O-Operationen zu entladen. Dies reduziert effektiv das Maß an Parallelität auf die Größe des Träger-Pools (typischerweise gleich der Anzahl der CPU-Kerne), was zu einem Zusammenbruch des Durchsatzes unter Last führen kann, da konkurrierende virtuelle Threads den festen Träger-Pool monopolieren.
Ein Finanzdienstleistungsunternehmen migrierte sein Legacy-Bestellverarbeitungsgateway von einem traditionellen Tomcat-Thread-per-Request-Modell (begrenzt auf 500 Plattform-Threads) zu Jetty mit virtuellen Threads und erwartete, 50.000 gleichzeitige WebSocket-Verbindungen verarbeiten zu können. Unmittelbar nach der Bereitstellung, trotz der Einführung virtueller Threads, stieg die Latenz auf mehrere Sekunden und der Durchsatz stagnierte bei nur 800 TPS während der Marktvolatilität. Thread-Dumps zeigten, dass alle 24 Träger-Threads im BLOCKED-Zustand innerhalb von synchronisierten Blöcken festsaßen, während tausende von virtuellen Threads auf I/O warteten und nicht fortfahren konnten.
Die erste Lösung, die in Betracht gezogen wurde, bestand darin, den ForkJoinPool-Parallelismus über -Djdk.virtualThreadScheduler.parallelism auf 1000 zu erhöhen. Dies würde mehr Träger-Threads bereitstellen, um die gepinnten Arbeitslasten zu absorbieren, was effektiv zu einem Verhalten eines großen Plattform-Thread-Pools zurückkehrt. Diese Methode maskiert jedoch nur den zugrunde liegenden architektonischen Fehler, indem sie übermäßige OS-Ressourcen verbraucht und die Vorteile der speichereffizienten Virtualisierung virtueller Threads zunichte macht.
Die zweite Lösung bestand darin, alle synchronisierten Blöcke, die gemeinsame Rate-Limitierungs-Caches schützen, so umzustrukturieren, dass sie stattdessen ReentrantLock verwenden. Im Gegensatz zu intrinsischen Monitoren integriert sich ReentrantLock mit dem virtuellen Thread-Scheduler, was eine Entladung während von Konkurrenz oder blockierenden Operationen ermöglicht, ohne den Träger zu pinnen. Dieser Ansatz bewahrt die Leichtgewichtigkeit virtueller Threads, erfordert jedoch eine systematische Überprüfung des Codebestands und eine sorgfältige Behandlung der Interruption von Sperrsemantiken.
Die dritte Lösung schlug vor, die konkurrierenden Hash-Map-Caches durch rein lockfreie Datenstrukturen wie ConcurrentHashMap-Berechnungsmethoden oder StampedLock für optimistische Lesevorgänge zu ersetzen. Obwohl dies das Blockieren für viele Lesewege beseitigt, adressiert es nicht Szenarien, die exklusiven Zugriff auf zustandsbehaftete externe Ressourcen, wie Datenbankverbindungsabfragen, erfordern, die notwendigerweise gegenseitigen Ausschluss benötigen.
Das Team wählte die zweite Lösung aus und priorisierte eine gezielte Migration von fünfzig kritischen synchronisierten Abschnitten zu ReentrantLock, nachdem durch Profiling identifiziert wurde, dass diese als Pinning-Hotspots fungierten. Diese Wahl adressierte direkt die Grundursache, indem sie es dem Scheduler ermöglichte, virtuelle Threads während der Konkurrenz zu entladen, ohne die zugrunde liegende Geschäftsanwendung oder den Speicherverbrauch zu verändern.
Nach der Umstrukturierung und der erneuten Bereitstellung erreichte das System die angestrebten 50.000 gleichzeitigen Verbindungen mit stabilen unter 100 ms p99-Latenz. Der Träger-Thread-Pool blieb bei der Standardgröße von 24 (entsprechend den CPU-Kernen), was zeigt, dass virtuelle Threads echte Skalierbarkeit nur dann bieten, wenn der Code das Fixieren der Träger durch intrinsische Synchronisation vermeidet.
// Vorher: Pinning des Träger-Threads synchronized (rateLimiter) { // Virtueller Thread kann hier nicht entladen werden externalApi.call(); } // Nachher: Erlaubt Entladung rateLimiter.lock(); try { // Virtueller Thread entlädt, entlastet den Träger externalApi.call(); } finally { rateLimiter.unlock(); }
Warum tritt das Pinning speziell bei synchronisierten Blöcken und nativen Methoden auf, während ReentrantLock eine Entladung ermöglicht?
Pinning tritt auf, weil die JVM intrinsische Monitore (synchronisiert) unter Verwendung von auf dem Thread-Stack basierenden Monitoraufzeichnungen und internen C++-VM-Strukturen implementiert, die zwangsläufig an den Ausführungskontext des physikalischen OS-Threads gebunden sind. Wenn ein virtueller Thread einen synchronisierten Block betreten, kann die JVM die Fortsetzung nicht sicher auf einen anderen Träger migrieren, ohne den Monitorzustand zu beschädigen oder die Happen-Before-Garantien auf nativer Ebene zu verletzen. Im Gegensatz dazu wird ReentrantLock rein in Java auf AbstractQueuedSynchronizer implementiert, das mit VarHandle- und LockSupport.park-Primitiven arbeitet, die der virtuelle Thread-Scheduler eingefügt hat, was eine sichere Entladung und erneute Montage über Träger hinweg ermöglicht, ohne von dem Zustand des nativen Threads abhängig zu sein.
Wie interagiert das Festpinnen des Träger-Threads mit dem Work-Stealing des ForkJoinPool, um potenzielle Hunger-Szenarien zu schaffen?
Im normalen Betrieb geht der ForkJoinPool davon aus, dass Aufgaben CPU-limitiert oder nicht blockierend sind; wenn ein Arbeiter-Thread blockiert, kompensiert er, indem er bis zur Parallelitätsgrenze zusätzliche Arbeiter spawnet oder aktiviert. Ein gepinnter virtueller Thread blockiert jedoch seinen Träger, ohne den Kompensationsmechanismus des Pools effektiv zu signalisieren. Folglich, wenn zwanzig virtuelle Threads gleichzeitig zwanzig Träger pinnen (z. B. beim Betreten synchronisierter Blöcke), bleiben keine Träger übrig, um die tausenden von bereitstehenden virtuellen Threads zu verarbeiten, die im Scheduler warten. Dies schafft eine Prioritätsumkehr, bei der nicht blockierte Arbeiten nicht fortschreiten können, obwohl Aufgaben verfügbar sind, und verkleinert effektiv die nutzbare Poolgröße dynamisch und katastrophal.
Kann aggressiver Gebrauch von ThreadLocal-Variablen das Festpinnen von Träger-Threads in virtuellen Thread-Umgebungen verursachen?
ThreadLocal-Variablen verursachen kein Pinning, da die Implementierung virtueller Threads die thread-lokale Map zwischen Trägern während Montage- und Entladevorgängen migriert. Kandidaten übersehen jedoch häufig, dass ThreadLocal eine eigenständige Katastrophe im Speichermanagement darstellt: Bei Millionen von kurzlebigen virtuellen Threads, die auf Thread-Lokale zugreifen, sammelt jeder Träger-Thread in seiner ThreadLocalMap Einträge für jeden virtuellen Thread, den er jemals war. Da diese Maps nur bei expliziter Entfernung oder Garbage Collection des Schlüssels (des virtuellen Threads) gereinigt werden, führt dies zu ungebundenem Speicherwachstum in langlaufenden Träger-Threads. Dies stellt effektiv einen Speicherleck dar, der nicht mit Pinning zusammenhängt, aber ebenso fatal für großangelegte virtuelle Thread-Bereitstellungen ist und eine Migration zu ScopedValue (JEP 446) für eine ordnungsgemäße Bereinigung erfordert.