Das nimmst du mit
- I/O und CPU trennen
- Pinning erkennen
- Bulkheads dimensionieren
- Limits messen
Kapitelkompass
Virtual Threads vereinfachen blockierenden Code, aber Datenbank, Socket, CPU und Fremdsystem bleiben begrenzte Ressourcen.
10.000 Tasks starten, aber der Connection Pool besitzt nur 40 Verbindungen.
Virtual Threads für wartelastige Arbeit nutzen und jeden knappen Downstream separat begrenzen.
Unbegrenzte Parallelität erzeugt Warteschlangen, Timeouts und Speicherlast statt Durchsatz.
| Signal | Lesart | Nächster Schritt |
|---|---|---|
| Viele wartende Tasks | I/O-bound | Virtual Threads plus Downstream-Bulkhead |
| CPU dauerhaft gesättigt | CPU-bound | Parallelität am Core-Budget begrenzen |
| Pinned Threads | synchronized oder native Blockade | JFR-Pinning analysieren und kritischen Abschnitt verkürzen |
Thema: Virtual Threads, Bulkheads, Connection-Pool-Grenzen, CPU vs I/O, Rate Limits und Produktions-Performance.
Virtual Threads in Java 21 verändern, wie man blockierenden Enterprise-Code modelliert. Sie machen blockierendes I/O nicht kostenlos, aber sie entfernen einen großen Teil der alten Threadpool-Kosten. Der wichtigste Deep-Dive-Punkt lautet: Virtual Threads vergrößern keine Datenbank-Connection-Pools, keine HTTP-Rate-Limits und keine CPU-Kerne.
Ein typischer Fehler in Enterprise-Projekten ist die Gleichung: mehr Threads = mehr Durchsatz. Diese Gleichung stimmt fast nie. Mehr Threads helfen nur, wenn die Arbeit überwiegend wartet und die wartende Ressource nicht selbst knapp ist. Ein Order-System kann 1000 parallele Virtual Threads erzeugen, aber wenn nur 30 Datenbankverbindungen vorhanden sind, entsteht der Engpass an anderer Stelle.
Schlechte Entscheidung: jeden eingehenden Request beliebig viele Downstream-Calls starten lassen. Bessere Entscheidung: fachliche Nebenläufigkeit zulassen, aber knappe Ressourcen explizit schützen: Bulkheads, Timeouts, Rate Limits, Queue-Grenzen und messbare SLOs.
Merksatz: Virtual Threads sind ein Modellierungsgewinn für I/O-bound Workloads. Für CPU-bound Workloads braucht man weiterhin begrenzte Parallelität, Profiling und effiziente Algorithmen.
Ein Platform Thread ist ein klassischer JVM-Thread, der typischerweise auf einem Betriebssystem-Thread basiert. Er ist relativ teuer: Stack, Scheduling und Kontextwechsel sind nicht gratis. Ein Virtual Thread ist dagegen eine leichte Ausführungseinheit der JVM. Er kann blockieren, ohne dauerhaft einen Platform Thread zu blockieren. Die JVM parkt den Virtual Thread und verwendet Carrier Threads für tatsächlich laufende Arbeit.
Trotzdem bleiben einige Regeln unverändert. Synchronisierte Blöcke, native Calls, CPU-Schleifen und knappe externe Ressourcen können weiterhin begrenzen. Der Code sieht oft einfacher aus, aber die Architektur muss sauberer werden, nicht beliebiger.
Schlechtes Mentalmodell:
Virtual Threads = ich kann alles ohne Grenze parallelisieren
Besseres Mentalmodell:
Virtual Threads = ich kann fachliche I/O-Arbeit einfach ausdrücken, muss aber knappe Ressourcen explizit begrenzen
Praktische Entscheidung: Für REST-Calls, Dateizugriff, HTTP-Clients und DB-Zugriffe kann Virtual Thread Code lesbarer machen. Für Hashing, Kompression, große JSON-Transformationen oder Report-Berechnung muss CPU-Parallelität limitiert bleiben.
I/O-bound bedeutet: Die Anwendung wartet viel auf Netzwerk, Datenbank, Dateisystem oder externe APIs. CPU-bound bedeutet: Die Anwendung rechnet tatsächlich. Virtual Threads helfen besonders bei I/O-bound Workloads, weil man viele wartende Operationen günstiger modellieren kann.
Ein Order-Enrichment-Use-Case ist oft I/O-bound: Order laden, Payment-Status lesen, Inventory lesen, Customer-Risk lesen. Ein PDF-Report mit Millionen Zeilen ist eher CPU- und Memory-bound. Dort helfen mehr Virtual Threads nicht, sondern können sogar Scheduling-Druck erzeugen.
Checkliste: - Wartet der Code auf externe Systeme? Dann Virtual Threads prüfen. - Rechnet der Code intensiv? Dann CPU-Pool begrenzen. - Ist der Engpass eine Datenbankverbindung? Dann Pool, Query und Index prüfen. - Ist der Engpass ein externer Dienst? Dann Rate Limit und Bulkhead setzen. - Ist der Engpass Speicher? Dann Allocation, Caches und GC prüfen.
Die naive Lösung sieht attraktiv aus: jede Teilinformation wird parallel geladen. In kleinen Tests ist sie schnell. In Produktion kann sie Downstreams überfahren.
// Schlechte Idee: Keine Grenze für Downstream-Calls.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<OrderDto> order = executor.submit(() -> orderClient.load(orderId));
Future<PaymentDto> payment = executor.submit(() -> paymentClient.load(orderId));
Future<InventoryDto> inventory = executor.submit(() -> inventoryClient.load(orderId));
return OrderView.of(order.get(), payment.get(), inventory.get());
}
Warum das gefährlich ist: - get() ohne Timeout kann
Request-Threads lange halten. - Der Payment-Service kann überlastet
werden. - Ein DB-Pool wird nicht größer, nur weil Threads leichter sind.
- Fehlerpfade sind unklar: Teilantwort, Abbruch oder Retry? - Es gibt
keine Metrik, welche Ressource gerade blockiert.
Eine bessere Lösung definiert pro externer Ressource Grenzen und Antwortverhalten.
Ein Bulkhead schützt eine Ressource vor Überlast. Ein Timeout verhindert unendliches Warten. Eine fachliche Teilantwort verhindert, dass ein optionaler Downstream das ganze System blockiert.
Das wichtige Designprinzip: Die Grenze gehört nicht irgendwo in einen technischen Helper, sondern wird pro fachlichem Port begründet. Payment kann kritischer sein als Recommendations. Inventory kann für Checkout hart erforderlich sein, aber für eine Historienansicht optional.
// Pattern: Bulkhead - begrenzt parallele Aufrufe eines knappen Downstreams.
public final class Bulkhead {
private final Semaphore permits;
public Bulkhead(int maxConcurrentCalls) {
this.permits = new Semaphore(maxConcurrentCalls);
}
public <T> T call(Supplier<T> work) {
if (!permits.tryAcquire()) {
throw new BulkheadRejectedException("downstream is saturated");
}
try {
return work.get();
}
finally {
permits.release();
}
}
}
Produktionsregel: Bulkhead-Ablehnungen sind nicht automatisch Fehler. Sie sind ein bewusstes Schutzsignal und müssen als Metrik sichtbar sein.
Datenbanken sind häufig der eigentliche Engpass. Ein JDBC-Pool mit 30 Connections kann nicht 1000 echte SQL-Operationen gleichzeitig ausführen. Virtual Threads machen das Warten günstiger, aber sie lösen keine DB-Kapazität.
Ein gefährliches Muster ist: große Parallelität im Service, kleine Datenbank, keine Timeouts. Das erzeugt Warteschlangen im Connection Pool, lange Request-Latenzen und am Ende Timeouts auf höherer Ebene.
Gute Architektur trennt drei Grenzen: - Request-Concurrency: Wie viele Requests nimmt der Service an? - Downstream-Concurrency: Wie viele parallele DB/HTTP-Aufrufe erlauben wir? - Queue-Time: Wie lange darf Arbeit auf eine knappe Ressource warten?
// Pattern: Resource Pool Simulation - zeigt, dass die Ressource knapper ist als der Thread.
public final class SimulatedConnectionPool {
private final Semaphore connections;
public SimulatedConnectionPool(int size) {
this.connections = new Semaphore(size);
}
public <T> T withConnection(Supplier<T> query) {
if (!connections.tryAcquire()) {
throw new PoolExhaustedException("no database connection available");
}
try {
return query.get();
}
finally {
connections.release();
}
}
}
Deep-Dive-Falle: Wenn man den DB-Pool einfach vergrößert, kann man die Datenbank selbst überlasten. Die richtige Frage ist nicht nur „wie viele Connections“, sondern „wie viele parallele Queries verträgt die DB bei akzeptabler Latenz?“
Ein Timeout ist nicht nur ein technischer Wert. Er sagt aus, wie lange ein Benutzer, Prozess oder Downstream warten darf, bevor eine andere fachliche Entscheidung getroffen wird.
Typische Abstufung: - Checkout-Payment: eher hart, aber mit klarer Fehlermeldung. - Order-Historie mit Recommendation: Recommendation darf fehlen. - Reporting-Refresh: kann asynchron nachholen. - Audit-Schreiben: darf nicht stillschweigend verloren gehen.
Timeouts müssen abgestimmt sein. Wenn der Client nach 1 Sekunde abbricht, der Server aber 10 Sekunden weiterarbeitet, entstehen Geisterlast und unnötige Downstream-Calls.
Produktionshinweise: - Timeouts pro Downstream dokumentieren. - Timeout-Gründe in Metrics trennen. - Cancellation respektieren. - Keine unbounded Retries nach Timeout. - Teilantworten fachlich begründen.
Wenn ein System überlastet ist, muss es nicht jede Arbeit annehmen. Backpressure bedeutet: Das System signalisiert, dass es gerade keine weitere Arbeit aufnehmen kann. Das kann durch HTTP 429, Queue-Limits, Bulkhead-Rejections oder interne Rate Limits passieren.
Schlechte Systeme stauen unbegrenzt. Gute Systeme lehnen früh, messbar und kontrolliert ab. Frühes Ablehnen schützt Benutzer oft besser als minutenlanges Warten.
// Pattern: Rate Limiter - begrenzt Aufrufe pro Zeitfenster.
public final class WindowRateLimiter {
private final int maxPerWindow;
private final long windowMillis;
private long windowStart = System.currentTimeMillis();
private int used;
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
if (now - windowStart >= windowMillis) {
windowStart = now;
used = 0;
}
if (used >= maxPerWindow) return false;
used++;
return true;
}
}
In Enterprise-Systemen sollte ein Rate Limit nie nur zufällig im Code stehen. Es braucht einen Grund: Lizenzlimit, Downstream-SLO, Datenbankkapazität, Schutz vor Retry-Stürmen oder faire Tenant-Nutzung.
Structured Concurrency bedeutet: Parallele Teilaufgaben gehören zu einem gemeinsamen Scope. Wenn der Scope endet, sind auch die Teilaufgaben beendet oder abgebrochen. Dadurch wird Nebenläufigkeit kontrollierbarer.
Auch wenn man nicht jede Preview-API produktiv nutzt, ist das Denkmodell wertvoll: Teilaufgaben sollen eine gemeinsame Lebensdauer haben, Fehler sollen zentral entschieden werden und Timeouts sollen den ganzen Scope betreffen.
Beispielhafte Fachregel: - Order-Daten sind Pflicht. - Payment-Status ist Pflicht für Checkout. - Recommendation ist optional. - Inventory ist für Checkout Pflicht, für Historie optional.
Deep-Dive-Punkt: Nicht jede parallele Aufgabe ist gleich wichtig. Die fachliche Kritikalität bestimmt, ob man abbricht, degradiert oder asynchron nachholt.
CPU-bound Workloads verbrauchen Rechenzeit. Beispiele: Kompression, Verschlüsselung, große JSON-Transformationen, Report-Berechnungen, Sortieren großer Datenmengen. Hier helfen Virtual Threads nicht, weil die CPU-Kerne der Engpass sind.
Schlechter Code startet pro Datensatz einen Virtual Thread und erzeugt Scheduling-Overhead. Besser ist eine begrenzte Parallelität passend zur CPU und zur Speicherlast.
// Für CPU-bound Arbeit bewusst begrenzen.
int cpuParallelism = Math.max(1, Runtime.getRuntime().availableProcessors() - 1);
ExecutorService cpuPool = Executors.newFixedThreadPool(cpuParallelism);
Produktionshinweis: CPU-bound Optimierung beginnt mit Profiling, nicht mit Thread-Anzahl. Erst messen, ob CPU, Allocation, Lock Contention oder I/O wartet.
Performance-Diskussionen scheitern oft, weil nur eine Zahl betrachtet wird. Latenz sagt, wie lange ein einzelner Vorgang dauert. Durchsatz sagt, wie viele Vorgänge pro Zeit verarbeitet werden. Sättigung sagt, wie nah eine Ressource an ihrer Grenze ist.
Ein Service kann hohen Durchsatz haben und trotzdem schlechte P99-Latenz. Ein anderer Service kann niedrige CPU haben, aber trotzdem langsam sein, weil alle Threads auf DB-Connections warten.
Metriken für Runbooks: - P50/P95/P99-Latenz pro Endpoint - DB-Pool active/idle/waiting - Downstream-Timeouts - Bulkhead-Rejections - Queue-Länge - CPU-Auslastung - GC-Pausen - Allocation Rate - Error Budget Burn
Bei einem Performance-Incident sollte man nicht planlos Parameter erhöhen. Eine saubere Diagnose läuft in Schritten.
Checkliste: 1. Welcher SLO ist verletzt: Latenz, Fehlerquote, Durchsatz? 2. Ist die Last höher oder die Kapazität niedriger? 3. Ist CPU hoch oder warten Threads? 4. Ist DB-Pool erschöpft? 5. Gibt es Downstream-Timeouts? 6. Gibt es Retry-Stürme? 7. Gibt es neue Deployments, Datenmengen oder Feature Flags? 8. Sind P99 und Durchschnitt auseinander gelaufen? 9. Ist das Problem tenant-spezifisch? 10. Welche Änderung kann sicher zurückgerollt werden?
Gute Teams dokumentieren nicht nur den Fix, sondern auch die Diagnosekette. Genau daraus entstehen bessere Bulkheads, Timeouts und Kapazitätsmodelle.
Das Code-Lab zeigt bewusst keinen Framework-Code. Es konzentriert
sich auf die Architekturidee. OrderViewService startet
fachlich parallele I/O-Aufgaben, aber jede knappe Ressource wird
geschützt.
Wichtige Klassen: - OrderViewService koordiniert
fachliche Teilaufrufe. - Bulkhead schützt externe Systeme.
- SimulatedConnectionPool zeigt DB-Grenzen. -
WindowRateLimiter begrenzt Downstream-Calls. -
TimeoutPolicy kapselt Wartezeit. -
MetricsRegistry sammelt technische Signale. -
Kapitel prüft Szenarien.
Die Tests prüfen nicht nur „happy path“, sondern auch Ablehnung, Pool-Erschöpfung, Rate-Limit, Timeout-Entscheidung und CPU-vs-I/O-Klassifikation.
Anti-Patterns: - Virtual Threads ohne Downstream-Grenzen. - CPU-bound Arbeit massiv parallel starten. - DB-Pool als Performance-Regler missbrauchen. - Timeouts nur technisch setzen, nicht fachlich erklären. - Retries ohne Budget. - Bulkhead-Rejections als technische Bugs behandeln. - P99-Latenz ignorieren. - Keine Tenant-Grenzen.
Bessere Entscheidungen: - Ressourcen pro Port begrenzen. - Timeouts pro Use Case begründen. - Optionalität fachlich modellieren. - Last früh abweisen, wenn Schutz notwendig ist. - SLOs in Tests und Runbooks übersetzen. - Profiling vor Optimierung. - Virtual Threads für lesbaren I/O-Code nutzen, nicht als Ersatz für Architektur.
Aufgaben: 1. Erhöhe im Code-Lab die parallelen Requests und beobachte
Bulkhead-Rejections. 2. Reduziere den simulierten DB-Pool und erkläre
die Wirkung. 3. Baue einen optionalen Recommendation-Port ein, der bei
Timeout eine Teilantwort liefert. 4. Erweitere
MetricsRegistry um P95-Messung. 5. Ergänze einen Test, der
CPU-bound Arbeit begrenzt. 6. Beschreibe, welche Werte als SLO für
Checkout sinnvoll sind. 7. Formuliere ein Runbook für „P99 steigt, CPU
niedrig, DB-Pool waiting hoch“.