virtual threads javavirtual threads java
HappyCoders Glasses

Virtuelle Threads in Java
(Project Loom)

Sven Woltmann
Sven Woltmann
Aktualisiert: 4. April 2024

Virtuelle Threads („virtual threads“) sind eine der wichtigsten Neuerungen in Java seit langem. Sie wurden in Project Loom entwickelt und sind seit Java 19 als Preview-Feature im JDK enthalten und seit Java 21 in ihrer finalen Version (JEP 444).

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, und wie viele virtuelle Threads lassen sich starten?
  • Wie verwendet man virtuelle Threads in Spring und Jakarta EE?
  • Was sind die Vorteile von virtuellen Threads?
  • Was sind virtuelle Threads nicht, und welche Einschränkungen weisen sie auf?

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:

  • Ein Betriebssystem-Thread reserviert 1 MB für den Stack im Vorraus und committet 32 oder 64 KB davon, je nach Betriebssystem.
  • Es dauert etwa 1 ms, um einen Betriebssystem-Thread zu starten.
  • Context-Switches finden im Kernel-Space statt und sind recht CPU-intensiv.

Mehr als ein paar Tausend sollte man also nicht starten, sonst riskiert man die Stabilität des gesamten Systems.

Ein paar Tausend 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 1.000 Threads limitieren, dann könnten maximal 500 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 asynchroner Programmierung bewältigen – z. B. mit CompletableFuture oder reaktiven Frameworks wie RxJava und Project Reactor.

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

public CompletionStage<Response> getProduct(String productId) {
  return productService
      .getProductAsync(productId)
      .thenCompose(
          product -> {
            if (product.isEmpty()) {
              return CompletableFuture.completedFuture(
                  Response.status(Status.NOT_FOUND).build());
            }

            return warehouseService
                .isAvailableAsync(productId)
                .thenCompose(
                    available ->
                        available
                            ? CompletableFuture.completedFuture(0)
                            : supplierService.getDeliveryTimeAsync(
                                product.get().supplier(), productId))
                .thenApply(
                    daysUntilShippable ->
                        Response.ok(
                                new ProductPageResponse(
                                    product.get(), daysUntilShippable))
                            .build());
          });
}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 asynchronen Flow, führt ihn aber nicht aus. Ausgeführt wird der eigentliche Business Code erst zu einem späteren Zeitpunkt in einem dafür vorgesehenen Thread-Pool.

Darüber hinaus müssen die eingesetzten Datenbanktreiber und Treiber für andere externe Dienste das asynchrone, nicht-blockierende 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 (englisch: „mounted”). Sobald der virtuelle Thread auf eine blockierende Operation stößt, wird der virtuelle Thread vom Träger-Thread genommen (english: „unmounted”), und der Träger-Thread kann einen anderen virtuellen Thread (einen neuen oder einen zuvor blockierten) ausführen.

Die folgende Grafik stellt diese M:N-Zuordnung von virtuellen Threads zu Träger-Threads und damit zu Betriebssystem-Threads dar:

Mapping von virtuellen Threads auf Carrier-Threads auf Betriebssystem-Threads
Mapping von virtuellen Threads auf Carrier-Threads auf Betriebssystem-Threads

Beim Träger-Thread-Pool handelt es sich um einen ForkJoinPool – also einen Pool, bei dem jeder Thread seine eigene Queue hat und Tasks von den Queues anderer Threads „stiehlt“, sollte seine eigene Queue leer sein. Seine Größe wird standardmäßig auf Runtime.getRuntime().availableProcessors() gesetzt und kann mit der VM-Option jdk.virtualThreadScheduler.parallelism angepasst werden.

Im zeitlichen Verlauf könnte beispielsweise die CPU-Aktivität von drei Tasks, die jeweils viermal Code ausführen und dazwischen dreimal verhältnismäßig lange blockieren, wie folgt auf einen einzigen Carrier-Thread gemappt werden:

