Auf I/O wartende Virtual Threads belegen nahezu keine Hardware- und Betriebssystem-Ressourcen mehr. Dies ist insbesondere in Serversystemen ein Gamechanger, da die Anzahl der gleichzeitig bedienbaren Clients dadurch massiv steigt. Der Umstieg auf Virtual Threads ist einfach, weil sie sich weitgehend wie die klassischen Java-Threads programmieren lassen. Dieser Artikel führt in die Benutzung und das Verständnis von Virtual Threads ein und gibt eine praktische Anleitung für die Migration.
Was sind Java-Platform-Threads, und was ist ihr Problem?
Threads sind die einzige Möglichkeit in Java, Aufgaben parallel oder nebenläufig auszuführen. Alle anderen Parallel-APIs im JDK, auch Virtual Threads, sind auf deren Basis realisiert.
Die klassischen Threads im JDK heißen nun Platform-Threads, um sie von den neuen Virtual Threads zu unterscheiden. Ein solcher Platform-Thread ist teuer, weil er durch einen Betriebssystem-Thread (OS-Thread) realisiert ist. Es gibt zwei Quellen für dessen Kosten: den Speicherplatz für den Thread-Stack und Laufzeit-Kosten für das mithilfe des Betriebssystems realisierte Scheduling, das heißt den Wechsel zwischen Threads auf einer CPU, siehe Abbildung 1.

Abb. 1: Java-Platform-Thread ist Wrapper um OS-Thread
Idee und Vorteil der Virtual Threads
Ziel der Virtual Threads ist, durch Ressourcen-Ersparnis den maximal möglichen Grad der Nebenläufigkeit eines Programms zu erhöhen, um dadurch einen Performance-Gewinn zu erzielen.
Dafür „teilen“ sich viele Virtual Threads wenige Platform-Threads, sie virtualisieren somit den Zugriff auf diese. Die Platform-Threads werden in einem Pool (siehe Abb. 2) vorgehalten und den Virtual Threads dynamisch zugeteilt.

Abb. 2: Pool: wenige Platform-Threads führen viele Virtual Threads im Wechsel aus
Derjenige Platform-Thread, der einen Virtual Thread zu einem bestimmten Zeitpunkt ausführt, wird als dessen „Carrier-Thread“ bezeichnet. Der Virtual Thread läuft so lange, bis er die CPU durch einen blockierenden Aufruf freigibt, zum Beispiel einen Aufruf an einen REST-Service. In diesem Moment wird er von seinem Carrier getrennt, der weiterlaufen kann, um einen anderen Virtual Thread auszuführen.
Die beiden Kostentreiber Stack und Betriebssystem-Scheduling sind bei Virtual Threads weitgehend „wegoptimiert“: Ein Virtual Thread nutzt den Stack seines Carriers. Sein Scheduling ist lediglich ein Methoden-Aufruf innerhalb der JVM.
Programmierung und API der Virtual Threads
Das zentrale Designziel des Virtual-Thread-Programmiermodells ist, sich nahtlos in das der klassischen Platform-Threads einzufügen. Das Thread-API wurde dafür erweitert und die Klasse VirtualThread
von Thread abgeleitet (siehe Abb. 3).

Abb. 3: Virtual-Thread-Klassen-Hierarchie
Die Möglichkeiten, Virtual Threads zu benutzen, sind syntaktisch und semantisch weitgehend identisch zu bisherigen API-Aufrufen. Sie unterscheiden sich jedoch wesentlich in ihrem Laufzeitverhalten hinsichtlich Ressourcenverbrauch und Skalierbarkeit. Die folgenden Beispiele zeigen die grundlegende Benutzung des Virtual-Thread-API: Starten, Warten und Abholen von Rückgabewerten.
Im ersten Fall bleibt der Thread-Pool verborgen, benutzt wird er dennoch, siehe Listing 1.
// Starten
Thread virtualThread = Thread.startVirtualThread(() ->{
// Code des Virtual-Threads
});
// Warten
virtualThread.join();
In der zweiten Variante wird der Thread-Pool als Instanz der Klasse ExecutorService
explizit sichtbar. Der submit()-
Aufruf übergibt sein Lambda-Argument an einen neu instanziierten Virtual Thread, der dann von einem der Pool-Threads ausgeführt wird, siehe Listing 2 und Abbildung 4.
// Thread-Pool als Instanz von ExecutorService erzeugen
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// Starten des Virtual-Threads durch Übergabe eines Lambdas an den Pool
Future<Integer> threadResult = executor.submit(() -> {
// Code des Virtual-Threads
return 7;
});
// Warten auf Ende des Virtual-Threads
// und seinen Rückgabewert abholen
Integer result = threadResult.get();

