← Zurück

Kapitelkompass

Leichte Threads ersetzen keine Kapazität

Virtual Threads vereinfachen blockierenden Code, aber Datenbank, Socket, CPU und Fremdsystem bleiben begrenzte Ressourcen.

TaskAusgangspunktVirtual ThreadEinordnenBulkheadAbsichernDownstreamErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • I/O und CPU trennen
  • Pinning erkennen
  • Bulkheads dimensionieren
  • Limits messen

Praxisfall

10.000 Tasks starten, aber der Connection Pool besitzt nur 40 Verbindungen.

Entscheidung

Virtual Threads für wartelastige Arbeit nutzen und jeden knappen Downstream separat begrenzen.

!

Typisches Risiko

Unbegrenzte Parallelität erzeugt Warteschlangen, Timeouts und Speicherlast statt Durchsatz.

Schnelle Einordnung
SignalLesartNächster Schritt
Viele wartende TasksI/O-boundVirtual Threads plus Downstream-Bulkhead
CPU dauerhaft gesättigtCPU-boundParallelität am Core-Budget begrenzen
Pinned Threadssynchronized oder native BlockadeJFR-Pinning analysieren und kritischen Abschnitt verkürzen

16. Virtual Threads, Bulkheads und Performance

Thema: Virtual Threads, Bulkheads, Connection-Pool-Grenzen, CPU vs I/O, Rate Limits und Produktions-Performance.

01. Orientierung: Warum Virtual Threads kein Performance-Zauberstab sind

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.

02. Threading-Grundlagen: Platform Thread, Virtual Thread, Carrier Thread

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:

TEXT
Virtual Threads = ich kann alles ohne Grenze parallelisieren

Besseres Mentalmodell:

TEXT
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.

03. I/O-bound vs CPU-bound: Der wichtigste Performance-Schnitt

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.

04. Schlechte Lösung: Alles parallel ohne Grenze starten

Die naive Lösung sieht attraktiv aus: jede Teilinformation wird parallel geladen. In kleinen Tests ist sie schnell. In Produktion kann sie Downstreams überfahren.

JAVA
// 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.

05. Bessere Lösung: Bulkhead + Timeout + Teilantwort

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.

JAVA
// 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.

06. Connection-Pool-Grenzen: Warum 1000 Virtual Threads bei 30 Connections warten

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?

JAVA
// 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?“

07. Timeouts: Technische Grenze mit fachlicher Bedeutung

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.

08. Rate Limiting und Backpressure: Last kontrolliert abweisen

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.

JAVA
// 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.

09. Structured Concurrency als Denkmodell

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.

10. CPU-bound Workloads: Warum Virtual Threads hier nicht helfen

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.

JAVA
// 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.

11. Latenz, Durchsatz und Sättigung gemeinsam lesen

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

12. Produktions-Performance: Incident-Checkliste

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.

13. Code-Walkthrough: OrderViewService mit Grenzen

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.

14. Anti-Patterns und bessere Entscheidungen

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.

15. Lernaufgaben

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“.

⌂ Cockpit