Virtuelle Threads und Structured Concurrency

Parallele Programmierung (3IB)

Prof. Dr.-Ing. Sandro Leuchter
Technische Hochschule Mannheim, Fakultät für Informatik
Wintersemester 2025/2026

 

Dieses Werk ist lizenziert unter einer Creative Commons „Namensnennung – Nicht-kommerziell – Weitergabe unter gleichen Bedingungen 4.0 International“ Lizenz.

Überblick

  • Problem: Traditionelle Threads in Java
  • Lösung: Virtuelle Threads
  • Nutzung: Die neue Thread API
  • Achtung: Pinning und gegenseitiger Ausschluss
  • Problem: Spaghetti-Code bei Nebenläufigkeit
  • Lösung: Structured Concurrency
  • Nutzung: StructuredTaskScope

Virtuelle Threads

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:

Runnable task = () -> IO.println("Plattform-Thread!");
Thread platformThread = new Thread(task);
platformThread.start();

Das “Thread-per-Request”-Modell

Ein gängiges Muster in Server-Anwendungen:

while (true) {
    var request = server.accept();
    var handler = new Thread(() -> handle(request));
    handler.start();
}

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.

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.

Tipp

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

Wie funktionieren sie?

Wenn ein virtueller Thread auf eine I/O-Operation wartet…

  1. …wird er von seinem Carrier-Thread (Plattform-Thread) “entkoppelt” (unmounted).
  2. Der Carrier-Thread ist nun frei und kann einen anderen virtuellen Thread ausführen.
  3. 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.

Thread APIs

Neues Thread API: ExecutorService für virtuelle Threads

  • seit Java 21 neue Factory-Methode newVirtualThreadPerTaskExecutor() für ExecutorService
  • erzeugt und verwendet jeweils neue virtuelle Threads auf dem commonPool:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> IO.println("Task 1"));
    executor.submit(() -> IO.println("Task 2"));
} // Executor wird automatisch geschlossen (try-with-resources)

Tipp

Der empfohlene Weg für die meisten Anwendungen.

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();
                }
            });
        }
    }
}

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
Thread.startVirtualThread(() -> { 
    System.out.println("Kurzlebiger Task");
});

Tipp

für Einzeiler

Beispiel: Virtuelle Threads mit startVirtualThread(Runnable)

void main() throws InterruptedException {
1    Thread.startVirtualThread(() -> {
2        IO.println(Thread.currentThread());
        try {
3            Thread.sleep(200);
4        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
5    }).join();
}
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

Neues Thread API: Thread Builder API

seit Java 21 Thread.ofVirtual() und Thread.ofPlatform():

void main() throws InterruptedException {
1    var vt = Thread.ofVirtual()
2        .name("worker")
3        .uncaughtExceptionHandler((t, th) -> IO.println("Exception in " + t.getName() + ": " + th.getMessage()))
4        .start(() -> IO.println(Thread.currentThread().getName()));
5    vt.join();
}
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 Runnable und gibt die Thread-Referenz zurück.
5
Wartet auf die Beendigung des Threads.

Thread Builder API: Factory für Threads

void main() {
    Runnable r = () -> IO.println(Thread.currentThread());

1    var vFactory = Thread.ofVirtual().factory();
3    Thread vt = vFactory.newThread(r);
    vt.start(); 

2    var pFactory = Thread.ofPlatform().factory();
    Thread pt = pFactory.newThread(r);
    pt.start(); 
}
1
Erstellt eine ThreadFactory für virtuelle Threads.
2
Erstellt eine ThreadFactory für Plattform-Threads.
3
Erzeugt einen neuen Thread (zunächst ungestartet) mit dem Runnable.

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

Pinning bei virtuellen Threads

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!

Warnung

Achtung: Pinning kann die Skalierbarkeit drastisch reduzieren! Das möchte man vermeinden!!

Wann passiert Pinning?

Die zwei häufigsten Ursachen für Pinning sind:

  1. synchronized-Blöcke/-Methoden: Wenn ein virtueller Thread innerhalb eines synchronized-Blocks eine blockierende Operation ausführt (z.B. I/O, Thread.sleep, LockSupport.park), wird er an den Carrier gepinnt.
  2. Native Methoden (JNI): Aufrufe in nativen Code (C/C++) pinnen den Thread ebenfalls.
// VORSICHT: Dieser Code führt zu Pinning!
public synchronized void aBadMethod() {
    // Wenn hier eine blockierende I/O-Operation stattfindet,
    // wird der Carrier-Thread blockiert!
    var line = IO.readln(); 
}

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.

// Gut: Verwendet ReentrantLock
private final Lock lock = new ReentrantLock();

public void aGoodMethod() {
    lock.lock(); // Lock erwerben
    try {
        // Blockierende Operationen hier sind unproblematisch
        var line = IO.readln();
    } finally {
        lock.unlock(); // Lock im finally-Block freigeben
    }
}

Tipp

Faustregel: Ersetze synchronized durch ReentrantLock in Code, der von virtuellen Threads ausgeführt wird.

Best Practices mit virtuellen Threads

  • Thread-per-Task-Modell für I/O-lastige Workloads nutzen
  • Keine blockierende I/O in synchronized-Blöcken; stattdessen
    • ReentrantLock (ggf. tryLock mit Timeout)
    • ReadWriteLock/StampedLock fü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()
  • Für CPU-bound Arbeit Parallelität begrenzen
    • Fixed-Size-Executor oder mit StructuredTaskScope gezielt limitieren

Structured Concurrency

Preview Feature in Java 25
viele Änderungen zu vorigen Versionen, noch nicht stabil – zukünftig noch Änderungen!!

java --enable-preview Main.java

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.

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.

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: wie allSuccessfulOrThrow, wenn Tasks Ergebnisse unterschiedlichen Typs haben.
  • awaitAll: wie allSuccessfulOrThrow, wenn Tasks keinen Rückgabewert haben.

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 Bedingung Predicate erfüllt ist oder alle Tasks abgeschlossen sind.

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.java

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 synchronized in Code, der von virtuellen Threads ausgeführt wird, und nutze stattdessen ReentrantLock.
  • Structured Concurrency (StructuredTaskScope) bringt Ordnung in nebenläufige Abläufe, verbessert die Fehlerbehandlung und macht den Code verständlicher.