multiple virtual threads mapped to one carrier thread
Mapping von drei virtuellen Threads auf einen Carrier-Thread

Blockierende Operationen blockieren somit den ausführenden Träger-Thread nicht, und wir 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 mit sequentiellem, blockierenden Code wie folgt implementieren:

public ProductPageResponse getProduct(String productId) {
  Product product = productService.getProduct(productId)
      .orElseThrow(NotFoundException::new);

  boolean available = warehouseService.isAvailable(productId);

  int shipsInDays =
     available ? 0 : supplierService.getDeliveryTime(product.supplier(), productId);

  return new ProductPageResponse(product, shipsInDays);
}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 asynchrone 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:

try (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");
}Code-Sprache: Java (java)

ExecutorService ist übrigens seit Java 19 auto-closeable, d. h. er kann durch einen try-with-resources-Block umschlossen sein. Am Ende des Blocks wird ExecutorService.close() aufgerufen, was wiederum shutdown() und awaitTermination() aufruft – und ggf. shutdownNow(), wenn der Thread während awaitTermination() interrupted wird.

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 den Ausdruck

Executors.newFixedThreadPool(100)Code-Sprache: Java (java)

ersetzen durch:

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().

Wie viele virtuelle Threads können gestartet werden?

In diesem GitHub-Repository findest du zahlreiche Demo-Programme, die die Fähigkeiten von virtuellen Threads demonstrieren.

Mit der Klasse HowManyVirtualThreadsDoingSomething kannst du testen, wie viele virtuelle Threads du auf deinem System laufen lassen kannst. Die Anwendung startet mehr und mehr Threads und führt in diesen Threads Thread.sleep()-Operationen in einer Endlosschleife durch, um das Warten auf die Antwort von einer Datenbank oder einer externen API zu simulieren. Versuche dem Programm mit der VM-Option -Xmx so viel Heap-Speicher wie möglich zur Verfügung zu stellen.

Auf meinem 64-GB-Rechner ließen sich problemlos 20.000.000 virtuelle Threads starten – und mit etwas Geduld auch 30.000.000. Ab dann versuchte der Garbage Collector pausenlos Full GCs durchzuführen – denn der Stack von virtuellen Threads wird auf dem Heap, in sogenannten StackChunk-Objekten „geparkt”, sobald ein virtueller Thread blockiert. Kurz darauf beendete sich die Anwendung mit einem OutOfMemoryError.

Mit der Klasse HowManyPlatformThreadsDoingSomething kannst du außerdem testen, wie viele Plattform-Threads dein System unterstützt. Doch sei gewarnt: Meistens endet das Programm irgendwann mit einem OutOfMemoryError (bei mir zwischen 80.000 und 90.000 Threads) – es kann aber auch deinen Rechner zum Absturz bringen.

Wie verwendet man virtuelle Threads mit Jakarta EE?

Die Beispiel-Methode vom Beginn dieses Artikels würde als Jakarta RESTful Webservices Controller – zunächst ohne virtuelle Threads – wie folgt aussehen:

@GET
@Path("/product/{productId}")
public ProductPageResponse getProduct(@PathParam("productId") String productId) {
  Product product = productService.getProduct(productId)
      .orElseThrow(NotFoundException::new);

  boolean available = warehouseService.isAvailable(productId);

  int shipsInDays =
     available ? 0 : supplierService.getDeliveryTime(product.supplier(), productId);

  return new ProductPageResponse(product, shipsInDays);
}Code-Sprache: Java (java)

Um diesen Controller nun auf einem virtuellen Thread laufen zu lassen, müssen wir lediglich eine einzige Zeile – die Annotation @RunOnVirtualThread – hinzufügen:

@GET
@Path("/product/{productId}")
@RunOnVirtualThread
public ProductPageResponse getProduct(@PathParam("productId") String productId) {
  Product product = productService.getProduct(productId)
      .orElseThrow(NotFoundException::new);

  boolean available = warehouseService.isAvailable(productId);

  int shipsInDays =
     available ? 0 : supplierService.getDeliveryTime(product.supplier(), productId);

  return new ProductPageResponse(product, shipsInDays);
}Code-Sprache: Java (java)

