Skip to content
Lektion undefined von undefined0/undefined abgeschlossen
Modul 11 — Virtuelle Threads & strukturierte Nebenläufigkeit

Virtuelle Threads

0 pts

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
AnsatzThreadsTypischer Einsatz
Executors.newFixedThreadPool(200)Platform, gepooltCPU-gebundene Arbeit
newVirtualThreadPerTaskExecutor()Virtual, 1/TaskI/O-lastige, parallele Calls
CompletableFuture + @Asynchängt vom Pool abFire-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 über ScopedValue (Preview) oder explizite Parameter reichen.
  • Pooling von VTs — siehe Lektion 52: Semaphore fü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.
Java Playground — Parallele Abfragen simuliertBereit
Editor wird geladen…

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:

    This site uses essential cookies for Stripe payments. No tracking cookies.