W3docs

Java Virtual Threads im Detail

Ein tieferer Blick auf Java Virtual Threads — Pinning, Scheduling und Migration von Platform Threads.

Platform Threads — die einzige Thread-Art, die Java bis JDK 21 kannte — werden eins zu eins auf Betriebssystem-Threads abgebildet. Sie sind teuer: Jeder reserviert etwa ein Megabyte Stack-Speicher, und der OS-Scheduler kommt bei einigen Tausend an seine Grenzen, bevor Context-Switching die CPU auffrisst. Virtual Threads, geliefert durch Project Loom, durchbrechen diese Schranke. Sie sind leichtgewichtige Threads, die von der JVM und nicht vom Betriebssystem verwaltet werden, sodass ein einziges Programm Millionen davon ausführen kann. Dieses Kapitel geht über die Einführung hinaus: wie sie geplant werden, was Pinning ist, wie Structured Concurrency ihre Lebenszeiten zusammenfasst und wo sie helfen (und wo nicht).

Wenn Sie mit dem Thema neu sind, lesen Sie zuerst die Einführung in Virtual Threads; dieses Kapitel setzt voraus, dass Sie bereits wissen, wie man einen startet. Eine Grundlage in Java-Multithreading und dem Executor-Framework ist ebenfalls hilfreich.

Platform Threads vs. Virtual Threads

Ein Virtual Thread ist weiterhin ein java.lang.Thread — dieselbe API, dasselbe Runnable. Der Unterschied liegt darin, was ihn unterstützt. Ein Platform Thread ist ein OS-Thread für seine gesamte Lebensdauer. Ein Virtual Thread läuft auf einem kleinen Pool von Platform Threads, den sogenannten Carriers: Wenn er bei I/O blockiert, hängt die JVM ihn von seinem Carrier ab, gibt diesen Carrier für einen anderen Virtual Thread frei und hängt den Virtual Thread später wieder ein, wenn die I/O abgeschlossen ist. Einen Virtual Thread zu blockieren ist günstig; einen Platform Thread zu blockieren verschwendet eine knappe Ressource.

AspektPlatform ThreadVirtual Thread
Gestützt durchEinen OS-ThreadEinen gepoolten Carrier-Thread
Speicherkosten~1 MB fixer StackEinige hundert Bytes, wächst bei Bedarf
Praktische AnzahlTausendeMillionen
Am besten fürCPU-intensive ArbeitI/O-intensive, hochnebenläufige Arbeit
Beim BlockierenVerschwendet den OS-ThreadWird abgehängt; der Carrier wird wiederverwendet
LebenszyklusPool anlegen und wiederverwendenEinen pro Aufgabe erstellen, wegwerfen

Das Denkmodell kehrt sich um. Mit Platform Threads dimensionieren Sie einen Pool sorgfältig und verwenden Threads wieder. Mit Virtual Threads erstellen Sie einen pro Aufgabe und lassen ihn sterben — sie sind günstig genug, um wegwerfbar zu sein.

Virtual Threads erstellen

Es gibt drei idiomatische Einstiegspunkte. Für eine einzelne Aufgabe verwenden Sie den Thread.ofVirtual()-Builder oder die Abkürzung Thread.startVirtualThread; für viele Aufgaben verwenden Sie einen Virtual-Thread-per-Task-Executor.

// One-off, started immediately.
Thread t = Thread.startVirtualThread(() ->
    System.out.println("hi from " + Thread.currentThread()));
t.join();

// Builder: configure before starting.
Thread named = Thread.ofVirtual().name("worker-", 0).unstarted(() -> doWork());
named.start();

// Many tasks: the executor creates a fresh virtual thread per submitted task.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000; i++) {
        executor.submit(() -> handleRequest());
    }
} // close() waits for every task to finish

Legen Sie niemals Virtual Threads in einen Pool. Ein traditioneller fixer Thread-Pool begrenzt die Nebenläufigkeit absichtlich; Virtual Threads in Executors.newFixedThreadPool(...) zu verpacken wirft ihren gesamten Vorteil weg. Das richtige Werkzeug ist newVirtualThreadPerTaskExecutor(), das keine Größenbeschränkung hat.

Scheduling, Carriers und Pinning

Virtual Threads werden durch einen dedizierten ForkJoinPool geplant, dessen Worker-Anzahl standardmäßig der Anzahl der CPU-Kerne entspricht. Diese Worker sind die Carrier-Threads. Wenn ein Virtual Thread in einen blockierenden Aufruf im JDK trifft — Thread.sleep, Socket-Reads, BlockingQueue.takehängt die Laufzeitumgebung ihn ab, damit der Carrier etwas anderes ausführen kann.

Manchmal kann ein Virtual Thread nicht abgehängt werden und bleibt an seinen Carrier gebunden. Dies ist Pinning, und es macht den Zweck zunichte: Ein blockierter, aber gepinnter Virtual Thread hält einen Carrier als Geisel. Zwei Situationen verursachen dies:

Pinning-UrsacheWarum es passiertLösung
Innerhalb eines synchronized-Blocks/einer MethodeDer Monitor ist an den Carrier gebundenErsetzen durch ReentrantLock
Innerhalb eines nativen (JNI) AufrufsDie Laufzeit kann den nativen Stack nicht erfassenBlockierung in nativem Code vermeiden
// Pins the carrier while sleeping — bad.
synchronized (lock) {
    Thread.sleep(1000); // the virtual thread cannot unmount here
}