Am Methodenkörper mussten wir nicht ein einziges Zeichen ändern.

@RunOnVirtualThread wird in Jakarta EE 11 definiert, das im ersten Quartal 2024 veröffentlicht werden soll.

Wie verwendet man virtuelle Threads mit Quarkus?

Quarkus unterstützt die in Jakarta EE 11 definierte @RunOnVirtualThread-Annotation bereits seit der Version 2.10 – also seit Juni 2022. Mit einer aktuellen Quarkus-Version kannst du also den oben gezeigten Code bereits einsetzen.

In diesem GitHub-Repository findest du eine Beispiel-Quarkus-Anwendung mit dem oben gezeigten Controller – einmal mit Platform-Threads, einmal mit virtuellen Threads und außerdem eine asynchrone Variante mit CompletableFuture. Die README erklärt, wie du die Anwendung startest und wie du die drei Controller aufrufst.

Wie verwendet man virtuelle Threads mit Spring?

In Spring würde der Controller wie folgt aussehen:

@GetMapping("/stage1-seq/product/{productId}")
public ProductPageResponse getProduct(@PathVariable("productId") String productId) {
  Product product =
      productService
          .getProduct(productId)
          .orElseThrow(() -> new ResponseStatusException(NOT_FOUND));

  boolean available = warehouseService.isAvailable(productId);

  int shipsInDays =
      available ? 0 : supplierService.getDeliveryTime(product.supplier(), productId);

  return new ProductPageResponse(product, shipsInDays);
}Code-Sprache: Java (java)

Zur Umstellung auf virtuelle Threads muss man allerdings etwas anders vorgehen. Laut der Spring-Dokumentation müssen folgende zwei Beans definiert werden:

@Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
public AsyncTaskExecutor asyncTaskExecutor() {
  return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}

@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
  return protocolHandler -> {
    protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
  };
}Code-Sprache: Java (java)

Dies führt jedoch dazu, dass alle Controller auf virtuellen Threads laufen, was für die meisten Anwendungsfälle in Ordnung sein mag, nicht jedoch bei CPU-lastigen Aufgaben – diese sollten immer auf Platform-Threads laufen.

In diesem GitHub-Repository findest du eine Beispiel-Spring-Anwendung mit dem oben gezeigten Controller. Die README erklärt, wie du die Anwendung startest und wie du den Controller von Plattform-Threads auf virtuelle Threads umschaltest.

Vorteile von Virtuellen Threads

Virtuelle Threads bieten beeindruckende Vorteile:

Erstens, sie sind günstig:

  • Sie können deutlich schneller erzeugt werden als Plattform-Threads: die Erzeugung eines Plattform-Threads dauert etwa 1 ms, die Erzeugung eines virtuellen Threads weniger als 1 µs.
  • Sie benötigen weniger Speicher: ein Plattform-Thread reserviert 1 MB für den Stack und committed, je nach Betriebssystem, 32 bis 64 KB im Voraus. Ein virtueller Thread beginnt mit etwa einem KB. Das gilt jedoch nur für flache Call-Stacks. Ein Call-Stack von der Größe eines halben Megabytes benötigt dieses halbe Megabyte in beiden Thread-Varianten.
  • Virtuelle Threads zu blockieren ist billig, da ein blockierter virtueller Thread keinen Betriebssystem-Thread blockiert. Umsonst ist es jedoch nicht, da der Stack des virtuellen Threads auf den Heap kopiert werden muss.
  • Context Switches sind schnell, da sie im User Space, nicht im Kernel Space durchgeführt werden und in der JVM zahlreiche Optimierungen vorgenommen wurden, um sie schneller zu machen.

