Spring Boot mit Virtual Threads: Migration und Benchmarks
Spring Boot mit Virtual Threads: Migration und Benchmarks
Als wir im September letzten Jahres Java 21 vorgestellt haben, war die Vorfreude auf Virtual Threads in Spring Boot greifbar. Mit Spring Boot 3.2 (November 2023) und dem kürzlich erschienenen 3.3 (Mai 2024) ist diese Unterstützung nun Realität -- und nach mehreren Monaten produktivem Einsatz können wir bei encircle360 ein erstes Fazit ziehen.
Das Ergebnis vorweg: Für I/O-lastige Services hat sich der Umstieg gelohnt. Der Weg dorthin war überraschend einfach -- und die Stolpersteine lagen dort, wo wir sie nicht erwartet hatten.
Eine Property, die alles verändert
Die Aktivierung von Virtual Threads in Spring Boot 3.2+ ist bemerkenswert unkompliziert:
# application.properties
spring.threads.virtual.enabled=true
Das war es. Eine Zeile. Aber was passiert unter der Haube?
Sobald diese Property gesetzt ist, ändert Spring Boot mehrere Infrastruktur-Komponenten gleichzeitig:
- Tomcat verwendet für eingehende HTTP-Requests einen Virtual-Thread-per-Request-Executor statt des klassischen Thread-Pools
- Asynchrone Tasks (
@Async) werden auf Virtual Threads ausgeführt - Scheduling (
@Scheduled) nutzt ebenfalls Virtual Threads - Spring MVC Request Handling profitiert automatisch, ohne Code-Änderungen
Der eingebettete Tomcat erzeugt also für jeden eingehenden Request einen neuen Virtual Thread -- keinen Pool, kein Tuning, keine server.tomcat.threads.max-Konfiguration mehr. Die JVM übernimmt das Scheduling auf die darunterliegenden Carrier Threads.
Voraussetzungen: Java 21 und Spring Boot 3.2+
Bevor man die Property setzt, müssen zwei Voraussetzungen erfüllt sein:
- Java 21 als Runtime -- Virtual Threads sind seit Java 21 ein finales Feature, kein Preview mehr
- Spring Boot 3.2 oder neuer -- wer noch auf der 3.0- oder 3.1-Linie ist, muss zuerst upgraden
Wer die Spring-Boot-3-Migration bereits hinter sich hat und auf Java 21 läuft, kann Virtual Threads sofort aktivieren. Für alle anderen empfehlen wir, die Java-21-Migration als separaten Schritt durchzuführen -- nicht gemeinsam mit der Virtual-Thread-Aktivierung.
Benchmark: Platform Threads vs. Virtual Threads
Wir haben einen typischen I/O-lastigen Service als Benchmark herangezogen: Ein REST-Controller, der eine Datenbank-Query und einen externen HTTP-Call ausführt. Zusammen erzeugen diese Aufrufe rund 100 ms Latenz pro Request.
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderRepository orderRepository;
private final RestClient restClient;
public OrderController(OrderRepository orderRepository, RestClient.Builder builder) {
this.orderRepository = orderRepository;
this.restClient = builder.baseUrl("https://inventory.internal").build();
}
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
// ~50 ms Datenbank-Query
Order order = orderRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
// ~50 ms externer HTTP-Call
InventoryStatus status = restClient.get()
.uri("/api/stock/{sku}", order.getSku())
.retrieve()
.body(InventoryStatus.class);
return new OrderResponse(order, status);
}
}
Wir haben diesen Endpoint mit 2.000 gleichzeitigen Verbindungen über 60 Sekunden belastet. Die Ergebnisse:
| Metrik | Platform Threads (200 max) | Virtual Threads |
|---|---|---|
| Durchsatz (req/s) | ~1.850 | ~14.200 |
| p50 Latenz | 108 ms | 105 ms |
| p99 Latenz | 1.240 ms | 142 ms |
| Fehlerrate | 2,3 % | 0,0 % |
Die p50-Latenz ist nahezu identisch -- ein einzelner Request wird nicht schneller. Aber der Durchsatz vervielfacht sich, weil Virtual Threads nicht im Pool aufgebraucht werden. Die p99-Latenz fällt drastisch, weil keine Requests mehr in der Queue auf einen freien Thread warten müssen. Und die Fehlerrate sinkt auf null, weil keine Connection-Timeouts durch Thread-Erschöpfung mehr auftreten.
WebFlux durch blockierenden Code ersetzen
Der vielleicht spannendste Aspekt von Virtual Threads: Sie machen reaktiven Code für viele Anwendungsfälle überflüssig. In unserem Virtual-Threads-Preview-Artikel hatten wir diese Entwicklung bereits angedeutet. Jetzt haben wir sie in der Praxis umgesetzt.
Hier ein typischer WebFlux-Service, wie er vor der Migration aussah:
// Vorher: WebFlux mit Reactor
@RestController
@RequestMapping("/api/users")
public class UserController {
private final WebClient webClient;
private final ReactiveUserRepository userRepository;
@GetMapping("/{id}/profile")
public Mono<UserProfile> getProfile(@PathVariable String id) {
return userRepository.findById(id)
.flatMap(user -> webClient.get()
.uri("/api/avatars/{hash}", user.getAvatarHash())
.retrieve()
.bodyToMono(AvatarInfo.class)
.map(avatar -> new UserProfile(user, avatar))
)
.switchIfEmpty(Mono.error(
new ResponseStatusException(HttpStatus.NOT_FOUND)));
}
}
Und hier die migrierte Version mit Spring MVC und Virtual Threads:
// Nachher: Spring MVC mit Virtual Threads
@RestController
@RequestMapping("/api/users")
public class UserController {
private final RestClient restClient;
private final UserRepository userRepository;
@GetMapping("/{id}/profile")
public UserProfile getProfile(@PathVariable String id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
AvatarInfo avatar = restClient.get()
.uri("/api/avatars/{hash}", user.getAvatarHash())
.retrieve()
.body(AvatarInfo.class);
return new UserProfile(user, avatar);
}
}
Der Unterschied in der Lesbarkeit ist offensichtlich. Kein Mono, kein flatMap, kein switchIfEmpty. Einfacher, sequentieller Code, der blockiert -- aber dank Virtual Threads trotzdem skaliert. Stack Traces sind wieder lesbar, Debugging funktioniert wie gewohnt, und jede JDBC-kompatible Datenbank funktioniert ohne reaktiven Treiber.
Wir haben in den vergangenen Monaten drei Services von WebFlux auf Spring MVC mit Virtual Threads migriert. Die Code-Komplexität sank messbar, die Performance blieb vergleichbar. Einzig bei echtem Streaming -- Server-Sent Events und WebSocket-Feeds -- behalten wir WebFlux bei, weil dort das reaktive Modell weiterhin Vorteile bietet.
Stolpersteine in der Praxis
synchronized und Pinning
Das Pinning-Problem, das wir im Preview-Artikel beschrieben hatten, ist nach wie vor relevant. Wenn ein Virtual Thread innerhalb eines synchronized-Blocks auf I/O wartet, wird er an seinen Carrier Thread gepinnt -- der gesamte Vorteil ist dahin.
Die Lösung bleibt dieselbe: synchronized durch ReentrantLock ersetzen, wo blockierende Operationen stattfinden:
// Problematisch: Pinning bei I/O innerhalb von synchronized
synchronized (lock) {
connection.executeQuery(sql); // Carrier Thread ist blockiert
}
// Korrekt: ReentrantLock erlaubt Unmounting
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
connection.executeQuery(sql); // Carrier Thread wird freigegeben
} finally {
lock.unlock();
}
Die gute Nachricht: Die wichtigsten Libraries haben nachgezogen. HikariCP unterstützt seit Version 5.1 Virtual Threads ohne Pinning, und die gängigen JDBC-Treiber (PostgreSQL, MySQL Connector/J) haben ihre synchronized-Blöcke ebenfalls überarbeitet. Trotzdem empfehlen wir, beim Aktivieren von Virtual Threads die JVM-Option -Djdk.tracePinnedThreads=short zu setzen. So werden Pinning-Ereignisse im Log sichtbar.
ThreadLocal und Speicherverbrauch
ThreadLocal-Variablen funktionieren mit Virtual Threads, aber die Skalierungsannahmen ändern sich. Bei 200 Platform Threads im Pool sind 200 ThreadLocal-Instanzen unkritisch. Bei Hunderttausenden gleichzeitiger Virtual Threads kann der Speicherverbrauch durch ThreadLocals explodieren.
In unseren Services haben wir ThreadLocal-Nutzung systematisch geprüft und wo möglich eliminiert. Für Request-scoped Daten nutzen wir stattdessen Spring's RequestContextHolder, der ohnehin an den Request und nicht an den Thread gebunden ist.
Connection Pools richtig dimensionieren
Ein subtiler Punkt, der uns anfangs Kopfzerbrechen bereitet hat: Virtual Threads erzeugen potenziell Tausende gleichzeitiger Datenbank-Anfragen. Der Connection Pool der Datenbank wird zum neuen Engpass. Wo vorher 200 Tomcat-Threads maximal 200 gleichzeitige Connections brauchten, versuchen jetzt tausende Virtual Threads gleichzeitig eine Connection zu holen.
Die Lösung ist nicht, den Pool zu vergrößern -- die Datenbank hat ihre eigenen Limits. Stattdessen muss der HikariCP-Pool bewusst begrenzt bleiben, und der connectionTimeout sollte großzügig konfiguriert werden, damit Virtual Threads geduldig auf eine freie Connection warten, ohne den Carrier Thread zu blockieren.
Wann Virtual Threads die richtige Wahl sind
Nach mehreren Monaten Erfahrung lässt sich klar sagen:
Virtual Threads lohnen sich, wenn der Service überwiegend I/O-gebunden ist -- Datenbank-Queries, HTTP-Calls an andere Services, Message-Queue-Interaktionen. Das trifft auf die große Mehrheit typischer Microservices zu.
Virtual Threads bringen wenig, wenn der Service CPU-intensive Berechnungen durchführt -- Bildverarbeitung, komplexe Algorithmen, Kryptographie. Hier ist die Anzahl der CPU-Kerne der limitierende Faktor, nicht die Anzahl der Threads.
Virtual Threads ersetzen WebFlux nicht vollständig, wenn echter Streaming-Support benötigt wird -- Server-Sent Events, WebSocket-basierte Echtzeitdaten oder Backpressure-Mechanismen.
Unser Fazit
Virtual Threads in Spring Boot sind kein Hype -- sie sind eine pragmatische Verbesserung, die mit minimalem Aufwand signifikante Performance-Gewinne bringt. Eine Property in der Konfiguration, die richtigen Library-Versionen, und ein Blick auf Pinning und Connection Pools: Mehr braucht es nicht.
Für uns bei encircle360 sind Virtual Threads inzwischen der Standard für neue Spring-Boot-Services. Die Kombination aus Java 21 und Spring Boot 3.3 liefert ein Concurrency-Modell, das den reaktiven Overhead für die meisten Anwendungsfälle überflüssig macht -- bei voller Abwärtskompatibilität mit bestehendem Code. Das ist genau die Art von Innovation, die Java seit Project Loom versprochen hat. Und sie hält, was sie verspricht.
Geschrieben von
Patrick HütterGründer & Software-Architekt
Softwarearchitekt, Ingenieur und Unternehmer. Patrick baut seit über einem Jahrzehnt Produkte und Plattformen — von Enterprise-Backends über Cloud-Native-Infrastruktur bis hin zu KI-gestützten Anwendungen. Als Gründer von encircle360 verbindet er technische Tiefe mit unternehmerischem Denken und treibt Open-Source-Projekte voran, die echten Impact haben.
Das könnte dich auch interessieren
Vom Manifest zur Produktion: Wie ADL, A2A und das Inference Gateway die Agenten-Infrastruktur revolutionieren
05.07.2026 · 12 Min. Lesezeit
Agent-Orchestrierung mit Java: Wie Sie LLM-Agenten produktionsreif in die JVM bringen
04.07.2026 · 6 Min. Lesezeit
Spring AI: Wie Java-Entwickler endlich KI-Features ohne Umwege integrieren
25.03.2026 · 5 Min. Lesezeit