Abb. 4: Virtual-Thread-Start über ExecutorService
In beiden Varianten stirbt der Virtual Thread am Ende des Lambdas. Der Platform-Thread kehrt zurück in den Pool.
Weitere Varianten ermöglichen, zum Beispiel über das Builder-Pattern, Virtual Threads mit unterschiedlichen Eigenschaften anzulegen.
Regeln für die Virtual-Thread-Programmierung |
---|
Ein Virtual Thread ist nicht schneller als ein Platform-Thread. Virtual Threads skalieren besser als Platform-Threads. Virtual Threads sind billig, Wiederverwendung oder Pooling sind sinnlos. In synchronized Blocks wird der Carrier beim Blockieren nicht freigegeben (siehe Abschnitt „Migration“) |
Architektur der Virtual Threads
Die Architektur der Virtual Threads verfolgt zwei Ziele: Einsparung von Ressourcen und Etablierung eines transparenten Programmiermodells für die Asynchronität (siehe auch Abschnitt „Asynchrone Ausführung trotz sequenzieller Programmierung“).
Die wesentlichen Elemente der Virtual-Thread-Architektur sind:
- Pool von Platform-Threads.
- Carrier-Thread führt Virtual Thread aus.
- Blockierender Aufruf gibt den Carrier frei.
- Scheduling ist lediglich Methodenaufruf in der JVM.
- Dynamischer Stack des Virtual Threads besteht aus „Stack-Chunks“.
Wie spielen diese Elemente zusammen, um den Ressourcenbedarf zu mindern?
Wie in der Einleitung skizziert, wird ein Virtual Thread von einem Platform-Thread aus dem Pool ausgeführt, bis er einen blockierenden Betriebssystemaufruf tätigt (z. B. warten auf einen Socket). Das Blockieren wird von der jeweiligen Methode der Java Library erkannt, die dafür sorgt, dass der Carrier freigegeben wird (siehe Abb. 5). Dieser kann dann einen anderen, laufbereiten Virtual Thread ausführen. Das ist auch die Basis für die transparente Asynchronität.

Abb. 5: Carrier-Freigabe bei blockierendem Aufruf
Ziel dieser Indirektion ist, mit weniger Platform-Threads ein höheres Maß an Nebenläufigkeit zu ermöglichen. Der Thread-Pool enthält in etwa so viele Platform-Threads, wie die Maschine CPU-Cores hat, und damit eine geringe Zahl im Verhältnis zur parallelen Last einer typischen Server-Applikation.
Folgende Ressourcen werden dadurch eingespart:
- Speicherplatz: Nur wenige Platform-Threads mit teuren Stacks werden benötigt.
- CPU: Pool-Threads laufen kontinuierlich, Betriebssystem-Scheduling entfällt weitgehend.
Details der Stack-Optimierung
Der Stack eines Threads ist der Bereich, in dem die Stack-Frames realisiert werden. Dies sind die Laufzeitrepräsentationen der Methodenaufrufe. Der Stack eines Plattform-Threads ist teuer, weil der benötigte Speicherplatz beim Start des Threads typischerweise in fester Größe alloziert wird [1]. Durch diesen Speicherplatzbedarf ist die maximale Anzahl der gleichzeitig existenzfähigen Threads begrenzt.
Der Stack eines Virtual Thread hingegen kann dynamisch wachsen und schrumpfen. Zu diesem Zweck ist er in sogenannte Stack-Chunks unterteilt. Diese werden beim „Unmounting“, der Trennung vom Carrier, vom Stack des Carriers entfernt. Beim „Mounting“, der erneuten Ausführung auf einem gegebenenfalls anderen Carrier, werden sie auf dessen Stack aufgebracht.
Die Speicherplatzersparnis ergibt sich daraus, dass der im blockierenden Aufruf untätig wartende Virtual Thread lediglich Platz für seine Stack-Chunks verbraucht (siehe Abb. 6). Ein blockierter Platform-Thread hingegen würde, ebenso untätig, den kompletten Platz für seinen statischen Stack im Speicher belegen. Die Ersparnis basiert auf der Annahme, dass der Stack des Virtual Thread eher flach ist, zum Beispiel weil er einen Request verarbeitet, der nur wenige Methodenaufrufe erfordert.

