In Teil 1 dieser Reihe ging es um die Geschichte der Java-Nebenläufigkeit, von Green Threads bis CompletableFuture, und darum, warum ich mich gerade jetzt wieder mit dem Thema beschäftige: Ich baue aktuell an einer OCR-Software, bei der viele Batches parallel abgearbeitet werden sollen. Genau dabei sind virtuelle Threads für mich vom theoretischen Konzept zum praktischen Werkzeug geworden. Hier geht es jetzt um den aktuellen Stand.
Das Problem, das virtuelle Threads lösen sollen
Bevor es um virtuelle Threads geht, lohnt sich ein Blick auf das eigentliche Problem. Klassische Java-Threads sind teuer. Jeder java.lang.Thread entspricht 1:1 einem Betriebssystem-Thread, und die haben einen spürbaren Speicher-Overhead (typischerweise ein Megabyte Stack-Speicher pro Thread) und sind auch im Scheduling nicht gerade günstig. Bei meiner OCR-Software zum Beispiel will ich Batches mit hunderten Seiten gleichzeitig einlesen. Diese an die Texterkennung schicken und die Ergebnisse wegschreiben können. Ohne dass mir dabei die Threads ausgehen und auch ohne komplexes Thread Management. Genau das ist mit klassischen Threads irgendwann ein Problem. Nicht mehr die CPU oder die Anwendungslogik limitiert, sondern schlicht die Anzahl der Threads, die das Betriebssystem noch mitmacht.
Genau hier setzt Project Loom an, aus dem die virtuellen Threads hervorgegangen sind. Die Idee: Behalte das vertraute Thread-per-Request-Programmiermodell (synchroner Code, keine Callback-Hölle, kein reaktives Umdenken nötig) aber erhalte die Skalierbarkeit von asynchronem Code geschenkt. Wie cool wäre das, oder? 🙂
Virtuelle Threads: Viele auf wenigen
Nun ein sperriger Lehrsatz.
Ein virtueller Thread ist ein vom JDK verwalteter Thread der nicht dauerhaft an einen Betriebssystem-Thread (im Loom-Jargon „Carrier Thread“ genannt) gebunden ist.
Blockiert ein virtueller Thread (zum Beispiel bei I/O), wird er einfach vom Carrier Thread „abgehängt“ (unmounted). Der Carrier Thread kann in der Zwischenzeit einen anderen virtuellen Thread bedienen. Sobald die blockierende Operation fertig ist, wird der virtuelle Thread wieder auf einen (nicht zwingend denselben) Carrier Thread gemountet und läuft weiter.
Damit kann man problemlos hunderttausende virtuelle Threads gleichzeitig laufen lassen, obwohl im Hintergrund nur wenige echte Betriebssystem-Threads existieren. Für meine OCR Anwendung heißt das, dass ich für jede Seite einen eigenen virtuellen Thread starten kann und mir keine Gedanken über Thread Pooling machen muss.
Ausprobieren lässt sich das schon seit Java 19 mit ein paar Zeilen Code:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // wartet automatisch, bis alle Tasks fertig sind
Der Code sieht aus wie ganz normaler, synchroner Java-Code. Thats the point. Kein CompletableFuture, kein reaktiver Stream, einfach nur ein blockierender sleep-Aufruf. Dass man sich jetzt mit hunderttausendfacher Parallelität erlauben kann. Eben weil nicht hunderttausend Betriebssystem-Threads dahinterstehen. Das bedeutet aber nicht, dass man es auch tun muss 😉
Alternativ lässt sich ein einzelner virtueller Thread auch direkt erzeugen:
Thread.ofVirtual().start(() -> {
System.out.println("Läuft in: " + Thread.currentThread());
});
Der Weg zur Stabilität: Java 19 bis 21
Als ich den Entwurf angefangen habe, war das Feature noch als Preview markiert (JEP 425 in Java 19), musste also explizit mit --enable-preview aktiviert werden. Nichts, was man ernsthaft in Produktion einsetzen sollte. Java 20 hat mit JEP 436 eine zweite Preview-Runde nachgelegt. Vor allem mit API-Feinschliff.
Final und produktionsreif wurden virtuelle Threads dann erst mit Java 21 (JEP 444). Das im September 2023 als LTS-Version erschienen ist. Seitdem verbreiten sich virtuelle Threads schnell in Frameworks wie Spring Boot oder Quarkus.
Die Pinning-Falle und ihre Lösung in Java 24
Es gab ein Detail, das in der Java-21-Version noch für Diskussionen gesorgt hat. Wenn ein virtueller Thread innerhalb eines synchronized-Blocks blockiert hat, konnte er nicht vom Carrier Thread abgehängt werden. Er blieb „gepinnt“ (pinned). Bei kurzen synchronized-Blöcken war das meist kein Problem. Wer aber zum Beispiel innerhalb eines synchronized-Blocks eine Datenbankabfrage abgesetzt hat, konnte damit ungewollt einen ganzen Carrier Thread blockieren. Im schlimmsten Fall sogar in ein Deadlock-artiges Verhalten laufen, wenn alle Carrier Threads gepinnt waren.
Genau dieses Problem hat JEP 491 in Java 24 (März 2025) gelöst. synchronized wurde JVM-intern so umgebaut, dass virtuelle Threads Monitore erwerben, halten und freigeben können, ohne dafür an ihren Carrier Thread gebunden zu sein. Damit sind fast alle Pinning-Szenarien, die vorher noch nervig dokumentiert werden mussten, seit Java 24 einfach verschwunden. Wer also mit synchronized und virtuellen Threads gearbeitet und sich über Pinning geärgert hat. Ein Update auf Java 24 oder neuer lohnt sich für euch 🙂
Was Java 25 dazu beigetragen hat
Virtuelle Threads selbst sind nun seit Java 21 stabil. Das Ökosystem drumherum entwickelt sich aber weiter. Vor allem zwei verwandten Features stehen dort im Fokus:
- Scoped Values (JEP 506) sind mit Java 25 final geworden. Sie sind gedacht als sichererer, unveränderlicher Ersatz für
ThreadLocal, gerade im Zusammenspiel mit virtuellen Threads und Structured Concurrency: Ein Wert wird für einen begrenzten Ausführungsbereich gesetzt und automatisch an Kind-Threads vererbt, statt wie beiThreadLocalunbegrenzt und veränderlich im Speicher zu hängen. - Structured Concurrency steckt in Java 25 dagegen weiterhin in der Preview-Phase (JEP 505, mittlerweile die fünfte Preview-Runde). Die Grundidee ist dass mehrere nebenläufige Teilaufgaben als eine Einheit behandelt werden. Wenn eine Teilaufgabe scheitert, werden die anderen automatisch abgebrochen, und Fehler sowie Abbruch propagieren sauber nach oben, statt dass man sich Thread-Leaks oder verwaiste Tasks einfängt.
Beide Features sind kein Ersatz für virtuelle Threads, sondern bauen auf ihnen auf. Sie machen den nebenläufigen Code robuster und leichter nachvollziehbar.
Virtuelle Threads selbst sind seit Java 21 problemlos und produktiv einsetzbar, und mit Java 24+ auch ohne die alten Pinning-Fallstricke. Scoped Values sind seit Java 25 final nutzbar. Wer Structured Concurrency ausprobieren möchte, braucht weiterhin das passende Preview-Flag und sollte damit rechnen, dass sich die API bis zur finalen Version noch ändert (es gab schon einige Previews ;)).
Fazit
Virtuelle Threads haben das Thread-per-Request-Modell zurückgebracht, ohne dessen Skalierungsprobleme. Mit Scoped Values wächst gerade das Rüstzeug drumherum, um nebenläufigen Code nicht nur schnell, sondern auch sicher und nachvollziehbar zu schreiben. Für meine OCR-Batches genau das richtige Werkzeug zur richtigen Zeit.
Wohin sich das Ganze mit Structured Concurrency & Co. noch entwickelt, schauen wir uns in Teil 3 dieser Reihe an. Wer die Vorgeschichte verpasst hat, findet sie in Teil 1.
Setzt ihr virtuelle Threads schon produktiv ein? Postet es gerne in die Kommentare.
