Concurrency Patterns in der JVM-Welt: Ein Nachruf auf den Callback-Wahnsinn
Oder: Wie ich lernte, die Bombe zu lieben und aufhörte, mir Sorgen um Thread-Pools zu machen
Einleitung: Der Zustand der Nation
Willkommen, geschätzte Leidensgenossen der nebenläufigen Programmierung. Wir schreiben das Jahr 2025, und während andere Branchen ihre Probleme mit künstlicher Intelligenz lösen, kämpfen wir immer noch damit, zwei Dinge gleichzeitig zu tun, ohne dass die JVM einen Nervenzusammenbruch erleidet.
Die gute Nachricht: Es war noch nie so einfach, hochskalierbare Anwendungen zu schreiben. Die schlechte Nachricht: Ihr müsst euren gesamten reaktiven Code, den ihr die letzten fünf Jahre geschrieben habt, möglicherweise in die Tonne treten.
Aber der Reihe nach.
Der historische Ballast: Warum wir hier sind
Das Thread-per-Request-Massaker
Früher war alles einfacher. Ein Request kam rein, bekam einen Thread, und dieser Thread blockierte fröhlich vor sich hin, während er auf die Datenbank wartete. Das funktionierte prächtig – solange man nicht mehr als 500 gleichzeitige Benutzer hatte.
Das Problem: Jeder Platform-Thread kostet etwa 1 MB Stack-Speicher. Bei 10.000 gleichzeitigen Verbindungen sprechen wir von 10 GB RAM – nur für Threads, die zu 99% ihrer Zeit auf I/O warten. Das ist, als würde man für jeden Hotelgast einen eigenen Concierge abstellen, der den ganzen Tag nur darauf wartet, dass der Gast klingelt.
Die reaktive Revolution (oder: Der Callback-Albtraum)
Als Antwort darauf entstanden reaktive Frameworks wie Project Reactor und RxJava. Die Idee war brillant: Statt zu blockieren, registriert man Callbacks. Ein Thread kann so tausende Requests bedienen.
Der Preis? Code, der aussieht, als hätte jemand eine Spaghetti-Fabrik in eine Mono/Flux-Wrapper gewickelt:
userRepository.findById(userId)
.flatMap(user -> orderService.getOrders(user.getId()))
.flatMap(orders -> paymentService.processPayments(orders))
.onErrorResume(e -> Mono.empty())
.subscribeOn(Schedulers.boundedElastic())
.publishOn(Schedulers.parallel())
// ... 47 weitere Zeilen später ...
.subscribe();
Debugging? Viel Glück. Die Stacktraces sehen aus wie moderne Kunst. Und wehe, man vergisst ein subscribeOn() – dann läuft plötzlich der I/O-Code auf dem falschen Thread, und die Produktion brennt.
Die aktuellen Concurrency Patterns im Überblick
1. Virtual Threads (Project Loom) – Der neue Messias
Mit Java 21 sind Virtual Threads offiziell in der JVM angekommen. Die Grundidee ist verblüffend einfach: Threads, die von der JVM statt vom Betriebssystem verwaltet werden.
Technische Details:
- Virtual Threads sind extrem leichtgewichtig (wenige KB statt ~1 MB)
- Die JVM kann Millionen davon verwalten
- Bei blockierenden I/O-Operationen wird der Virtual Thread "geparkt" und der Carrier Thread (Platform Thread) für andere Aufgaben freigegeben
- Der Stack wird auf dem Heap gespeichert und nur bei Bedarf geladen
Code-Beispiel:
// So einfach kann das Leben sein
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 1_000_000).forEach(i -> {
executor.submit(() -> {
// Blockierende Operation? Kein Problem!
Thread.sleep(Duration.ofSeconds(1));
return processRequest(i);
});
});
}
Vorteile:
- Schreibt blockierenden Code, der wie synchroner Code aussieht
- Keine Callback-Hölle
- Stacktraces, die man tatsächlich lesen kann
- Minimale Änderungen an bestehendem Code erforderlich
- Keine neue API zu lernen (es ist immer noch
java.lang.Thread)
Nachteile/Einschränkungen:
synchronized-Blöcke können zu "Pinning" führen (der Virtual Thread blockiert den Carrier Thread) – wird allerdings in Java 24 behoben (JEP 491)- ThreadLocals können problematisch werden bei Millionen von Threads
- Native Methoden und Foreign Functions können ebenfalls pinnen
2. Kotlin Coroutines – Der elegante Außenseiter
Kotlin kam der Partei ein paar Jahre zuvor mit Coroutines. Ähnliches Konzept, andere Implementierung.
Technische Details:
- Stackless Coroutines (im Gegensatz zu den stackful Virtual Threads)
- Explizite Suspension Points durch das
suspend-Keyword - Structured Concurrency als First-Class-Citizen
- Verschiedene Dispatcher für verschiedene Workloads
Code-Beispiel:
suspend fun fetchUserData(): User = coroutineScope {
val profile = async { api.getProfile() }
val posts = async { api.getPosts() }
User(profile.await(), posts.await())
}
// Aufruf
runBlocking {
val user = fetchUserData()
}
Die "Zwei-Farben"-Problematik:
Anders als Virtual Threads teilen Coroutines die Kotlin-Welt in zwei Lager: suspend-Funktionen und normale Funktionen. Man kann keine suspend-Funktion aus einer normalen Funktion aufrufen (ohne extra Aufwand). Das ist wie zwei verschiedene Sprachen im selben Code sprechen zu müssen.
Virtual Threads haben dieses Problem nicht – jede Funktion kann implizit suspendieren, ohne dass der Compiler davon wissen muss.
Vorteile:
- Elegante Syntax
- Strukturierte Nebenläufigkeit eingebaut
- Funktioniert auf Kotlin/JS und Kotlin/Native (Multiplatform)
- Reife Bibliotheken und Tooling
- Verschiedene Dispatcher (
Dispatchers.IO,Dispatchers.Default, etc.)
Nachteile:
- Die Zwei-Farben-Welt
- Lernkurve für das Coroutine-Paradigma
- Nicht direkt in Java nutzbar
3. Project Reactor / RxJava – Die alten Recken
Die reaktiven Frameworks sind noch da und werden auch nicht über Nacht verschwinden.
Aktuelle Entwicklung: Project Reactor 3.6.0 hat offiziell Virtual Threads-Unterstützung hinzugefügt. Man kann also beide Welten kombinieren:
Mono.fromCallable(() -> {
// Blockierender Code auf Virtual Threads
return blockingService.call();
}).subscribeOn(Schedulers.loomBoundedElastic());
Wann Reactive noch Sinn macht:
- Streaming-Szenarien (unbegrenzte Datenströme)
- Backpressure-Anforderungen (Kontrolle des Datenflusses)
- Bereits existierende reaktive Codebasis
- Teams mit etablierter Reactive-Expertise
Ist das Reactor Pattern noch zeitgemäß?
Die ehrliche Antwort: Jein.
Die Benchmark-Wahrheit
Aktuelle Benchmarks zeigen ein gemischtes Bild:
| Szenario | Virtual Threads | WebFlux/Reactor |
|---|---|---|
| Einfache I/O-Operationen | Vergleichbar/Besser | Vergleichbar |
| Hohe Concurrency (10k+) | Sehr gut | Sehr gut |
| Extreme Last (1M+ Requests) | Gut | Minimal besser |
| Code-Komplexität | Niedrig | Hoch |
In etwa 45% aller Benchmark-Szenarien liefern Virtual Threads die beste Performance, bei etwa 30% gewinnt Project Reactor. Der Rest ist unentschieden.
Das Fazit zur Zeitgemäßheit
Reaktive Programmierung ist NICHT tot, aber ihre Existenzberechtigung hat sich verschoben:
Reactor/RxJava bleiben relevant für:
- Streaming-Anwendungen (Kafka-Consumer, WebSockets)
- Szenarien mit expliziter Backpressure-Kontrolle
- Systeme, die End-to-End non-blocking sein müssen
- Bestandscode (Migration ist teuer)
Virtual Threads sind besser für:
- Klassische Request/Response-APIs
- Legacy-Code-Migration
- Teams ohne Reactive-Erfahrung
- Neue Projekte ohne spezielle Streaming-Anforderungen
Entscheidungsmatrix: Welches Pattern wann?
Szenario 1: Neue REST-API mit Datenbankzugriff
→ Virtual Threads (Java 21+)
Begründung: Einfach zu schreiben, einfach zu debuggen, performant genug.
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
// Blockiert? Egal, Virtual Thread parkt automatisch
return userRepository.findById(id).orElseThrow();
}
Szenario 2: Kotlin-Projekt (Android oder Backend)
→ Kotlin Coroutines
Begründung: Native Integration, bessere Toolunterstützung, Structured Concurrency.
@GetMapping("/users/{id}")
suspend fun getUser(@PathVariable id: Long): User {
return userRepository.findById(id) ?: throw NotFoundException()
}
Szenario 3: Streaming/Event-Driven-Architektur
→ Project Reactor / WebFlux
Begründung: Backpressure, Flux für unbegrenzte Datenströme.
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Event> streamEvents() {
return eventService.getEventStream()
.onBackpressureBuffer(1000);
}
Szenario 4: Migration einer bestehenden Anwendung
→ Virtual Threads (schonende Migration)
Begründung: Minimale Code-Änderungen, kein Paradigmenwechsel.
// Vorher (Spring Boot mit Tomcat)
spring.threads.virtual.enabled=true // application.properties
// Das war's. Ernsthaft.
Szenario 5: CPU-intensive Berechnungen
→ Platform Threads mit ForkJoinPool
Begründung: Virtual Threads bringen bei CPU-bound Workloads keinen Vorteil.
var result = ForkJoinPool.commonPool().invoke(
new RecursiveTask<>() {
// Parallele Berechnung
}
);
Best Practices für 2025
1. Default: Virtual Threads
Für I/O-bound Anwendungen sind Virtual Threads der neue Standard. Sie sind einfacher, performant und erfordern keine neue Denkweise.
2. Kein Thread-Pooling für Virtual Threads
Virtual Threads sind so leichtgewichtig, dass Pooling kontraproduktiv ist. Ein Thread pro Task ist das gewünschte Pattern.
// FALSCH
ExecutorService pool = Executors.newFixedThreadPool(100);
pool.submit(() -> virtualThreadTask());
// RICHTIG
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> task());
3. ThreadLocals vermeiden
Bei Millionen von Virtual Threads werden ThreadLocals zum Speicherfresser. Nutzt stattdessen Scoped Values (JEP 446, Preview in Java 21+).
4. Synchronized-Blöcke überprüfen
Bis Java 24 können synchronized-Blöcke zu Thread-Pinning führen. Alternatives: ReentrantLock verwenden.
// Potenziell problematisch bis Java 24
synchronized (lock) {
blockingCall();
}
// Besser
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
blockingCall();
} finally {
lock.unlock();
}
5. Structured Concurrency nutzen
Für komplexere Szenarien mit parallelen Aufgaben:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<User> userFuture = scope.fork(() -> fetchUser());
Future<Orders> ordersFuture = scope.fork(() -> fetchOrders());
scope.join();
scope.throwIfFailed();
return new UserWithOrders(userFuture.resultNow(), ordersFuture.resultNow());
}
6. Hybrid-Ansätze in Betracht ziehen
Manchmal macht es Sinn, Reactive und Virtual Threads zu kombinieren:
@GetMapping("/users")
public Mono<String> getUser() {
return Mono.fromCallable(() ->
VirtualThreadExecutor.submit(() -> userService.fetchUser())
).flatMap(Mono::fromFuture);
}
Zusammenfassung: Der pragmatische Leitfaden
| Wenn... | Dann... |
|---|---|
| Neues Java 21+ Projekt | Virtual Threads |
| Kotlin-Projekt | Coroutines |
| Streaming/Events | Reactor/WebFlux |
| Android | Coroutines |
| Legacy-Migration | Virtual Threads |
| CPU-intensive Arbeit | Platform Threads + ForkJoinPool |
| Backpressure erforderlich | Reactor |
| Team ohne Reactive-Erfahrung | Virtual Threads |
| Bestehendes Reactive-System | Weitermachen (nicht kaputtoptimieren) |
Epilog: Die Zukunft
Die JVM-Welt bewegt sich eindeutig in Richtung "einfacher blockierender Code, der magisch skaliert". Virtual Threads sind der Beweis, dass man die Performance-Vorteile asynchroner Programmierung haben kann, ohne dafür mit der Lesbarkeit zu bezahlen.
Reactive Programmierung wird nicht sterben, aber sie wird zu dem, was sie immer hätte sein sollen: ein Werkzeug für spezifische Anwendungsfälle, nicht das Standard-Paradigma für alles, was skalieren soll.
Und Kotlin Coroutines? Die werden weiterhin die Herzen der Kotlin-Entwickler erfreuen, während sie neidisch zusehen, wie Java-Entwickler plötzlich auch elegante Nebenläufigkeit schreiben können.
In diesem Sinne: Mögen eure Threads leicht und eure Stacktraces lesbar sein.
Zuletzt aktualisiert: November 2025 Der Autor übernimmt keine Haftung für Produktionsausfälle, die durch voreilige Migration zu Virtual Threads entstehen. Testet euren Kram.