Abb. 6: Dynamischer Stack
Folgendes Beispiel illustriert die aus dieser Optimierung resultierende massive Steigerung der Skalierbarkeit.
Beispiel Skalierbarkeit
Das Programm in Listing 3 wirft auf einer Beispiel-Plattform (MacBook Pro, 48 GIG RAM, 64 Bit HotSpot VM) nach ca. 12.000 gestarteten und simultan lebenden Platform-Threads einen „OutOfMemoryError“
mit der Message: „unable to create native thread: possibly out of memory or process/resource limits reached“. Die JVM signalisiert damit, dass entweder der Speicher erschöpft ist oder das Betriebssystem mit den hier genutzten Default-Einstellungen keine weiteren Threads starten will.
for(int i = 0; i < 1_000_000; ++i) {
Thread thread = new Thread(()->{
try {
// Aufruf, um den Thread am Leben zu erhalten
Thread.sleep(10000);
} catch (InterruptedException e) {}
});
thread.start();
}
Listing 4 zeigt die Umstellung dieses Programms auf Virtual Threads, die dazu führt, dass es auf derselben Plattform stabil läuft.
// Rahmen wie im oberen Listing (cnt enthält i)
Thread thread = Thread.startVirtualThread(()->{
System.out.println("New Virtual Thread " + cnt);
// weiter wie oben …
});
Der Output in Abbildung 7 zeigt, dass das Programm 1 Million Virtual Threads startet. Diese warten in Thread.sleep()
und existieren daher simultan im Programm.

Abb. 7: Output zu Listing 4
Einordnung der Skalierbarkeit von Betriebssystem-Threads
Trotz dieser Betrachtungen sind moderne Betriebssysteme hoch skalierbar hinsichtlich der Anzahl von Threads. Es gibt keine allgemeingültigen Obergrenzen dafür. Diese hängen vom konkreten Betriebssystem, dem verfügbaren RAM, der Anzahl der CPU-Kerne und der einstellbaren Stack-Größe ab. Die JVM-Spezifikation erlaubt zudem dynamische Thread-Stacks und nimmt Optimierungen hinsichtlich des Speicherbedarfs vor [2, 3].
Die entscheidende Frage ist daher, bis zu welcher Anzahl von OS-Threads ein System effizient skaliert. Daraus folgt für die Anwendungsentwicklung: Wenn ein System mit Platform-Threads hoch genug skaliert, dann erzielen Virtual Threads nicht unbedingt einen Performance-Vorteil.
Vorteile der Virtual-Thread Architektur zusammengefasst |
---|
Virtual Threads verbrauchen weniger Ressourcen in Hardware und Betriebssystem als Platform-Threads. Dies ermöglicht es, sehr viel mehr gleichzeitig existierende Virtual Threads zu realisieren, als dies mit Platform-Threads möglich wäre. Die Parallelität steigt dadurch zwar nicht, denn sie ist durch die Anzahl der CPU-Kerne begrenzt. Die Nebenläufigkeit steigt jedoch massiv. Es kann somit auf sehr viele blockierende Aufrufe gleichzeitig gewartet werden. Dies ermöglicht es z. B. einem Server, wesentlich mehr Clients in der gleichen Zeit zu bedienen. |
Asynchrone Ausführung trotz sequenzieller Programmierung
Virtual Threads verbinden die Eleganz des sequenziellen Programmiermodells mit der Effizienz einer asynchronen Ausführung. Dies erleichtert die Programmierung beispielsweise eines Servers im Thread-per-Request-Stil, bei dem jeder Client von einem eigenen Thread im Server bedient wird. Die Programmierung ist dadurch einfach, denn alle Anweisungen stehen ohne Hinweise auf Asynchronität nacheinander im Code.
Die Ausführung jedoch ist sehr wohl asynchron (siehe Abb. 8).

