Wer ein Spring-Boot-System mit vielen Scheduled Tasks und asynchroner Verarbeitung betreibt, kennt das Problem: Irgendwann sind die Threads im Pool alle belegt. Health-Checks reagieren nicht, Tasks laufen verzögert, und im Log tauchen RejectedExecutionException auf.
Die erste Reaktion ist immer dieselbe: Pool-Größe erhöhen. Von 8 auf 20, dann auf 50. Das verschiebt das Problem - löst es aber nicht.
Das eigentliche Problem
Klassische Threads sind teuer. Jeder Thread belegt rund 1 MB Stack-Speicher und ist an einen OS-Thread gebunden. Wenn ein Thread auf eine Datenbank-Antwort wartet oder einen HTTP-Call macht, blockiert er - tut aber nichts. Bei 60+ Scheduled Tasks und einem Pool von 8 oder 20 Threads ist der Pool schneller erschöpft, als man denkt.
Virtual Threads: Was sich ändert
Mit Java 21 gibt es Virtual Threads. Die Idee ist einfach: Ein Virtual Thread gibt den darunterliegenden OS-Thread (Carrier Thread) frei, sobald er auf etwas wartet - eine DB-Query, einen HTTP-Response, ein Thread.sleep. Der Carrier Thread kann sofort den nächsten Virtual Thread bedienen. Kein Pool-Sizing mehr nötig.
In Spring Boot ist die Aktivierung denkbar einfach:
spring:
threads:
virtual:
enabled: true
Damit laufen alle eingehenden HTTP-Requests auf Virtual Threads. Tomcats klassischer Thread-Pool verliert seine Bedeutung - er existiert technisch noch, aber die Threads sind jetzt virtuell und das manuelle Sizing entfällt.
Für den Scheduler und Async-Executor braucht es etwas mehr Konfiguration, aber der Kern ist derselbe: Executors.newVirtualThreadPerTaskExecutor() statt ThreadPoolTaskExecutor.
Was in der Praxis gut funktioniert
- Thread-Starvation ist weg. Komplett. Tasks laufen, Health-Checks reagieren, keine Rejection mehr.
- Thread-Pool-Tuning wird einfacher. Keine Diskussionen mehr über core/max/queue-Größen für den Request-Pool. Aber: Connection Pools (DB, HTTP-Clients), Rate Limits und CPU-gebundene Tasks brauchen weiterhin bewusstes Sizing.
- Der Code ändert sich kaum. Die meisten Anpassungen sind Konfiguration, nicht Business-Logik.
Wo die Fallstricke liegen
1. synchronized + blockierendes I/O = Pinning
Das war die größte Überraschung. Wenn ein Virtual Thread innerhalb eines synchronized-Blocks auf I/O wartet (z.B. eine DB-Antwort oder einen HTTP-Response), wird er an den Carrier Thread gepinnt. Der Carrier Thread wird nicht freigegeben - der gesamte Vorteil von Virtual Threads ist damit aufgehoben.
Wichtig: Nicht jedes synchronized ist problematisch. Ein kurzer synchronized-Block, der nur In-Memory-Operationen macht, ist harmlos. Pinning wird erst zum Problem, wenn innerhalb des synchronized-Blocks blockierendes I/O stattfindet.
Die Lösung für betroffene Stellen: synchronized durch ReentrantLock ersetzen. Virtual Threads, die auf lock() warten, geben den Carrier Thread korrekt frei.
// Vorher - verursacht Pinning
private synchronized void doSomething() {
// blockierender Aufruf
}
// Nachher - kein Pinning
private final ReentrantLock lock = new ReentrantLock();
private void doSomething() {
lock.lock();
try {
// blockierender Aufruf
} finally {
lock.unlock();
}
}
In meinem Fall waren es rund 10 Stellen im Code, die synchronized mit blockierendem I/O kombinierten. Nicht viel, aber jede einzelne hätte den Effekt von Virtual Threads untergraben.
Mit -Djdk.tracePinnedThreads=short als JVM-Argument kann man Pinning-Events in den Logs sichtbar machen - sehr empfehlenswert nach der Migration.
2. Connection Pool als neuer Engpass
Virtual Threads können theoretisch unbegrenzt parallel laufen. Das klingt gut, schafft aber ein neues Problem: Wenn 60 Tasks gleichzeitig eine Datenbank-Connection brauchen und der HikariCP-Pool nur 30 hergibt, warten 30 Tasks auf eine Connection bis zum Timeout.
Die Lösung: Ein Concurrency-Limit auf dem Scheduler, das zur Connection-Pool-Größe passt. In meinem Fall: 30 gleichzeitige Tasks bei 30 Connections.
3. Third-Party-Libraries mit synchronized
Nicht jeder Code ist unter eigener Kontrolle. Manche Libraries nutzen intern synchronized - da kann man nichts machen, außer zu prüfen, ob die betroffenen Codepfade überhaupt auf Virtual Threads laufen.
4. Nachtrag: Semaphore statt unbegrenzter Virtual Threads
Einige Wochen nach der Migration kam ein neues Problem: unable to create native thread. Der unbegrenzte Executors.newVirtualThreadPerTaskExecutor() im Async-Executor erzeugte bei Lastspitzen so viele Virtual Threads, dass HikariCP intern - mit synchronized - massenhaft Carrier Threads pinnte. Irgendwann waren die nativen Threads aufgebraucht.
Die Lösung: Ein Semaphore als Concurrency-Limit vor dem Virtual Thread Executor.
// Vorher - unbegrenzt
@Bean
public Executor asyncExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
// Nachher - max 20 gleichzeitig
@Bean
public Executor asyncExecutor() {
Semaphore semaphore = new Semaphore(20);
return task -> Thread.ofVirtual().start(() -> {
semaphore.acquire();
try {
task.run();
} finally {
semaphore.release();
}
});
}
Warum Semaphore und nicht einfach einen ThreadPool mit 20 Threads? Weil Semaphore.acquire() Virtual-Thread-freundlich ist - wartende Virtual Threads werden unmountet und geben den Carrier Thread frei. Ein klassischer ThreadPool würde den Vorteil von Virtual Threads wieder zunichtemachen.
Die Rechnung: 30 Scheduled Tasks + 20 Async Tasks = maximal 50 gleichzeitig. Bei 30 HikariCP-Connections warten im Worst Case einige Tasks auf eine Connection, aber es sind nie mehr als 50 - statt potenziell Hunderte.
Fazit
Die Migration zu Virtual Threads hat bei mir rund einen Tag gedauert. Der größte Aufwand war das Refactoring der synchronized-Blöcke mit blockierendem I/O. Der Effekt ist deutlich: Das System läuft stabiler, Thread-Starvation ist Geschichte, und die Ressourcennutzung ist effizienter.
Die Erfahrung mit dem Semaphore-Fix zeigt aber auch: Virtual Threads verschieben das Tuning, sie eliminieren es nicht. Statt Thread-Pool-Größen konfiguriert man jetzt Concurrency-Limits mit Semaphore oder RateLimiter - das ist ein aufkommendes Standard-Pattern für Virtual Threads. Wer Libraries mit internem synchronized nutzt - und HikariCP gehört dazu - muss die Parallelität bewusst begrenzen. Unbegrenzte Virtual Threads klingen verlockend, können aber genau das Problem erzeugen, das man eigentlich gelöst hat.
Voraussetzungen: Java 21 und Spring Boot 3.2+. Wer das schon hat, sollte es ausprobieren - der Aufwand ist überschaubar, der Gewinn spürbar.
Bonus: Kotlins Coroutines als Alternative
Wer in Kotlin unterwegs ist, kennt das Konzept bereits - nur eleganter. Kotlin Coroutines lösen dasselbe Problem wie Virtual Threads: leichtgewichtige Nebenläufigkeit ohne die Kosten echter OS-Threads. Aber sie gehen anders an die Sache ran.
Der entscheidende Unterschied: Coroutines sind kooperativ. Eine Coroutine gibt den Thread explizit frei, wenn sie suspend aufruft - bei einem Netzwerk-Call, einer DB-Abfrage oder einem delay(). Das Pinning-Problem von Virtual Threads existiert bei Coroutines konzeptionell nicht, weil es keine Carrier Threads gibt.
Aber: Coroutines sind kein Allheilmittel. Wer innerhalb einer Coroutine blockierenden Code aufruft - etwa Thread.sleep(), blockierendes JDBC oder eine synchrone Library - blockiert den darunterliegenden Thread genauso wie in Java. Die Lösung ist Dispatchers.IO, der dafür einen eigenen Thread-Pool bereitstellt. Coroutines erzwingen also eine bewusstere Trennung zwischen suspending und blockierendem Code.
// Kotlin Coroutines - suspend-Funktion, blockiert nicht
suspend fun getPrice(ticker: String): StockPriceResponse {
return stockPriceService.getPrice(ticker) // suspendiert, blockiert nicht
}
// Blockierender Code? Explizit auf Dispatchers.IO auslagern
suspend fun loadFromJdbc(): List<Order> = withContext(Dispatchers.IO) {
orderRepository.findAll() // blockierendes JDBC
}
// Parallelität begrenzen? Eingebaut.
val limiter = Semaphore(20)
suspend fun limitedTask() {
limiter.withPermit {
doWork()
}
}
Was Coroutines anders machen:
- Structured Concurrency:
coroutineScopesorgt dafür, dass alle Kind-Coroutines beendet werden, bevor der Scope schließt. Kein Nachdenken über vergessene Threads. - Eingebaute Cancellation: Coroutines lassen sich sauber abbrechen - bei Virtual Threads muss man das selbst bauen.
- Dispatcher statt Executor:
Dispatchers.IO,Dispatchers.Default- die Parallelität ist von Anfang an kontrolliert. Blockierender Code muss aber explizit auf den richtigen Dispatcher gelegt werden.
Das heißt nicht, dass Coroutines besser sind - es sind unterschiedliche Trade-offs. Virtual Threads sind transparent und funktionieren mit bestehendem blockierendem Java-Code. Coroutines erfordern suspend-Funktionen und damit eine bewusstere API-Gestaltung - machen dafür aber Nebenläufigkeit explizit und verhindern versehentliches Blockieren. Wer ein reines Kotlin-Projekt hat, fährt mit Coroutines komfortabler. Wer ein bestehendes Java-System migriert, ist mit Virtual Threads schneller am Ziel.