9 Virtuelle Threads und Structured Concurrency
9.1 Virtuelle Threads
9.1.1 Das Problem: Traditionelle Threads
Java-Threads waren bisher immer “Plattform-Threads”.
- 1:1-Mapping zum Thread des Betriebssystems (z.B. POSIX-Thread, Windows-Thread).
- “Schwergewichtig”: Das Erstellen eines OS-Threads ist teuer (Speicher & CPU-Zeit), u.a. weil System Calls mit Kontextwechsel in Kernel-Mode beteiligt sind.
- Begrenzte Anzahl: Die maximale Anzahl an Threads ist durch das Betriebssystem und die Hardware limitiert (oft nur wenige tausend).
Der “alte” Weg:
Max. Anzahl von Threads doch nicht so begrenzt wie erwartet…
❯ cat /proc/sys/kernel/?(threads-|pid_)max /proc/sys/vm/max_map_count
4194304
507217
10485769.1.2 Das “Thread-per-Request”-Modell
Ein gängiges Muster in Server-Anwendungen:
Problem: Was passiert, wenn 10.000 Clients gleichzeitig eine Anfrage stellen?
- Die Anwendung versucht, 10.000 Plattform-Threads zu erstellen.
- Das System wird überlastet, stürzt ab oder wird extrem langsam (
OutOfMemoryError). - Die Skalierbarkeit ist stark eingeschränkt.
9.1.3 Die Lösung: Virtuelle Threads
Seit Java 21 sind Virtuelle Threads (Teil von Project Loom) standardmäßig verfügbar.
- M:N-Mapping: Viele virtuelle Threads laufen auf wenigen Plattform-Threads (sog. “Carrier Threads”).
- “Leichtgewichtig”: Von der JVM verwaltet, nicht vom Betriebssystem. Benötigen kaum Speicher.
- Millionen möglich: Man kann problemlos Millionen von virtuellen Threads erstellen.
ideal für…
- I/O-lastige Aufgaben (z.B. Warten auf Netzwerk, Datenbank, Dateisystem), bei denen der Thread die meiste Zeit blockiert ist
- sehr viele kleine, unabhängige Aufgaben
9.1.4 Wie funktionieren sie?
Wenn ein virtueller Thread auf eine I/O-Operation wartet…
- …wird er von seinem Carrier-Thread (Plattform-Thread) “entkoppelt” (unmounted).
- Der Carrier-Thread ist nun frei und kann einen anderen virtuellen Thread ausführen.
- Sobald die I/O-Operation abgeschlossen ist, wird der ursprüngliche virtuelle Thread wieder zur Ausführung eingeplant (mounted).
Das ist der Game-Changer: Warten blockiert nicht mehr den wertvollen OS-Thread!
Hinweis: Viele virtuelle Threads teilen sich wenige Carrier-Threads (“M:N-Scheduling”) – blockierende I/O parkt den virtuellen Thread, nicht den Carrier-Thread.
9.2 Thread APIs
9.2.1 Neues Thread API: ExecutorService für virtuelle Threads
- seit Java 21 neue Factory-Methode
newVirtualThreadPerTaskExecutor()fürExecutorService - erzeugt und verwendet jeweils neue virtuelle Threads auf dem
commonPool:
Der empfohlene Weg für die meisten Anwendungen.
Hinweis: Der Executor-Service sollte geschlossen werden, um Ressourcen freizugeben. Verwenden Sie try-with-resources oder rufen Sie shutdown() manuell auf.
9.2.2 Beispiel ExecutorService
Simulation von vielen kurzen I/O-lastigen Aufgaben:
void main() {
try (var executor = Executors newCachedThreadpool()) {
for (int i = 0; i <= 10000; i++) {
final int fi = i;
executor.submit(() -> {
IO.println(fi + ": " + Thread.currentThread());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
}void main() {
try (var executor = Executors.newThreadPerTaskExecutor(Executors.defaultThreadFactory())) {
for (int i = 0; i <= 10000; i++) {
final int fi = i;
executor.submit(() -> {
IO.println(fi + ": " + Thread.currentThread());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
}void main() {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i <= 10000; i++) {
final int fi = i;
executor.submit(() -> {
IO.println(fi + ": " + Thread.currentThread());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
}9.2.3 Neues Thread API: Komfortable Factory Methode
seit Java 21 startVirtualThread(Runnable):
- erzeugt einen neuen virtuellen Thread (Typ:
Thread) - mit dem übergebenen
Runnable - im
commonPool - startet den virtuellen Thread
- gibt Referenz auf den virtuellen Thread zurück
für Einzeiler
9.2.4 Beispiel: Virtuelle Threads mit startVirtualThread(Runnable)
- 1
-
virtuellen Thread starten (Ergebnis ist Referenz auf
Thread-Objekt) - 2
-
Ausgabe z.B.
VirtualThread[#35]/runnable@ForkJoinPool-1-worker-1 - 3
- virtuelles Thread-sleep <- wie Plattform-Threads
- 4
- Interrupt-Status wiederherstellen <- wie Plattform-Threads
- 5
- auf Beendigung warten <- wie Plattform-Threads
9.2.5 Neues Thread API: Thread Builder API
seit Java 21 Thread.ofVirtual() und Thread.ofPlatform():
- 1
-
Erstellt einen Builder für virtuelle Threads. Alternativ:
Thread.ofPlatform()für Plattform-Threads. - 2
-
Setzt den Namen des Threads (wie
setName(String)). - 3
-
Setzt einen Handler für nicht abgefangene Exceptions (wie
setUncaughtExceptionHandler) - 4
-
Startet den Thread mit dem angegebenen
Runnableund gibt dieThread-Referenz zurück. - 5
- Wartet auf die Beendigung des Threads.
9.2.6 Thread Builder API: Factory für Threads
- 1
-
Erstellt eine
ThreadFactoryfür virtuelle Threads. - 2
-
Erstellt eine
ThreadFactoryfür Plattform-Threads. - 3
-
Erzeugt einen neuen Thread (zunächst ungestartet) mit dem
Runnable.
9.2.7 Thread Builder API: ungestartete oder gestartete Threads vom Builder
Runnable r = () -> IO.println(Thread.currentThread().getName());
1Thread t = Thread
.ofVirtual()
.name("worker")
.unstarted(r);
t.start();
Thread.ofVirtual().start(r);
Thread.ofPlatform().start(r);- 1
- Unstarted: Thread-Objekt erzeugen und später starten
9.3 Pinning bei virtuellen Threads
9.3.1 Pinning und gegenseitiger Ausschluss
Ein virtueller Thread kann an seinen Carrier-Thread “gepinnt” werden.
Pinning bedeutet, dass der virtuelle Thread nicht vom Carrier-Thread entkoppelt werden kann, selbst wenn er blockiert.
Konsequenz: Der Carrier-Thread ist blockiert und kann keine anderen virtuellen Threads ausführen. Dies untergräbt den gesamten Vorteil von virtuellen Threads!
Achtung: Pinning kann die Skalierbarkeit drastisch reduzieren! Das möchte man vermeinden!!
9.3.2 Wann passiert Pinning?
Die zwei häufigsten Ursachen für Pinning sind:
synchronized-Blöcke/-Methoden: Wenn ein virtueller Thread innerhalb einessynchronized-Blocks eine blockierende Operation ausführt (z.B. I/O,Thread.sleep,LockSupport.park), wird er an den Carrier gepinnt.- Native Methoden (JNI): Aufrufe in nativen Code (C/C++) pinnen den Thread ebenfalls.
9.3.3 Die Lösung: ReentrantLock
Statt synchronized sollte man java.util.concurrent.locks.ReentrantLock verwenden.
ReentrantLock ist “virtual-thread-aware” und führt nicht zu Pinning.
Faustregel: Ersetze synchronized durch ReentrantLock in Code, der von virtuellen Threads ausgeführt wird.
9.3.4 Best Practices mit virtuellen Threads
- Thread-per-Task-Modell für I/O-lastige Workloads nutzen
- Keine blockierende I/O in
synchronized-Blöcken; stattdessenReentrantLock(ggf.tryLockmit Timeout)ReadWriteLock/StampedLockfür lese-lastige Workloads
- Kritische Abschnitte klein halten; nur kurze, nicht-blockierende Arbeit darin
- Zeitlimits und Cancellation vorsehen (Timeouts, Abbruch bei Fehlern)
- API-Wahl:
- Kurzlebige Tasks \(\to\)
Thread.startVirtualThread(...) - Längerlebige/Batch \(\to\)
Executors.newVirtualThreadPerTaskExecutor()
- Kurzlebige Tasks \(\to\)
- Für CPU-bound Arbeit Parallelität begrenzen
- Fixed-Size-Executor oder mit
StructuredTaskScopegezielt limitieren
- Fixed-Size-Executor oder mit
9.4 Structured Concurrency
Preview Feature in Java 25
viele Änderungen zu vorigen Versionen, noch nicht stabil – zukünftig noch Änderungen!!
java --enable-preview Main.java
9.4.1 Das Problem: Spaghetti-Code bei Nebenläufigkeit
Traditionelle Concurrency ist oft “fire-and-forget”.
- Ein Thread startet andere Threads und “vergisst” sie.
- Fehlerbehandlung, Abbruch und das Abwarten auf Ergebnisse werden komplex.
- Wer ist für welchen Thread verantwortlich? Es entsteht ein unstrukturiertes Chaos.
Structured Concurrency behandelt nebenläufige Aufgaben als eine einzige Arbeitseinheit.
9.4.2 StructuredTaskScope
Das Kernkonzept ist der StructuredTaskScope: ermöglicht Strukturierung und Kontrolle der Lebenszeit für nebenläufige Aufgaben
- Alle Aufgaben, die in einem Scope gestartet (
fork) werden, müssen beendet sein, bevor der Hauptthread den Scope-Block verlässt. - Wenn eine Aufgabe fehlschlägt, werden die anderen abgebrochen.
- Wenn der Hauptthread den Scope verlässt (z.B. durch eine Exception), werden alle Kind-Tasks automatisch abgebrochen.
Erreicht wird dies durch unterschiedliche Joiner-Strategien.
9.4.3 Joiner: Shutdown on Failure
Warten auf alle erfolgreichen Tasks: Liefert einen Stream der Ergebnisse
allSuccessfulOrThrow: Bei einem Fehler wird der Scope abgebrochen und alle Tasks beendet. Ansonsten wird gewartet, bis alle Tasks erfolgreich sind.awaitAllSuccessfulOrThrow: wieallSuccessfulOrThrow, wenn Tasks Ergebnisse unterschiedlichen Typs haben.awaitAll: wieallSuccessfulOrThrow, wenn Tasks keinen Rückgabewert haben.
9.4.4 Joiner: Shutdown on Success
Warten auf den ersten erfolgreichen Task oder spezifischere Bedingung:
anySuccessfulResultOrThrow: Der Scope beendet sich, sobald eine Task erfolgreich abgeschlossen ist.allUntil(Predicate): Der Scope beendet sich, sobald die BedingungPredicateerfüllt ist oder alle Tasks abgeschlossen sind.
9.4.5 Beispiel: ShutdownOnSuccess (“Race”)
Las Vegas Algorithmus zur Primzahlerzeugung (z.B. für RSA-Schlüsselerzeugung)
structuredPrime.java
import java.util.concurrent.StructuredTaskScope.Joiner;
BigInteger parallelPrime(int bitLength) throws InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<BigInteger>anySuccessfulResultOrThrow())) {
for (var i = 0; i < ForkJoinPool.commonPool().getParallelism(); i++) {
scope.fork(() -> BigInteger.probablePrime(bitLength, ThreadLocalRandom.current()));
}
return scope.join();
}
}
void main() throws InterruptedException {
IO.println(parallelPrime(4096));
}Kompilieren und Ausführen mit Preview-Features
$ java --enable-preview structuredPrime.java9.5 Zusammenfassung
- Virtuelle Threads sind die Zukunft der Concurrency in Java für I/O-lastige Anwendungen. Sie sind leichtgewichtig und ermöglichen massive Skalierbarkeit.
- Die neue Thread-API (
Thread.ofVirtual(),Executors.newVirtualThreadPerTaskExecutor()) macht die Verwendung einfach. - Pinning ist eine Leistungsfalle. Vermeide
synchronizedin Code, der von virtuellen Threads ausgeführt wird, und nutze stattdessenReentrantLock. - Structured Concurrency (
StructuredTaskScope) bringt Ordnung in nebenläufige Abläufe, verbessert die Fehlerbehandlung und macht den Code verständlicher.