Abb. 8: Implizite asynchrone Ausführung
Die Virtual-Thread-Laufzeitumgebung nutzt ihre Fähigkeit, blockierende Aufrufe zu erkennen, um an diesen Stellen in den Programmablauf einzugreifen. Ruft ein Virtual Thread zum Beispiel einen REST-Service blockierend auf, so erhält er dessen Rückgabedaten als Return-Wert der blockierenden Methode, sobald er wieder geschedult wird. Callbacks und andere Konstrukte für die Asynchronität werden dadurch überflüssig.
Die Designer der Virtual Threads haben sich bewusst gegen das beispielsweise in C# etablierte async/await entschieden, um die damit einhergehende Komplexität einer expliziten Asynchronität aus der Programmiersprache herauszuhalten und die Migration zu erleichtern [1].
Performance durch Virtual-Threads
Wie aber kommt der erhoffte Performance-Gewinn zustande? Durch Virtual Threads kann die Anzahl der nebenläufig bearbeiteten Aufgaben erhöht werden. Bei einem System, das einen wesentlichen Teil der Zeit mit blockierenden Aufrufen verbringt, steigt dadurch die Bearbeitungszeit für den einzelnen Request nicht im selben Maße. Denn während des Blockierens wird keine CPU benötigt. Somit können mehr Aufgaben in derselben Zeit verarbeitet werden.
Little’s Law (John Little 1961, [4]) formalisiert diesen Zusammenhang. Es besagt, dass im statistischen Mittel, bei konstanter Verarbeitungszeit für den einzelnen Request, der Durchsatz eines Systems proportional zum Grad der Nebenläufigkeit wächst [5].
Das folgende Beispiel demonstriert, dass somit unter bestimmten Voraussetzungen die Performance um ein Vielfaches gesteigert werden kann. Der Code in Listing 5 startet 10.00 Virtual Threads, von denen jeder eine Sekunde blockiert. Das komplette Szenario ist nach gut einer Sekunde abgearbeitet. Der Grund ist, dass die 10.000 blockierenden Aufrufe nahezu simultan warten, weil Thread.sleep()
den Carrier sofort freigibt. Die wenigen Platform-Threads aus dem Pool genügen so, um all diese Aufrufe an das Betriebssystem innerhalb von Millisekunden abzusetzen.
// Pool mit neuem Virtual Thread pro Aufruf
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
// 10.000 Aufgaben starten
for(int i = 0 ; i <= 10_000; ++i) {
pool.submit(() -> {
// Blockierender Aufruf
// (Exception Handling nicht gezeigt)
Thread.sleep(1000);
});
}
Die Variante in Listing 6 führt jede der Aufgaben in einem Platform-Thread aus einem Pool begrenzter Größe aus. Sie braucht erheblich länger, sie läuft 10 Sekunden. Das liegt daran, dass der Pool mit seinen lediglich 1000 Platform-Threads immer nur ein Zehntel der 10.000 Aufträge gleichzeitig bearbeiten kann. Ein ausführliches Client-Server-Beispiel findet sich in [6].
// Pool mit 1000 Platform-Threads
ExecutorService pool = Executors.newFixedThreadPool(1000);
// weiter wie oben …
//… wie oben
Diese Beispiele demonstrieren den generellen Mechanismus der Performance-Steigerung durch Virtual Threads. Es handelt sich dabei jedoch um Mikro-Benchmarks, deren quantitative Aussagekraft begrenzt ist. Die real erzielbaren Gewinne hängen von den Bedingungen in einem konkreten System ab.
Migration
Wie sehr eine Applikation vom Umstieg auf Virtual Threads profitiert, hängt von den folgenden Faktoren ab:
- Blockierende Aufrufe haben einen wesentlichen Anteil an der Gesamtzeit der Ausführung. Je größer dieser Anteil, desto größer ist der potenzielle Performance-Gewinn.
- Das gewünschte Maß an Nebenläufigkeit ist mit Platform-Threads nicht realisierbar. Nur wenn dies gilt, erbringen Virtual Threads einen signifikanten Performance-Vorteil.
Applikationen, die von Virtual Threads profitieren
Jede Applikation, die die beiden obigen Bedingungen erfüllt, kann profitieren. Dies können alle Arten von Systemen sein, die intensiv mit I/O zu tun haben. Dazu gehören zum Beispiel Datenbankapplikationen, Microservice-Systeme mit viel Kommunikation, aber auch industrielle Steuerungssoftware, die sich mit Sensoren und Aktoren verbindet.
Kurz laufende Threads mit „flachen“ Aufruf-Stacks profitieren stärker als Langläufer, da ein tiefer Stack den Vorteil gegenüber einem Platform-Threads schwinden lässt [7].
Der ideale Use-Case ist somit ein skalierbarer Server, der viele Clients mit kurzen Anfragen simultan bedient und für jeden Client-Request Daten über blockierende Aufrufe von Backend-Services bezieht, wie eine Fahrplansuche (siehe Abb. 9).

