Virtuelle Threads in Java

Virtuelle Threads in Java (Project Loom)

Autor-Bild
von Sven Woltmann – 13. Juni 2022

Virtuelle Threads ("virtual threads") sind eine der wichtigesten Neuerungen in Java seit langem. Sie wurden in Project Loom entwickelt und sind seit Java 19 als Preview-Feature (JEP 425) im JDK enthalten.

In diesem Artikel erfährst du:

  • Warum brauchen wir virtuelle Threads?
  • Was sind virtuelle Threads, und wie funktionieren sie?
  • Wie setzt man virtuelle Threads ein?
  • Wie erzeugt man virtuelle Threads?

Beginnen wir bei der Herausforderung, die zur Entwicklung virtueller Threads geführt hat.

Warum brauchen wir virtuelle Threads?

Wer jemals eine Backend-Anwendung unter hoher Last maintained hat, weiß: Threads sind oft das Bottleneck. Denn für jeden eingehenden Request wird ein Thread benötigt, der den Request abarbeitet. Ein Java-Thread entspricht einem Betriebssystem-Thread, und diese sind resourcenhungrig. Mehr als ein paar Hundert sollte man nicht starten, sonst riskiert man die Stabilität des gesamten Systems.

Ein paar Hundert reichen jedoch nicht immer, insbesondere dann nicht, wenn die Bearbeitung eines Requests länger dauert, weil auf blockierende Datenstrukturen, wie z. B. Queues, Locks, oder externe Dienste wie Datenbanken, Microservices oder Cloud-APIs gewartet werden muss.

Wenn ein Request beispielsweise zwei Sekunden dauert und wir den Thread-Pool auf 100 Threads limitieren, dann könnten maximal 50 Anfragen pro Sekunde beantwortet werden. Die CPU wäre aber bei weitem nicht ausgelastet, da sie die meiste Zeit auf die Antworten der externen Dienste warten würde, selbst wenn pro CPU-Core mehrere Threads bedient werden.

Bislang konnten wir dieses Problem nur mit Reaktiver Programmierung bewältigen, wie sie von Frameworks wie RxJava und Project Reactor bereitgestellt wird.

Wer allerdings schon einmal Code wie den folgenden maintainen musste, weiß, dass reaktiver Code um ein vielfaches komplexer ist als sequentieller Code – und absolut keinen Spaß macht.

public DeferredResult<ResponseEntity<?>> createOrder( CreateOrderRequest createOrderRequest, Long sessionId, HttpServletRequest context) { DeferredResult<ResponseEntity<?>> deferredResult = new DeferredResult<>(); Observable.just(createOrderRequest) .doOnNext(this::validateRequest) .flatMap( request -> sessionService .getSessionContainer(request.getClientId(), sessionId) .toObservable() .map(ResponseEntity::getBody)) .map( sessionContainer -> enrichCreateOrderRequest(createOrderRequest, sessionContainer, context)) .flatMap( enrichedRequest -> orderPersistenceService.persistOrder(enrichedRequest).toObservable()) .subscribeOn(Schedulers.io()) .subscribe( success -> deferredResult.setResult(ResponseEntity.noContent()), error -> deferredResult.setErrorResult(error)); return deferredResult; }
Code-Sprache: Java (java)

Nicht nur, dass dieser Code kaum lesbar ist, er ist auch extrem schwer zu debuggen. Beispielweise würde es hier keinen Sinn machen einen Breakpoint zu setzen, denn der Code definiert nur den reaktiven Flow, führt ihn aber nicht aus. Ausgeführt wird der Code erst nach dem Aufruf von subscribe() (am Ende der Methode) durch die reaktive Library in einem separaten Thread-Pool.

Darüber hinaus müssen die eingesetzten Datenbanktreiber und Treiber für andere externe Dienste das reaktive Modell ebenso unterstützen.

Was sind virtuelle Threads?

Virtuelle Threads lösen das Problem auf eine Art und Weise, die es uns wieder erlaubt, leicht lesbaren und wartbaren Code zu schreiben. Virtuelle Threads fühlen sich aus Sicht des Java-Codes wie ganz normale Threads an, werden aber nicht 1:1 auf Betriebssystem-Threads gemappt.

Stattdessen gibt es einen Pool sogenannter Träger-Threads (Carrier Threads), auf die ein virtueller Thread vorübergehend gemappt wird. Sobald der virtuelle Thread auf eine blockierende Operation stößt, wird der virtuelle Thread vom Träger-Thread genommen, und der Träger-Thread kann einen anderen virtuellen Thread (einen neuen oder einen zuvor blockierten) ausführen:

Mapping von virtuellen Threads auf Platform-Threads
Mapping von virtuellen Threads auf Platform-Threads

Blockierende Operationen blockieren somit den ausführenden Thread nicht mehr. Dadurch können wir mit einem kleinen Pool von Träger-Threads eine Vielzahl von Requests parallel bearbeiten.

Den Beispiel-Use-Case von oben könnte man dann ganz einfach so implementieren:

public void createOrder( CreateOrderRequest createOrderRequest, Long sessionId, HttpServletRequest context) { validateRequest(createOrderRequest); SessionContainer sessionContainer = sessionService .getSessionContainer(createOrderRequest.getClientId(), sessionId) .execute() .getBody(); EnrichedCreateOrderRequest enrichedCreateOrderRequest = enrichCreateOrderRequest(createOrderRequest, sessionContainer, context); orderPersistenceService.persistOrder(enrichedCreateOrderRequest); }
Code-Sprache: Java (java)

Dieser Code ist nicht nur einfacher zu schreiben und zu lesen, sondern auch – wie jeder sequentielle Code – mit herkömmlichen Mitteln zu debuggen.

