Virtuelle Threads
Virtuelle Threads & strukturierte Nebenläufigkeit
Seit JDK 21 (LTS) bringt die JVM Virtual Threads mit: leichtgewichtige Threads, die die JVM selbst schedult — Millionen parallel sind kein Problem mehr. Für Spring Boot bedeutet das: blocking Code schreiben, trotzdem skalieren wie Reactive — ohne den Programmierstil zu wechseln.
Lektion 51: Platform-Threads vs. Virtual Threads
Ein Platform-Thread ist ein dünner Wrapper um einen OS-Thread: teuer in der Anschaffung (~1 MB Stack), begrenzt in der Anzahl (typisch ein paar tausend pro Prozess). Ein Virtual Thread lebt im Heap, kostet wenige KB und wird von der JVM auf wenige Carrier-Threads (ForkJoinPool) gemultiplext — M:N-Scheduling.
// Platform-Thread: teuer, OS-gebunden
Thread platform = Thread.ofPlatform().start(() -> {
System.out.println("OS-Thread: " + Thread.currentThread());
});
// Virtual Thread: billig, JVM-geschedult
Thread virtual = Thread.ofVirtual().start(() -> {
System.out.println("VT: " + Thread.currentThread());
});
Der entscheidende Punkt: Wenn ein Virtual Thread auf I/O wartet (HTTP-Call, JDBC-Query), gibt die JVM den Carrier-Thread frei — ein anderer VT kann laufen. Blockieren ist damit fast kostenlos, und genau deshalb funktioniert das klassische "ein Thread pro Request"-Modell wieder skaliert.
# application.yml — Spring Boot 3.2+ aktiviert VTs global:
# Tomcat-Worker, @Async, @Scheduled laufen dann auf Virtual Threads.
spring:
threads:
virtual:
enabled: true
Lektion 52: ExecutorService mit Virtual Threads
Der idiomatische Weg: Executors.newVirtualThreadPerTaskExecutor().
Kein Pool — pro Task ein neuer Virtual Thread, der nach dem Task
verworfen wird. Pools existieren, um teure Ressourcen zu schonen;
VTs sind billig, also poolt man sie nicht.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = insuredIds.stream()
.map(id -> executor.submit(() -> loadPolicy(id)))
.toList();
for (Future<String> f : futures) {
System.out.println(f.get()); // blockiert nur den VT, nicht die App
}
} // try-with-resources: wartet, bis alle Tasks fertig sind
| Ansatz | Threads | Typischer Einsatz |
|---|---|---|
Executors.newFixedThreadPool(200) | Platform, gepoolt | CPU-gebundene Arbeit |
newVirtualThreadPerTaskExecutor() | Virtual, 1/Task | I/O-lastige, parallele Calls |
CompletableFuture + @Async | hängt vom Pool ab | Fire-and-forget Aufgaben |
Ein Thread pro Task — kein Pooling
Virtual Threads sollen billig sein und kurz leben. Ein Pool vor VTs
widerruft genau das: er begrenzt die Parallelität künstlich und
mixt Platform-Thread-Probleme (Queue, Sizing) zurück in den Code.
Wenn die Datenbank begrenzt ist, limitiert man mit einer
Semaphore — nicht mit dem Thread-Pool.
Lektion 53: Pinning — wenn VT am Carrier klebt
Pinning tritt auf, wenn ein Virtual Thread innerhalb eines
synchronized-Blocks oder in einer Foreign-Function blockiert:
Er kann dann nicht vom Carrier-Thread abgehängt werden und blockiert
den OS-Thread mit. Bei vielen gleichzeitigen Requests können so
alle Carrier blockiert werden — trotz VTs keine Parallelität mehr.
// PROBLEM: blockierendes I/O in synchronized → Pinning
public synchronized Policy load(long id) {
return restTemplate.getForObject(url, Policy.class); // pinned!
}
// FIX: ReentrantLock statt synchronized — VT wird abgehängt
private final ReentrantLock lock = new ReentrantLock();
public Policy load(long id) {
lock.lock();
try {
return restTemplate.getForObject(url, Policy.class);
} finally {
lock.unlock();
}
}
# Pinning in Produktion sichtbar machen: JFR-Event jdk.VirtualThreadPinned
java -jar app.jar \
-XX:StartFlightRecording=filename=pinning.jfr \
-Djdk.tracePinnedThreads=full
In JMC (Mission Control) sieht man im Event jdk.VirtualThreadPinned,
welcher VT wie lange am Carrier hing — inklusive Stacktrace mit der
verursachenden synchronized-Stelle. Ab JDK 24 ist synchronized
übrigens weitgehend pinning-frei (JEP 491), aber der JFR-Check bleibt
Bestandteil des Betriebs-Handwerkszeugs.
Lektion 54: Structured Concurrency mit StructuredTaskScope
Zwei parallele Calls — etwa CropGuard ruft Policy und DWD-Trockenindex
gleichzeitig — enden klassisch in Handgemalten: ExecutorService
manuell beenden, Exceptions einsammeln, Timeout-Handling. Structured
Concurrency (JDK 21 Preview, --enable-preview) koppelt die
Lebensdauer von Sub-Tasks an einen Block: verlässt man den Scope,
laufen keine verwaisten Tasks mehr.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Policy> policy =
scope.fork(() -> policyService.load(id));
Subtask<DwdIndex> weather =
scope.fork(() -> dwdClient.currentIndex(region));
scope.join() // auf beide warten
.throwIfFailed(); // eine Exception bricht beide ab
return new RiskAssessment(policy.get(), weather.get());
} // Scope-Ende: garantiert keine verwaisten Sub-Tasks
Schlägt ein Sub-Task fehl, bricht ShutdownOnFailure die Geschwister
ab — keine Leaks, keine toten Threads, die auf ein Ergebnis warten,
das niemand mehr liest. Das ist die Nebenläufigkeits-Analogie zum
strukturierten Programmieren: ein Eingang, ein Ausgang, klare
Fehlerpropagierung.
Lektion 55: Wann VT Reactive ersetzen — und Fallstricke
Wann VTs statt WebFlux? Wenn das Team blocking Code mit klarem
Stacktrace bevorzugt: JDBC (Blocking), RestClient, @Async. VTs
liefern die Skalierung von Reactive ohne die kognitive Last von
Mono/Flux. Reactive bleibt stark bei nicht-blockierenden Streams
mit Backpressure (SSE, Kafka-Streaming). Für den klassischen
request/response-Stack ist VT der einfachere Weg.
Fallstricke, die im Betrieb echten Schaden anrichten:
- ThreadLocal bei Millionen VTs — Jeder VT hat sein eigenes
ThreadLocal. Was beim 200er-Pool 200 Kopien waren, sind bei 100.000 VTs 100.000 Kopien → Memory-Blowup. Kontext besser überScopedValue(Preview) oder explizite Parameter reichen. - Pooling von VTs — siehe Lektion 52:
Semaphorefürs DB-Limit, kein Thread-Pool. - CPU-gebundene Arbeit auf VTs — VTs beschleunigen nur Wartezeiten, keine Rechenzeit. Für CPU-Last bleibt ein Platform-Pool in Poolgröße = Kernanzahl der richtige Ort.
Blocking Code, async Skalierung
Virtual Threads ändern nicht, wie man Code schreibt — sie ändern, was der Code kostet. Ein JDBC-Call blockiert weiterhin den Thread, aber der Thread ist jetzt fast kostenlos. Genau deshalb ersetzen VTs in Spring Boot häufig das Reactive-Modell: gleiche Skalierung, lesbarer Code, normale Stacktraces.
Praxis-Szenario: CropGuard: Parallele DWD-Abfragen mit StructuredTaskScope
Die Risiko-Bewertung braucht Policy und aktuellen DWD-Trockenindex. Sequenziell dauern beide Calls 400 ms, unter Last staut sich der 200er Tomcat-Pool. Mit StructuredTaskScope laufen beide Calls parallel — Antwortzeit halbiert, und schlägt die DWD-API fehl, bricht der Scope beide Tasks sauber ab statt auf ein Timeout zu warten.
✅ Wichtige Erkenntnisse
Haken setzen, um deinen Lernfortschritt zu markieren: