W3docs

Java Virtual Threads

Leichtgewichtige, JVM-geplante Threads (Java 21+) für hochdurchsatzfähige nebenläufige Anwendungen — was sie lösen und wie sie die Pool-Größe verändern.

Jedes Kapitel in diesem Buchteil beschrieb bisher einen Plattform-Thread — einen Java-Thread, der eins zu eins auf einen OS-Thread abgebildet wird. Plattform-Threads sind leistungsfähig, aber teuer: Jeder belegt etwa 1 MB nativen Stack, und das Betriebssystem begrenzt einen Prozess auf grob einige Zehntausend davon. Für CPU-gebundene Arbeit ist das ausreichend. Für I/O-gebundene Arbeit — ein Webserver mit einem Thread pro Anfrage, der überwiegend auf eine Datenbank wartet — ist es eine harte Obergrenze, die seit zwei Jahrzehnten das zentrale Spannungsfeld im Java-Server-Design darstellt.

Java 21 hat Virtual Threads eingeführt, um genau diesen Fall zu lösen. Ein Virtual Thread ist ein Java-Thread, der von der JVM (nicht vom OS) auf einen kleinen Pool von OS-Carrier-Threads geplant wird. Sie sind günstig — Millionen pro JVM sind keine Seltenheit — und das Blockieren auf I/O parkt den Virtual Thread, ohne den Carrier zu blockieren. Der Code sieht genauso aus wie zuvor; das Kostenmodell ist anders.

Was sich ändert (und was nicht)

Virtual Threads sind java.lang.Threads. Die Klasse ist dieselbe; die Methoden sind dieselben; Thread.currentThread() funktioniert weiterhin. Was sich unterscheidet, ist die Planung und die Kosten:

  • Ein Plattform-Thread kostet etwa 1 MB nativen Stack und wird vom OS geplant.
  • Ein Virtual Thread kostet anfangs etwa 1 KB (wächst bei Bedarf) und wird von der JVM geplant.
  • Das Blockieren eines Plattform-Threads blockiert den darunterliegenden OS-Thread.
  • Das Blockieren eines Virtual Threads parkt den Virtual Thread; der Carrier-OS-Thread führt einen anderen Virtual Thread aus.

Der vierte Punkt ist entscheidend. Wenn ein Virtual Thread Socket.read(), Thread.sleep(), BlockingQueue.take(), Lock.lock() oder praktisch eine andere blockierende JDK-API aufruft, entkoppelt die JVM ihn von seinem Carrier, und der Carrier nimmt einen anderen Virtual Thread zur Ausführung auf. Der blockierte Virtual Thread kostet während des Wartens so gut wie nichts.

Virtual Threads erstellen

Es gibt drei Wege:

// 1. Direct
Thread t = Thread.ofVirtual().start(() -> doWork());

// 2. Builder
Thread t2 = Thread.ofVirtual().name("vt-", 0).start(this::work);    // names "vt-0", "vt-1", ...

// 3. Executor — the production form
try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {
  for (int i = 0; i < 10_000; i++) {
    es.submit(() -> handleRequest());
  }
}

Die Executor-Form ist die, die fast jeder Server verwendet. Sie vergibt pro übermittelter Aufgabe einen Virtual Thread; es gibt keinen Pool, dessen Größe man konfigurieren müsste, da der Carrier-Pool sich selbst anpasst.

Man kann auch explizit einen Plattform-Thread anfordern:

Thread t = Thread.ofPlatform().name("compute").start(() -> doCpu());

Nützlich für echte CPU-gebundene Arbeit, bei der die Eins-zu-eins-OS-Abbildung erwünscht ist.

Wann Virtual Threads gewinnen

Die Form, für die Virtual Threads optimiert sind:

  • Viele nebenläufige Aufgaben (Hunderte, Tausende, Millionen).
  • Jede Aufgabe verbringt die meiste Zeit blockiert auf I/O, Warteschlangen oder Sperren.
  • Die Arbeit wird nicht von der CPU dominiert.

Das ist genau das Web-Server-Muster: Jede Anfrage ist eine Aufgabe, die überwiegend auf eine Datenbank, einen vorgelagerten Dienst oder den Client wartet. Mit Plattform-Threads benötigt ein Server mit 1000 gleichzeitigen langsamen Anfragen 1000 Plattform-Threads — 1 GB nativen Stack und erhebliche OS-Scheduler-Last. Mit Virtual Threads läuft dieselbe Last auf etwa 8 Carriern; die 1000 Virtual Threads kosten zusammen nur wenige MB.

Das mentale Modell: Hören Sie auf, für I/O-Arbeit an Thread-Pools zu denken. Übergeben Sie pro Anfrage einen Virtual Thread und lassen Sie die Laufzeitumgebung den Rest erledigen.

Wann Virtual Threads nicht gewinnen

Einige Fälle, in denen sie nicht helfen oder sogar schaden:

  • CPU-gebundene Arbeit. Ein Virtual Thread, der reines Computing ausführt, kann nicht geparkt werden — er muss die ganze Zeit auf einem Carrier laufen. Man ist nicht schneller als die Anzahl der Carrier, die der CPU-Anzahl entspricht. Für CPU-Arbeit bleiben Plattform-Threads (und Fork/Join) das richtige Werkzeug.
  • Synchronized-Blöcke um I/O. Ein Virtual Thread innerhalb von synchronized (obj) { blockingIO(); } pinnt sich an seinen Carrier — die JVM kann ihn während des blockierenden Aufrufs nicht abkoppeln, weil der Monitor an den OS-Thread gebunden ist. Das ist eine echte Falle: Ein Server, der synchronized zum Schutz eines Datenbankaufrufs verwendet, skaliert mit Virtual Threads nicht. Die Lösung ist, stattdessen ReentrantLock zu verwenden (was der Virtual-Thread-Mechanismus korrekt behandelt).
  • ThreadLocal-Speicher mit vielen Threads. Virtual Threads unterstützen ThreadLocal, aber die Anzahl kann explodieren — Millionen von Virtual Threads × N Thread-Locals × Wertgröße = viel Speicher. Java 21 hat Scoped Values (ScopedValue) als strukturierte Alternative eingeführt.
  • Code, der davon ausgeht, dass Threads selten sind (z. B. der pro Thread eine Verbindung aufbaut). Eine Verbindung pro Virtual Thread ist eine Verbindung pro Anfrage, was die Datenbank nicht mag. Verwenden Sie einen echten Verbindungspool.

Zusammenfassung: Virtual Threads machen I/O-gebundene Nebenläufigkeit günstig, aber sie transformieren keine CPU-gebundene Arbeit und legen Code-Pfade offen, die sich an Carrier pinnen.

Pinning: der eine Punkt, den man beachten muss

Ein gepinnter Virtual Thread kann nicht abgekoppelt werden. Die zwei Ursachen für Pinning:

  1. synchronized-Blöcke, die einen blockierenden Aufruf enthalten.
  2. Native Methodenaufrufe, die in JNI blockieren.

Pinning lässt sich über die Systemeigenschaft erkennen:

java -Djdk.tracePinnedThreads=full ...

Wenn ein Virtual Thread während des Pinnens blockiert, gibt die JVM einen Stack-Trace aus. In der Produktion besteht die Lösung darin, synchronized durch ReentrantLock rund um die blockierende Region zu ersetzen. Zukünftige JDKs arbeiten daran, synchronized zu entpinnen (JEP 491 in Arbeit); bis dahin gilt jedes synchronized um einen I/O-Aufruf als Anti-Pattern für Virtual Threads.

Was ist mit wait, notify und join?

Alle funktionieren — Virtual Threads können auf intrinsischen Monitoren warten, benachrichtigt werden und gejoined werden. Die Laufzeitumgebung behandelt das Parken und Abkoppeln korrekt. Die Einschränkung betrifft nur synchronisierte Blöcke: Den Monitor durch einen blockierenden Aufruf innerhalb des Blocks zu halten, pinnt; wait() aufzurufen, um den Monitor freizugeben und zu parken, ist in Ordnung.

synchronized (lock) {
  lock.wait();                                    // OK — releases monitor, parks, no pin
}

synchronized (lock) {
  socket.read(buf);                                // BAD — holds monitor through blocking read; pins
}

Den Pool dimensionieren — es gibt keinen Pool

Die konzeptionelle Verschiebung, die Virtual Threads ermöglichen: Hören Sie auf zu dimensionieren. Jeder Executor, den Sie in diesem Buch konfiguriert haben, besaß einen Regler für die Thread-Anzahl. Mit newVirtualThreadPerTaskExecutor ist die Anzahl „so viele Anfragen wie gerade in Bearbeitung sind." Der Carrier-Pool (den Sie nicht direkt konfigurieren) dimensioniert sich selbst anhand der CPU-Anzahl; die Virtual Threads sind nur Buchhaltung.

In einem Server, der Virtual Threads verwendet:

  • Verbindungspools sind weiterhin wichtig. Ein Virtual Thread, der auf eine Verbindung wartet, ist in Ordnung; 10.000 zu spawnen, die alle einen 5-Verbindungs-Pool wollen, macht den Engpass nur sichtbar.
  • Rate-Limits sind weiterhin wichtig. Virtual Threads beseitigen das Thread-Limit, nicht das Limit des nachgelagerten Dienstes.
  • Speicher ist weiterhin wichtig. Jeder Virtual Thread hat einen Stack und etwaige ThreadLocals. Millionen davon sind Millionen von Stacks.

Virtual Threads beseitigen die Thread-Anzahl-Obergrenze; sie beseitigen nicht die darunterliegenden Einschränkungen, die die Obergrenze verdeckte.

Ein ausgearbeitetes Beispiel: eine Million Virtual Threads vs. ein Plattform-Thread

Das folgende Programm lässt 100.000 Aufgaben jeweils 200 ms schlafen, parallel. Mit Plattform-Threads (auf eine sinnvolle Anzahl begrenzt) dauert das lange und verbraucht viel RAM. Mit Virtual Threads ist es kaum länger als der Schlaf der einzelnen Aufgabe selbst.

java— editable, runs on the server

Was man aus dem Lauf mitnehmen sollte:

  • Die 100.000 Virtual Threads wurden in etwa einer Sekunde Echtzeit fertig — nahe an dem einzelnen 200-ms-Schlaf zuzüglich des Overheads für das Erstellen und Planen von 100.000 Threads, nicht 100.000 × 200 ms. Das ist der eigentliche Sinn von Virtual Threads: Die Nebenläufigkeit (wie viele Dinge gleichzeitig in Bearbeitung sind) ist von der Parallelität (wie viele Kerne CPU-Arbeit ausführen) entkoppelt. Die genaue Zahl variiert je nach Maschine, bleibt aber im gleichen niedrigen Sekundenbereich, egal wie hoch man die Aufgabenzahl treibt.
  • Der 5.000-Aufgaben-Plattform-Pool-Lauf mit 100 Worker-Threads dauerte etwa 5000 / 100 * 200 = ~10 Sekunden — die Aufgaben stauten sich, weil der Pool nur 100 gleichzeitig ausführen konnte. Um in derselben Echtzeit wie die Virtual-Thread-Version fertig zu werden, bräuchte der Plattform-Pool 100.000 Threads, was nahe an oder jenseits des OS-Limits der meisten Systeme liegt.
  • Thread.currentThread().isVirtual() unterschied die beiden Thread-Typen zur Laufzeit. Auch die Namen unterscheiden sich — Virtual Threads haben typischerweise eine generische Darstellung statt eines benutzerdefinierten Namens, sofern man keinen über den Builder gesetzt hat. Nützlich für das Logging, wenn man beide Arten mischt.
  • Die Pinning-Warnung ist der mit Abstand wichtigste Vorbehalt für Virtual Threads in der Produktion. Ein synchronized-Block um einen blockierenden Aufruf (Datenbank-I/O, Datei-I/O, Netzwerk) macht den größten Teil des Nutzens zunichte, weil der Carrier während des Wartens nicht freigegeben werden kann. Das Ersetzen von synchronized durch ReentrantLock hält den Virtual Thread parkbar.
  • Die Form try (ExecutorService vexec = ...) tat beim Schließen das Richtige — sie führte shutdown() aus und wartete, bis jede übermittelte Aufgabe fertig war. Bei 100.000 in Bearbeitung befindlichen Aufgaben war dieses Warten real (200 ms pro Aufgabe, alle zusammen geparkt, alle nahezu gleichzeitig fertig). Ohne das try-with-resources wäre der Executor am Leben geblieben und hätte Nicht-Daemon-Threads gehalten, und das Programm wäre hängengeblieben.

Ende von Teil 15

Dies ist das letzte Kapitel des Teils über Multithreading und Nebenläufigkeit. Wir sind von „ein Thread ist eine OS-Ebenen-Sache" über die Sperren, Atomics und nebenläufigen Collections, die man für korrekte gemeinsame Zustände benötigt, zum Executor-Framework gegangen, das Thread-Management verbirgt, dann zu CompletableFuture und ForkJoinPool für Komposition, und schließlich zu Virtual Threads für die I/O-intensive Last, mit der moderne Server tatsächlich konfrontiert sind.

Das Muster durch all das hindurch: Wählen Sie das kleinste Werkzeug, das Ihr konkretes Problem löst. Ein Zähler? AtomicInteger. Ein Flag? volatile. Ein Producer-Consumer? BlockingQueue. Viele parallele I/O-Aufrufe? Virtual Threads. Das Schlüsselwort synchronized ist immer noch richtig, wenn es richtig ist; Lock ist für die Fälle, in denen es das nicht ist; die High-Level-Executors und Futures sind für die Situationen, in denen man über beides hinausgewachsen ist. Gehen Sie im Stack nur dann tiefer, wenn die Abstraktion darüber nicht das tut, was Sie brauchen.

Der nächste Teil des Buchs behandelt Annotations — was die @-Markierungen an Klassen, Methoden und Feldern tatsächlich bewirken, die eingebauten in java.lang und die Regeln für das Schreiben eigener.

Übungen

Übung
In einem Server mit Virtual Threads umschließen Sie einen JDBC-Aufruf mit `synchronized (this) { jdbc.execute(sql); }`. Was ist die Konsequenz?
In einem Server mit Virtual Threads umschließen Sie einen JDBC-Aufruf mit `synchronized (this) { jdbc.execute(sql); }`. Was ist die Konsequenz?
Was this page helpful?