Abb. 9: Server mit Virtual Threads, wenige Platform-Threads bedienen viele Clients
Applikationen, die nicht von Virtual Threads profitieren
Ein reiner Numbercruncher, der rechenintensive Aufgaben auf der CPU erledigt, profitiert nicht. Denn er braucht für eine Performance-Steigerung echte Parallelität statt Nebenläufigkeit. Diese ist jedoch nur durch mehr CPU-Kerne erzielbar.
Risiken und Sonderfälle
Zwei Aspekte im Verhalten der Virtual Threads können sich problematisch auswirken, Pinning und das nicht-präemptive Scheduling. Zunächst zu Pinning in synchronized-
Blöcken.
Pinning bedeutet, dass ein Virtual Thread seinen Carrier trotz eines blockierenden Aufrufs nicht freigibt. Das ist unter anderem innerhalb von synchronized-
Blöcken der Fall. Sobald Pinning stattfindet, stehen weniger freie Pool-Threads zur Verfügung. Das kann die Performance bis hin zum Deadlock beeinträchtigen. Diese Gefahr ist durchaus real, weil unabhängige Abläufe in einem System verschiedene Locks akquirieren und dort blockierend warten können [8].
Pinning kann vermieden werden, indem synchronized
durch ein sogenanntes explizites Lock ersetzt wird, was sich zum Beispiel mithilfe der Klasse ReentrantLock
realisieren lässt.
Pinning trifft auch die Monitor-Methode Object.wait()
, die stets in einem synchronized-
Block aufgerufen werden muss. Zwar vergrößert sich in diesem Fall der Pool automatisch, aber nur bis zu einem gewissen Maß (Default 256). JNI-Aufrufe und Foreign Functions sind ebenfalls betroffen.
Nun zum zweiten Aspekt. Nicht präemptives Scheduling kann das Programmverhalten verändern. Das Scheduling zwischen Virtual Threads ist nicht präemptiv, denn ein Virtual Thread gibt seinen Carrier erst dann frei, wenn er von sich aus die CPU abgibt. Dadurch kann es passieren, dass ein anderer Virtual Thread zwar laufen möchte, aber sehr lange, gegebenenfalls unendlich, keine CPU zugeteilt bekommt. Dieses Verhalten ist dem Paradigma immanent und daher nicht zu beanstanden. Man sollte es sich aber bei einer Migration als Risiko bewusst machen.
Checkliste Migration |
---|
1. Ein Performance-Gewinn ist nur erzielbar, wenn Folgendes gilt: o Das System verbringt eine signifikante Zeit in blockierenden Aufrufen. o Das ideale Maß an Nebenläufigkeit ist mit Platform-Threads nicht herstellbar. 2. synchronized-Blöcke mit langen blockierenden Aufrufen stellen ein Risiko dar: o synchronized sollte durch explizites Locking mit ReentrantLock ersetzt werden. 3. Eingesetzte 3rd Party Software sollte Virtual Threads unterstützen: o Frameworks wie Spring oder Quarkus unterstützen Virtual Threads durch Schalter oder Annotationen. o Bibliotheken sollten für Virtual Threads geeignet sein, das heißt zum Beispiel keine langen blockierenden Aufrufe in synchronized-Blöcken haben. |
Fazit
Virtual Threads sind ein Gamechanger für die parallele Programmierung in Java. Sie kombinieren eine einfache Programmierung im Thread-per-Request-Stil mit einer effizienten asynchronen Ausführung. Unter bestimmten Bedingungen lässt sich so eine massive Performance-Steigerung erzielen. Der Umstieg lohnt sich vor allem dann, wenn mit Platform-Threads nicht die gewünschte Skalierbarkeit erreicht werden kann.
Der Aufwand für Anpassungen im Code ist im günstigen Fall begrenzt, da nur die Thread-Erzeugung verändert werden muss. Allerdings sind Sonderfälle wie Pinning zu beachten. Java hat damit zu anderen Sprachen wie Go oder Kotlin aufgeschlossen, die ein effizientes Parallelitätsmodell anbieten.
Die anderen Parallel-APIs im JDK haben jedoch weiterhin ihre Berechtigung für ihre spezifischen Anwendungsfälle: zum Beispiel Platform-Threads für langlaufende Aufgaben und die Strukturierung einer Systemarchitektur oder Parallel Streams und Fork-Join-Tasks für die Parallelisierung CPU-intensiver Algorithmen [9].
Virtual Threads werden aber auf lange Sicht breite Anwendung finden. Insbesondere für skalierbare Server sind sie der Goldstandard, an dem sich alles andere wird messen lassen müssen.
Literaturangaben
[1] B. Goetz, D. Bryant, Virtual Threads: New Foundations for High-Scale Java Applications, 2022, siehe:
https://www.infoq.com/articles/java-virtual-threads/
(Begründung zu den Design-Entscheidungen)
[2] Java Virtual Machine Specification, 2.5.2. Java Virtual Machine Stacks, siehe:
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.2
[3] F. Cicio, How Many Threads Can a Java VM Support?, 2024, siehe:
https://www.baeldung.com/jvm-max-threads
[4] Little’s Law, siehe:
https://en.wikipedia.org/wiki/Little%27s_law
[5] R. Pressler, A. Bateman, JDK Enhancement Proposal (JEP), Nr. 444, Virtual Threads, siehe:
https://openjdk.org/jeps/444
(umfassende, gut verständliche Erläuterung des gesamten Themas mit praktischen Beispielen)
[6] Oracle Java Core Libraries, Virtual-Threads-Dokumentation, siehe:
https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html
[7] T. Nurkiewicz, Project Loom: Revolution in Java Concurrency or Obscure Implementation Detail?, QCon Plus, 2022, siehe:
https://www.infoq.com/presentations/loom-java-concurrency/
(kritische Auseinandersetzung mit Virtual Threads)
[8] D. Filanovski et al., Java 21 Virtual Threads – Dude, Where's My Lock?, 2024, siehe:
https://netflixtechblog.com/java-21-virtual-threads-dude-wheres-my-lock-3052540e231d
(Erfahrungsbericht über Pinning in einer Systemmigration)
[9] J. Hettel, Manh Tien Tran, Nebenläufige Programmierung mit Java: Konzepte und Programmiermodelle für Multicore-Systeme, dpunkt.verlag, 2016