Project Loom ist angekommen

Mit Java 21 sind Virtual Threads als stabiles Feature verfügbar. Nach Jahren der Entwicklung unter dem Namen Project Loom hat diese Neuerung das Potenzial, die Art und Weise grundlegend zu verändern, wie wir nebenläufige Java-Anwendungen entwickeln. Für Enterprise-Systeme, die auf Spring Boot, Kafka und relationale Datenbanken setzen, ergeben sich konkrete Konsequenzen.

Virtual Threads vs. Platform Threads

Klassische Java-Threads (Platform Threads) sind 1:1 an OS-Threads gebunden. Das bedeutet: Jeder Thread verbraucht etwa 1 MB Stack-Speicher, und die maximale Anzahl gleichzeitiger Threads liegt typischerweise bei einigen Tausend. Für einen Webserver mit 200 gleichzeitigen Requests ist das ausreichend. Für einen Kafka Consumer, der 50.000 Nachrichten parallel verarbeiten soll, nicht.

Virtual Threads brechen diese Beschränkung auf:

EigenschaftPlatform ThreadsVirtual Threads
Speicherverbrauch~1 MB pro Thread~wenige KB pro Thread
Max. AnzahlTausendeMillionen
SchedulingOS KernelJVM (ForkJoinPool)
Blocking I/OBlockiert OS-ThreadBlockiert nur Virtual Thread
Context SwitchTeuer (Kernel-Space)Günstig (User-Space)

Der entscheidende Unterschied: Wenn ein Virtual Thread auf I/O wartet (Datenbank-Query, HTTP-Call, Kafka Poll), wird der zugrundeliegende Carrier Thread freigegeben und kann einen anderen Virtual Thread ausführen. Das ist transparentes, kooperatives Scheduling durch die JVM.

Auswirkungen auf Spring Boot

Spring Boot 3.2+ unterstützt Virtual Threads nativ. Die Aktivierung ist denkbar einfach:

spring:
  threads:
    virtual:
      enabled: true

Damit werden alle Request-Handler auf Virtual Threads ausgeführt. Tomcat erstellt für jeden eingehenden Request einen neuen Virtual Thread statt eines Platform Threads aus dem Thread Pool.

Was sich ändert

  • Thread Pool Tuning wird irrelevant, Die klassische Frage “Wie viele Threads für den Tomcat Pool?” entfällt. Virtual Threads werden on-demand erstellt, nicht aus einem Pool bezogen
  • Blocking Code wird akzeptabel, Der Hauptgrund für reaktive Frameworks (WebFlux, RxJava) war die Vermeidung blockierender Calls. Mit Virtual Threads ist Thread.sleep() oder ein blockierender JDBC-Call kein Anti-Pattern mehr
  • Thread-Local-Variablen überdenken, Bei Millionen potenzieller Threads wird ThreadLocal zum Speicherproblem. Java 21 bietet ScopedValues als Alternative

Was sich nicht ändert

Virtual Threads lösen keine CPU-bound Probleme. Wenn ein Thread rechnet, statt auf I/O zu warten, bringt ein Virtual Thread keinen Vorteil gegenüber einem Platform Thread. Der Carrier Thread ist in beiden Fällen belegt.

Kafka Consumer mit Virtual Threads

Für Kafka Consumer in unseren Zahlungsverkehrs-Systemen sind Virtual Threads besonders interessant. Ein typisches Szenario: Ein Consumer empfängt eine Nachricht, validiert sie, führt eine Datenbankabfrage durch und sendet das Ergebnis an ein weiteres Topic.

Mit Platform Threads war die Parallelität durch den Thread Pool begrenzt. Mit Virtual Threads kann jede Nachricht in einem eigenen Thread verarbeitet werden:

@KafkaListener(topics = "vop-requests")
public void handleRequest(VopRequest request) {
    // Jeder Aufruf in eigenem Virtual Thread
    var result = validationService.validate(request);  // DB-Call
    var response = matchingService.match(result);       // Externer Service
    kafkaTemplate.send("vop-responses", response);      // Kafka Produce
}