Zweitens, wir können virtuelle Threads auf vertraute Weise einsetzen:

  • An den Thread- und ExecutorService-APIs wurden nur minimale Änderungen vorgenommen.
  • Anstatt asynchronen Code mit Callbacks zu schreiben, können wir Code im traditionellen blockierenden Thread-pro-Request-Stil schreiben.
  • Wir können virtuelle Threads mit existierenden Tools debuggen, beobachten und profilen.

Was sind virtuelle Threads nicht?

Virtuelle Threads haben natürlich nicht nur Vorteile. Schauen wir uns zunächst einmal an, was virtuelle Threads nicht sind, bzw. was wir mit ihnen nicht machen können oder sollten:

  • Virtuelle Threads sind keine schnelleren Threads – sie können nicht mehr CPU-Befehle als ein Plattform-Thread in derselben Zeit ausführen.
  • Sie sind nicht präemptiv: Während ein virtueller Thread eine CPU-intensive Aufgabe ausführt, wird er nicht vom Carrier-Thread genommen. Wenn du also 20 Carriers-Threads und 20 virtuelle Threads hast, die die CPU beanspruchen, ohne zu blockieren, wird kein anderer virtueller Thread ausgeführt.
  • Sie bieten keine höhere Abstraktion als Plattform-Threads. Du musst dir all der subtilen Dinge bewusst sein, die du auch bei der Verwendung regulärer Threads beachten musst. Das heißt, wenn ein virtueller Thread auf gemeinsam genutzte Daten zugreift, musst du dich um Sichtbarkeitsprobleme kümmern, du musst atomare Operationen synchronisieren usw.

Welche Einschränkungen weisen virtuelle Threads auf?

Über folgende Einschränkungen solltest du Bescheid wissen. Einige davon werden in zukünftigen Java-Versionen aufgehoben werden sein:

1. Nicht unterstützte blockierende Operationen

Obwohl die überwiegende Mehrheit der blockierenden Operationen im JDK umgeschrieben wurde, um virtuelle Threads zu unterstützen, gibt es immer noch Operationen, die einen virtuellen Thread nicht vom Carrier-Thread nehmen:

  • File I/O – dies wird in naher Zukunft ebenfalls angepasst werden
  • Object.wait()

In diesen beiden Fällen wird ein blockierter virtueller Thread auch den Träger-Thread blockieren. Um dies zu kompensieren, erhöhen beide Operationen vorübergehend die Anzahl der Träger-Threads – bis zu einem Maximum von 256 Threads, das über die VM-Option jdk.virtualThreadScheduler.maxPoolSize angepasst werden kann.

2. Pinning

Pinning bedeutet, dass eine blockierende Operation, die normalerweise einen virtuellen Thread vom Träger-Thread nehmen würde, dies nicht tut, weil der virtuelle Thread an seinen Träger-Thread „gepinnt” wurde – was bedeutet, dass er den Träger-Thread nicht wechseln darf. Dies passiert in zwei Fällen:

  • innerhalb eines synchronized-Blocks
  • wenn der Call-Stack Aufrufe nativen Codes enthält

Der Grund dafür ist, dass in beiden Fällen Zeiger auf Speicheradressen auf dem Stack existieren können. Wenn der Stack beim Unmounten auf dem Heap geparkt und beim Mounten zurück auf den Stack verschoben wird, könnte er an einer anderen Speicheradresse landen. Und das würde diese Zeiger ungültig machen.

Mit der VM-Option -Djdk.tracePinnedThread=full/short kannst du dir einen vollständigen/gekürzten Stack-Trace ausgeben, wenn ein virtueller Thread blockiert, während er gepinnt ist.

Einen synchronized-Block um blockierende Operation kannst du durch ein ReentrantLock ersetzen.

3. Keine Locks in Thread Dumps

Thread-Dumps enthalten derzeit keine Daten über Locks, die von virtuellen Threads gehalten werden oder durch die sie blockiert werden. Dementsprechend zeigen sie auch keine Deadlocks zwischen virtuellen Threads oder zwischen einem virtuellen und einem Plattform-Thread an.

