Java Virtual Threads: praktischer Leitfaden
1. Überblick
Virtual Threads sind eine offizielle Funktion ab Java 21 (JEP 444), die es Ihnen ermöglicht, blockierenden Code (Blocking-Stil) zu schreiben, aber gleichzeitig denselben Durchsatz wie Non-Blocking-I/O zu erreichen. Dieser Artikel führt Sie vom Konzept über die Erstellung von Virtual Threads, dem Leistungsvergleich mit Platform Threads bis zur Integration in Spring Boot. Nach der Lektüre wissen Sie, wann Sie Virtual Threads einsetzen sollten und wann nicht.
2. Das Problem mit traditionellen Platform Threads
Jeder Platform Thread in Java ist 1:1 auf einen OS-Thread abgebildet. Ein OS-Thread verbraucht viel Speicher (üblicherweise 1MB Stack) und der Kontextwechsel hat hohe Kosten. Daher werden Anwendungen, die Zehntausende gleichzeitiger Anfragen verarbeiten (z. B. Server, die auf I/O warten), durch die Anzahl der Threads, die der Thread Pool erstellen kann, begrenzt.
ExecutorService executor = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
try {
Thread.sleep(1000); // I/O-Aufruf simulieren
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
Bei einem Pool von 200 Threads müssen 10.000 Tasks warten, und die Gesamtabschlusszeit verlängert sich, da jeder Thread nur eine Task gleichzeitig verarbeiten kann, obwohl die meiste Zeit wartend verbracht wird, nicht beim Rechnen.
3. Was ist ein Virtual Thread?
Ein Virtual Thread ist ein von der JVM verwalteter Thread (nicht vom OS), der auf einen Platform Thread (genannt Carrier Thread) nur dann “gemountet” wird, wenn er Code ausführt. Wenn ein Virtual Thread eine blockierende Operation aufruft (I/O, Thread.sleep, Lock…), “unmountet” die JVM ihn automatisch vom Carrier Thread, gibt den Carrier Thread frei, um andere Virtual Threads zu verarbeiten.
Das Ergebnis: Sie können Millionen von Virtual Threads erstellen, ohne sich Sorgen um Ressourcen zu machen, während der Code weiterhin im vertrauten blockierenden Stil geschrieben wird — ohne CompletableFuture, ohne Reactive.
3.1. Virtual Threads einfach erstellen
Thread vThread = Thread.ofVirtual()
.name("order-processor")
.start(() -> {
System.out.println("Läuft auf: " + Thread.currentThread());
});
vThread.join();
Thread.ofVirtual() gibt einen Builder zurück, um den Thread zu konfigurieren. .start() erstellt und startet ihn sofort. Wenn Sie es ausführen, sehen Sie:
Läuft auf: VirtualThread[#21,order-processor]/runnable@ForkJoinPool-1-worker-1
Beachten Sie den Teil @ForkJoinPool-... — das ist der Carrier Thread, der diesen Virtual Thread gerade “trägt”.
4. Executor für Virtual Threads
Die häufigste Möglichkeit, Virtual Threads in echten Anwendungen zu verwenden, ist über ExecutorService.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
int id = i;
futures.add(executor.submit(() -> {
Thread.sleep(1000); // I/O-Aufruf simulieren (DB, HTTP…)
return "Task " + id + " fertig";
}));
}
for (Future<String> f : futures) {
f.get();
}
}
newVirtualThreadPerTaskExecutor() erstellt einen neuen Virtual Thread für jede Task, anstatt Threads wie in einem traditionellen Thread Pool wiederzuverwenden. Da die Kosten für die Erstellung eines Virtual Threads sehr niedrig sind (einige hundert Bytes, nicht 1MB), ist dies völlig sinnvoll. Der try-with-resources-Block stellt sicher, dass der Executor geschlossen wird und alle Tasks beim Beenden des Blocks abgeschlossen sind.
Im Vergleich zum Beispiel in Abschnitt 2 (Pool von 200 Platform Threads) führt dieser Code die gesamten 10.000 Tasks in etwa einer Sekunde aus (da es keine Pool-Begrenzung gibt), anstatt durch mehrere Runden in der Warteschlange zu gehen.
5. Leistungsvergleich durch praktische Tests
class VirtualThreadBenchmarkTest {
@Test
void virtualThreadsShouldHandleManyBlockingTasksFast() throws InterruptedException {
int taskCount = 10_000;
CountDownLatch latch = new CountDownLatch(taskCount);
Instant start = Instant.now();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
}
latch.await();
Duration elapsed = Duration.between(start, Instant.now());
assertThat(elapsed).isLessThan(Duration.ofSeconds(5));
}
}
Dieser Test erstellt 10.000 Tasks, die jeweils 100ms schlafen. Wenn es sequenziell laufen würde, würde es 1000 Sekunden dauern, aber mit Virtual Threads werden alle in wenigen Sekunden abgeschlossen, da die Tasks wirklich parallel laufen. CountDownLatch wird verwendet, um auf alle abgeschlossenen Tasks zu warten, bevor der Assert durchgeführt wird.
Hinweis: Echte Benchmarks sollten zusätzlich CPU und Memory mit JFR (Java Flight Recorder) messen, anstatt nur die Zeit zu betrachten, da die Zeit auch von der Maschine abhängt, auf der der Test läuft.
6. Fallstrick: Pinning bei Verwendung von synchronized
Ein Virtual Thread wird nicht von seinem Carrier Thread unmountet, wenn er sich in einem synchronized-Block befindet — diese Situation wird Pinning genannt. In diesem Fall blockiert das I/O-Blocking innerhalb von synchronized den Carrier Thread direkt, wodurch der Vorteil von Virtual Threads verloren geht.
public class InventoryService {
private final Object lock = new Object();
public void reserveStock(String sku) {
synchronized (lock) {
callExternalInventoryApi(sku); // blocking I/O innerhalb synchronized -> Pinning!
}
}
}
Der synchronized (lock)-Block hält den Carrier Thread während des gesamten callExternalInventoryApi-Aufrufs fest, obwohl dies eine I/O-Operation ist. Wenn Tausende von Virtual Threads gleichzeitig pinnen, wird der Carrier Thread Pool (standardmäßig gleich der Anzahl der CPU-Kerne) schnell erschöpft.
Lösung: Ersetzen Sie synchronized durch java.util.concurrent.locks.ReentrantLock, das gut mit Virtual Threads kompatibel ist (kein Pinning).
public class InventoryService {
private final ReentrantLock lock = new ReentrantLock();
public void reserveStock(String sku) {
lock.lock();
try {
callExternalInventoryApi(sku);
} finally {
lock.unlock();
}
}
}
ReentrantLock ermöglicht es dem Virtual Thread, sich normal von seinem Carrier Thread zu unmounten, wenn er auf blockierendes I/O stößt und behält die “leichte” Natur von Virtual Threads.
7. Virtual Threads in Spring Boot integrieren
Ab Spring Boot 3.2 müssen Sie nur eine Property setzen, damit Spring Web (Tomcat) Virtual Threads für jede Anfrage nutzt.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
spring.threads.virtual.enabled=true
Diese Property aktiviert virtualThreadExecutor für Tomcat (und andere in Spring Boot unterstützte Executors). Jede eingehende HTTP-Anfrage wird auf einem eigenen Virtual Thread verarbeitet, anstatt einen festen Platform Thread Pool zu teilen (Tomcat-Standard: 200 Threads).
@RestController
@RequestMapping("/orders")
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
return orderService.fetchOrderWithDetails(id); // DB + andere APIs aufrufen, blocking wie gewohnt
}
}
Der Controller-Code braucht keine Änderungen — Sie schreiben weiterhin im blockierenden Stil wie gewohnt. Wenn Sie Last-Tests mit Tools wie ab oder k6 durchführen, werden Sie einen deutlichen Durchsatzanstieg sehen, besonders bei Endpoints mit hohem I/O-Latency (Datenbank-Aufrufe, andere Service-Aufrufe), da Tomcat nicht mehr auf 200 Standard-Threads begrenzt ist.
8. Wann man Virtual Threads NICHT verwenden sollte
- CPU-bound Tasks: Wenn die Arbeit hauptsächlich aus Berechnungen besteht (kein I/O-Warten), bringt ein Virtual Thread keinen Vorteil — die Anzahl der Carrier Threads ist immer noch auf die Anzahl der CPU-Kerne begrenzt.
ForkJoinPooloderparallelStream()sind besser geeignet. - Code mit schwerem
ThreadLocal: Virtual Threads sind mitThreadLocalkompatibel, aber wenn Sie Millionen von Virtual Threads erstellen und jeder hält ein großesThreadLocal, führt das zu erheblichem Speicherverbrauch — erwägen Sie stattdessenScopedValue(Preview ab Java 21). - Alte Bibliotheken mit viel
synchronized: Wie in Abschnitt 6 erwähnt, kann Pinning die Leistung stille reduzieren, ohne dass Sie es sofort bemerken. - Begrenzte Connection Pool: Virtual Threads helfen dabei, viele Anfragen parallel zu erstellen, aber wenn Ihr Datenbank-Connection Pool nur 20 Verbindungen hat, werden Sie trotzdem dort gebremst — Sie müssen die Pool-Größe entsprechend erhöhen oder einen Connection Pool mit besserer Unterstützung für hohe Concurrency verwenden.
Hinweis: Virtual Threads lösen das Problem der Anzahl von Threads, nicht die Geschwindigkeit der Verarbeitung jeder Task. Erwarten Sie nicht, dass sie CPU-bound Code schneller machen.
9. Fazit
Virtual Threads ermöglichen es Ihnen, einfachen blockierenden Code zu schreiben, der dennoch Tausende gleichzeitiger Anfragen gut skaliert, besonders geeignet für I/O-intensive Anwendungen wie Webservices und Microservices, die viele APIs/Datenbanken aufrufen. Sie müssen nur den Executor wechseln oder eine Property in Spring Boot aktivieren — keine Umschreibung Ihrer Logik im reaktiven Stil nötig. Beachten Sie jedoch den Fallstrick des Pinning bei synchronized und denken Sie daran, dass Virtual Threads CPU-bound Tasks nicht helfen.