Spring Kafka nutzt Virtual Threads automatisch, wenn sie global aktiviert sind. Die I/O-Wartezeiten bei DB-Queries und externen Aufrufen blockieren nicht mehr den Carrier Thread.

DB Connection Pools, der Flaschenhals

Virtual Threads entfernen die Thread-Limitierung, aber nicht die Connection-Limitierung. Ein HikariCP Pool mit 10 Connections bleibt bei 10 gleichzeitigen Datenbankverbindungen, egal wie viele Virtual Threads darauf zugreifen.

Das kann zum Problem werden: 10.000 Virtual Threads, die gleichzeitig auf den Connection Pool zugreifen, erzeugen massive Contention. Die Virtual Threads warten zwar günstig (kein OS-Thread blockiert), aber die Antwortzeit steigt trotzdem.

Strategien:

  • Connection Pool überwachen, hikaricp_connections_pending als kritische Metrik behandeln
  • Pool-Größe anpassen, Aber nicht blind erhöhen, sondern die Datenbank-Kapazität berücksichtigen
  • Semaphore als Admission Control, Die Anzahl gleichzeitiger DB-Zugriffe begrenzen, bevor der Pool erreicht wird
private final Semaphore dbPermits = new Semaphore(50);

public Result queryWithBackpressure(Query query) {
    dbPermits.acquire();
    try {
        return jdbcTemplate.queryForObject(query);
    } finally {
        dbPermits.release();
    }
}

Structured Concurrency (Preview)

Java 21 bringt Structured Concurrency als Preview-Feature. Das Konzept stellt sicher, dass nebenläufige Aufgaben als Einheit behandelt werden, vergleichbar mit strukturierter Programmierung für Threads.

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<User> user = scope.fork(() -> userService.findById(id));
    Subtask<Account> account = scope.fork(() -> accountService.findByUserId(id));

    scope.join();
    scope.throwIfFailed();

    return new UserProfile(user.get(), account.get());
}

Beide Aufrufe laufen parallel. Wenn einer fehlschlägt, werden alle anderen abgebrochen. Der Scope stellt sicher, dass keine Subtasks “entfliehen”, alle sind an den Lifecycle des umgebenden Blocks gebunden.

Migration von Java 17

Für bestehende Java 17 Anwendungen ist die Migration zu Virtual Threads in den meisten Fällen unkompliziert:

  1. Java 21 als Runtime, Die Anwendung auf Java 21 kompilieren und testen
  2. Spring Boot 3.2+ Update, Die Virtual Thread-Unterstützung ist ab dieser Version vorhanden
  3. synchronized prüfen, Synchronized Blocks pinnen den Carrier Thread. Ersetzen durch ReentrantLock wo möglich
  4. ThreadLocal-Nutzung auditieren, Speicherintensive ThreadLocals identifizieren
  5. Load Tests durchführen, Verhalten unter Last mit Virtual Threads validieren

Der kritischste Punkt ist synchronized: Code, der synchronized verwendet und darin I/O ausführt, verhindert das Unmounting des Virtual Threads vom Carrier Thread. Das reduziert den Vorteil auf null. JDK Flight Recorder zeigt diese Pinning-Events an.

Wann Virtual Threads nicht helfen

  • CPU-intensive Workloads, Bildverarbeitung, Verschlüsselung, Berechnungen. Hier helfen nur mehr CPU-Kerne
  • Sehr kurze Requests, Wenn ein Request nur Mikrosekunden dauert, ist der Overhead des Virtual Thread Scheduling messbar
  • Native Code über JNI, Native Aufrufe pinnen den Carrier Thread ähnlich wie synchronized

Fazit

Virtual Threads sind die bedeutendste Neuerung in Java seit Lambdas. Für I/O-lastige Enterprise-Anwendungen, und das sind die meisten, vereinfachen sie die nebenläufige Programmierung erheblich. Der Wechsel von reaktiven Frameworks zurück zu imperativem, blockierendem Code wird in vielen Fällen die bessere Wahl sein. Die Migration ist für die meisten Spring Boot Anwendungen mit überschaubarem Aufwand machbar, erfordert aber ein bewusstes Verständnis der neuen Grenzen, insbesondere bei Connection Pools und synchronisiertem Code.