// Does not pin — good.
lock.lock();
try {
    Thread.sleep(1000); // the virtual thread unmounts freely
} finally {
    lock.unlock();
}

Sie können Pinning diagnostizieren, indem Sie mit -Djdk.tracePinnedThreads=full ausführen. Das gibt einen Stack-Trace aus, wann immer ein Virtual Thread seinen Carrier pinniert.

Structured Concurrency

Das spontane Erzeugen von Threads führt zu Leaks: Wenn eine Unteraufgabe fehlschlägt, laufen ihre Geschwister weiter und man muss daran denken, sie abzubrechen. Structured Concurrency (StructuredTaskScope, eine Preview-API) lässt eine Gruppe von Unteraufgaben wie eine einzige Arbeitseinheit verhalten — sie werden gemeinsam geforkt, gemeinsam gejoint und gemeinsam abgebrochen. Wenn der übergeordnete Scope endet, sind alle untergeordneten Threads garantiert abgeschlossen.

import java.util.concurrent.StructuredTaskScope;

Response handle() throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        var user  = scope.fork(() -> fetchUser());     // subtask 1
        var order = scope.fork(() -> fetchOrder());    // subtask 2

        scope.join();            // wait for both
        scope.throwIfFailed();   // propagate the first failure, cancel the rest

        return new Response(user.get(), order.get());
    } // both subtasks are guaranteed finished or cancelled here
}

ShutdownOnFailure bricht die verbleibenden Unteraufgaben ab, sobald eine eine Ausnahme wirft; ShutdownOnSuccess kehrt zurück, sobald die erste Unteraufgabe erfolgreich ist (praktisch für das Racing redundanter Aufrufe). In beiden Fällen gibt es keine verwaisten Threads. Für die vollständige API und weitere Muster siehe das Kapitel Structured Concurrency.

Ein Praxisbeispiel: Zehntausend nebenläufige Aufgaben

Das folgende Programm übergibt 10.000 I/O-intensive Aufgaben — jede schläft nur 50 ms, um einen Netzwerkaufruf zu simulieren — an einen Virtual-Thread-per-Task-Executor. Es zählt, wie viele verschiedene Carrier-Threads die Arbeit tatsächlich ausgeführt haben, und vergleicht die Wanduhrzeit mit der sequenziellen Ausführung derselben Aufgaben.

java— editable, runs on the server

Was man aus dem Lauf mitnehmen sollte:

  • 10.000 Aufgaben werden abgeschlossen, und dennoch endet der gesamte Lauf in deutlich unter einer Sekunde — weit entfernt von den ~500.000 ms, die dieselben Sleeps bei sequenzieller Ausführung benötigen würden, weil das gesamte Warten überlappt.
  • Die Anzahl der Carrier-Threads entspricht der Anzahl der CPU-Kerne (Carrier threads stimmt mit Available cores überein): Tausende von Virtual Threads werden auf diese kleine Handvoll von Platform Threads gemultiplext.
  • Thread.sleep hängt den Virtual Thread von seinem Carrier ab — genau deshalb können so wenige Carriers so vielen Aufgaben gleichzeitig dienen — der Carrier sitzt nie untätig wartend.
  • Das Schließen des newVirtualThreadPerTaskExecutor() in einem try-with-resources-Block blockiert, bis jede übermittelte Aufgabe abgeschlossen ist, sodass der abgeschlossene Zähler immer 10.000 erreicht, bevor das Timing ausgegeben wird.
  • isVirtual() gibt true zurück und isDaemon() gibt true zurück — Virtual Threads sind immer Daemon-Threads, daher halten sie die JVM nie allein am Leben.

Wann Virtual Threads verwenden (und wann nicht)

Virtual Threads sind ein Gewinn, wenn Ihre Aufgaben die meiste Zeit mit Warten verbringen — auf das Netzwerk, eine Datenbank, eine Datei oder einen nachgelagerten Dienst. Das ist die übliche Form serverseitiger Arbeit, daher lautet der typische Rat: Verwenden Sie einen Virtual Thread pro Anfrage und schreiben Sie gewöhnlichen blockierenden Code.

Sie sind kein Speedup für CPU-intensive Arbeit. Eine Aufgabe, die Zahlen verarbeitet, blockiert nie und hängt sich daher nie ab; eine Million davon auszuführen fügt nur Planungs-Overhead hinzu. Für reine Berechnungen dimensionieren Sie stattdessen einen Pool nach Ihrer Core-Anzahl. Noch zwei Dinge zu beachten:

  • Prüfen Sie heiße Pfade auf synchronized-Blöcke, die blockierende Aufrufe umhüllen, und migrieren Sie sie zu ReentrantLock, um Pinning zu vermeiden.
  • Legen Sie Virtual Threads nicht in einen Cache oder Pool, und verlassen Sie sich nicht auf Thread-lokalen Zustand, um die Nebenläufigkeit zu begrenzen — verwenden Sie ein Semaphor oder einen anderen expliziten Begrenzer, wenn Sie drosseln müssen.

Übungen

Übung
Sie verpacken Virtual Threads in Executors.newFixedThreadPool(200), um 10.000 I/O-intensive Aufgaben auszuführen. Warum ist das ein Fehler?
Sie verpacken Virtual Threads in Executors.newFixedThreadPool(200), um 10.000 I/O-intensive Aufgaben auszuführen. Warum ist das ein Fehler?
Was this page helpful?