Falls dein Code bereits so aussieht – du also nie auf reaktive Programmierung umgestellt hast, dann habe ich gute Nachrichten: du kannst deinen Code unverändert mit virtuellen Threads weiterverwenden.

Virtuelle Threads – Beispiel

Die Mächtigkeit virtueller Threads können wir auch ohne Backend-Framework demonstrieren. Dazu simulieren wir ein Szenario, das dem oben beschriebenen ähnelt: wir starten 1.000 Tasks, die jeweils eine Sekunde warten (um den Zugriff auf eine externe API zu simulieren) und dann ein Ergebnis (im Beispiel eine Zufallszahl) zurückliefern.

Als erstes implementieren wir den Task:

public class Task implements Callable<Integer> { private final int number; public Task(int number) { this.number = number; } @Override public Integer call() { System.out.printf( "Thread %s - Task %d waiting...%n", Thread.currentThread().getName(), number); try { Thread.sleep(1000); } catch (InterruptedException e) { System.out.printf( "Thread %s - Task %d canceled.%n", Thread.currentThread().getName(), number); return -1; } System.out.printf( "Thread %s - Task %d finished.%n", Thread.currentThread().getName(), number); return ThreadLocalRandom.current().nextInt(100); } }
Code-Sprache: Java (java)

Nun messen wir, wie lange es mit einem Pool von 100 Plattform-Threads (so werden nicht virtuelle Threads bezeichnet) dauert, alle 1.000 Tasks abzuarbeiten:

ExecutorService executor = Executors.newFixedThreadPool(100); List<Task> tasks = new ArrayList<>(); for (int i = 0; i < 1_000; i++) { tasks.add(new Task(i)); } long time = System.currentTimeMillis(); List<Future<Integer>> futures = executor.invokeAll(tasks); long sum = 0; for (Future<Integer> future : futures) { sum += future.get(); } time = System.currentTimeMillis() - time; System.out.println("sum = " + sum + "; time = " + time + " ms"); executor.shutdown();
Code-Sprache: Java (java)

Das Programm läuft etwas über 10 Sekunden. Das war zu erwarten:

1.000 Tasks geteilt durch 100 Threads = 10 Tasks pro Thread

Jeder Plattform-Thread musste 10 Tasks, die jeweils etwa 1 Sekunde dauerten, sequentiell abarbeiten.

Als nächstes testen wir das ganze mit virtuellen Threads. Dazu müssen wir lediglich die Zeile

ExecutorService executor = Executors.newFixedThreadPool(100);
Code-Sprache: Java (java)

ersetzen durch:

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Code-Sprache: Java (java)

Dieser Executor verwendet keinen Thread-Pool, sondern legt für jeden Task einen neuen virtuellen Thread an.

Danach benötigt das Programm keine 10 Sekunden mehr, sondern nur noch knapp über eine Sekunde. Schneller geht es auch kaum, da ja jeder Task eine Sekunde wartet.

Beeindruckend: selbst 10.000 Tasks kann unser kleines Programm in etwas über einer Sekunde abarbeiten.

Erst bei 100.000 Tasks lässt der Durchsatz spürbar nach: hierfür benötigt mein Laptop etwa vier Sekunden – was aber immer noch rasend schnell ist im Vergleich zum Thread-Pool, der dafür knapp 17 Minuten brauchen würde.

Wie erzeugt man virtuelle Threads?

Eine Möglichkeit zur Erzeugung virtueller Threads haben wir bereits kennengelernt: Ein Executor Service, den wir mit Executors.newVirtualThreadPerTaskExecutor() erzeugen, erstellt pro Task einen neuen virtuellen Thread.

Mittels Thread.startVirtualThread() oder Thread.ofVirtual().start() können wir virtuelle Threads auch explizit starten:

Thread.startVirtualThread(() -> { // code to run in thread }); Thread.ofVirtual().start(() -> { // code to run in thread });
Code-Sprache: Java (java)

Bei der zweiten Variante liefert Thread.ofVirtual() einen VirtualThreadBuilder zurück, dessen start()-Methode einen virtuellen Thread startet. Die alternative Methode Thread.ofPlatform() liefert einen PlatformThreadBuilder zurück, über den ein Plattform-Thread gestartet werden kann.

Beide Builder implementieren das Interface Thread.Builder. Das ermöglicht uns flexiblen Code zu schreiben, bei dem erst zur Laufzeit entschieden wird, ob dieser in einem virtuellen oder in einem Plattform-Thread laufen soll:

Thread.Builder threadBuilder = createThreadBuilder(); threadBuilder.start(() -> { // code to run in thread });
Code-Sprache: Java (java)

Herauszufinden, ob Code in einem virtuellen Thread läuft, kannst du übrigens mit Thread.currentThread().isVirtual().

Fazit

Virtuelle Threads halten, was sie versprechen: Sie ermöglichen es uns lesbaren und wartbaren Code zu schreiben, der Betriebssystem-Threads nicht blockiert, wenn auf Locks, blockierende Datenstrukturen oder Antworten vom Dateisystem oder externen Services gewartet werden muss.

Bis die gängigen Backend-Frameworks (im Beispiel oben war es Spring) virtuelle Threads unterstützen, werden wir uns noch ein wenig gedulden müssen.

Ich hoffe, du bist dennoch ebenso begeistert wie ich und kannst es nicht abwarten virtuelle Threads in deinen Projekten einzusetzen!

Wenn du noch Fragen hast, stelle sie gerne über die Kommentar-Funktion. Möchtest du über neue Tutorials und Artikel informiert werden? Dann klicke hier, um dich für den HappyCoders.eu-Newsletter anzumelden.