Thread Dumps mit virtuellen Threads

Die herkömmlichen Thread-Dumps, die per jcmd <pid> Thread.print ausgegeben werden, enthalten übrigens keine virtuellen Threads. Der Grund dafür ist, dass dieses Kommando die VM anhält, um einen Snapshot der laufenden Threads zu erstellen. Dies ist für einige hundert oder sogar einige tausend Threads machbar, aber nicht für Millionen von ihnen.

Daher wurde eine neue Variante von Thread-Dumps implementiert, bei der die VM nicht angehalten wird (entsprechend ist der Thread-Dump ggf. nicht in sich konsistent), die dafür aber virtuelle Threads mit einschließt. Dieser neue Thread-Dump kann mit einem der beiden folgenden Kommandos erzeugt werden:

  • jcmd <pid> Thread.dump_to_file -format=plain <file>
  • jcmd <pid> Thread.dump_to_file -format=json <file>

Das erste Kommando generiert einen Thread-Dump ähnlich des bisherigen, mit Thread-Namen, -IDs und Stack-Traces. Das zweite Kommando generiert eine Datei im JSON-Format, die darüber hinaus Informationen über Thread-Container, Eltern-Container und Eigentümer-Threads enthält.

Wann sollten virtuelle Threads eingesetzt werden?

Virtuelle Threads solltest du einsetzen bei vielen nebenläufig abzuarbeitenden Tasks, die in erster Linie blockierende Operationen enthalten.

Dies gilt für die meisten Serveranwendungen. Wenn deine Serveranwendung allerdings CPU-intensive Aufgaben bearbeitet, solltest du dafür Plattform-Threads verwenden.

Was gilt es sonst noch zu beachten?

Hier ein paar Tipps zum Einsatz von und zum Umstieg auf virtuelle Threads:

  • Virtuelle Threads sind neu, und wir haben noch nicht viel Erfahrung mit ihnen, verglichen mit asynchronen oder reaktiven Frameworks. Du solltest Anwendungen mit virtuellen Threads also intensiv testen, bevor du sie in Produktion einsetzt.
  • Auch wenn viele Artikel über virtuelle Threads uns das glauben machen wollen: sie verwenden nicht grundsätzlich weniger Speicher als ein Plattform-Thread. Dies ist nur dann der Fall, wenn der Call-Stack nicht sehr tief ist. Bei tiefen Call-Stacks verbrauchen beide Arten von Threads die gleiche Menge an Speicher. Auch hier gilt also: intensiv testen!
  • Virtuelle Threads müssen nicht gepoolt werden. Ein Pool wird verwendet, um teure Ressourcen zu teilen. Virtuelle Threads sind hingegen so billig, dass es besser ist, einen zu erstellen, wenn man ihn braucht, und ihn terminieren zu lassen, wenn man ihn nicht mehr braucht.
  • Wenn du den Zugriff auf eine Ressource begrenzen musst, z. B. wie viele Threads gleichzeitig auf eine Datenbank oder eine API zugreifen dürfen, verwende statt einem Thread-Pool einen Semaphor.
  • Ein Großteil des Codes für virtuelle Threads ist in Java geschrieben. Dementsprechend musst du die JVM aufwärmen, bevor du Performance-Tests durchführst, damit der gesamte Bytecode kompiliert und optimiert ist, bevor die Messung beginnt.

Fazit

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

Virtuelle Threads können in der Größenordnung von mehreren Millionen erzeugt werden.

Die gängigen Backend-Frameworks wie Spring und Quarkus können bereits mit virtuellen Threads umgehen. Dennoch solltest du Anwendungen intensiv testen, wenn du sie auf virtuelle Threads umstellst. Beachte, dass du auf ihnen keine CPU-intensiven Rechenaufgaben ausführst, dass sie nicht durch das Framework gepoolt werden und dass in ihnen keine ThreadLocals gespeichert werden (s. auch Scoped Value).

Ich hoffe, du bist 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.