Teil 8) Multithreading in Java
Virtual Threads
Wir haben in dieser Serie bereits viele verschiedene Multithreading-Mechanismen kennengelernt jedoch bleiben immer noch viele Ungewissheiten zurück:
- Habe ich meinen Threadpool optimal konfiguriert?
- Nutze ich meine bestehenden Threads effizient?
- Was ist, wenn ich zu viele erstelle?
- Was ist, wenn ich zu wenig erstelle?
Es wäre doch schön, wenn wir einfach einen Thread für jede Aufgabe erstellen könnten. Einen Thread, der keinen Overhead beim Starten hat. Einen Thread, der niemals wirklich blockiert, sondern sich immer neue Arbeit sucht.
Gute Neuigkeiten!
Mit Java 21 haben es endlich Virtual Threads in die Sprache geschafft. Sie haben einen minimalen Startup-Overhead, man kann abertausende davon erstellen ohne Speicherprobleme zu bekommen und sie arbeiten bei blockierenden IO-Tasks mit voller Auslastung weiter.
Ein Platformthread mit seinen Virtual Threads: KI generierte Grafik von OpenAI, August 2025
Wie funktioniert das Ganze?
Virtual Threads sind im gleichen Sinne keine richtigen Threads, wie Platformthreads keine OS-Threads, und OS-Threads keine unabhängigen Programme auf verschiedenen Kernen sind.
Ein Virtual Thread ist nämlich eigentlich eine Aufgabe, die von einem Platformthread abgearbeitet wird. Diesen nennt man dann “Carrier”. Sollte die Aufgabe blockieren, hört der Carrier auf sie zu bearbeiten (unmount) und nimmt sich die Aufgabe von einem anderen Virtual Thread (mounting).
Die Aufgabe von einem Virtual Thread kann also in ihrem gesamten Lebenszyklus von verschiedenen Platformthreads bearbeitet werden.
Folglich sind sie also auch nicht “schneller” als Platformthreads, da sie selber darauf laufen. Ziel ist es nur, Wartezeiten zu vermindern und damit den Durchsatz an Aufgaben zu erhöhen.
Carrierthreads
Bei der ersten Nutzung von VirtualThreads wird ein ForkJoinPool mit genau so vielen Carrierthreads, wie CPU-Kernen erstellt. Das liegt daran, dass die Carrierthreads ja Blockaden vermeiden, und damit theoretisch immer zu 100% ausgelastet sind.
Sollte man jedoch zu experimentellen Zwecken diese Werte ändern wollen, geht das über die JVM-Properties:
jdk.virtualThreadScheduler.parallelism // Zielwert an CarrierThreads
jdk.virtualThreadScheduler.maxPoolSize // Absolute Obergrenze
jdk.virtualThreadScheduler.minRunnable // Absolute Untergrenze (auch bei Inaktivität)
Siehe hierfür: java.lang.VirtualThread.createDefaultScheduler()
Genau wie beim Beispiel “CommonPool” für parallele Streams gibt es nur einen ForkJoinPool für alle Virtual Threads, welcher spezifisch für das Teilen von blockierenden Aufgaben optimiert ist.
Wie nutze ich Virtual Threads?
Da sie selbst auch von java.lang.Thread erben, funktionieren Virtual Threads auch mit nahezu allen anderen Mechanismen.
Warum manche besser und manche schlechter geeignet sind, kommt jetzt.
Manuell
Einen VirtualThread für “fire and forget tasks” kann man sehr einfach mit Thread.startVirtualThread() erstellen.
Thread virtualThread = Thread.startVirtualThread(() -> {
// Aufgabe ohne Rückgabewert
});
...
virtualThread.join(); // Warte, bis er fertig ist
Man muss sich hier zwar weniger Gedanken um den Lifecycle machen, es verbleibt jedoch etwas Managementaufwand beim Herumgeben des Objekts, den man sich andernfalls auch sparen kann.
ExecutorServices
Wir können ebenfalls einen ExecutorService, nämlich den “VirtualThreadPerTaskExecutor” nutzen. Wie der Name schon andeutet, erstellt dieser für jede übergebene Aufgabe einen eigenen Virtual Thread.
ExecutorService execService = Executors.newVirtualThreadPerTaskExecutor();
// Lade alle Dateien, die zu einem Datensatz gehören, mit Virtual Threads herunter
var futureFileDownloads = new ArrayList<Future<File>>();
for (URI fileLocation : sourceDataset.fileLocations()) {
Future<File> futureFileDownload = execService.submit(() -> downloadFile(fileLocation));
futureFileDownloads.add(futureFileDownload);
}
Vielleicht erinnerst du dich noch an den Artikel, in dem ich dem ThreadPerTaskExecutor teilweise seine Existenzberechtigung abgesprochen habe. (Klicke hier zur Erinnerung)
Tja. Wir haben ihn gerade indirekt aufgerufen. Der VirtualThreadPerTaskExecutor ist nämlich nichts anderes als ein ThreadPerTaskExecutor mit einer ThreadFactory, die VirtualThreads erstellt.
// Implementierung in java.util.concurrent.Executors
public static ExecutorService newVirtualThreadPerTaskExecutor() {
ThreadFactory factory = Thread.ofVirtual().factory();
return newThreadPerTaskExecutor(factory);
}
ThreadPools
Haaalt Stop! Virtual Threads sind nicht dafür da, gepoolt zu werden, da sie bereits auf einem ForkJoinPool laufen. Auch wenn man Threadpools ebenfalls mit einer VirtualThreadFactory erstellen kann, sollte man dies niemals tun.
CompletableFutures
…können beliebige ExecutorServices nutzen, also auch den VirtualThreadPerTaskExecutor. Viel Spaß!
ExecutorService execService = Executors.newVirtualThreadPerTaskExecutor();
var futureFileDownloads = new ArrayList<CompletableFuture<File>>();
for (URI fileLocation : sourceDataset.fileLocations()) {
CompletableFuture<File> futureFileDownload =
CompletableFuture.supplyAsync(() -> downloadFile(fileLocation), execService);
futureFileDownloads.add(futureFileDownload);
}
ForkJoinTasks
Man kann Virtual Threads leider nicht für RecursiveTasks oder RecursiveActions nutzen.
Sie sind zwar Threads, aber keine java.util.concurrent.ForkJoinWorkerThreads.
Wann nutze ich Virtual Threads?
Hauptsächlich für interaktionsintensive Aufgaben (IO): Wenn du viele Netzwerkanfragen sendest, mit Datenbanken interagierst oder Dateien ausliest, nutze Virtual Threads. Wenn du hingegen Bilder komprimierst, Videos renderst, oder komplexe Berechnungen auf der CPU ausführst, nutze Platformthreads (berechnungsintensive Aufgaben).
🤷🏻 Ich bin mir nicht sicher!?
Probier beides aus! Achte vor allem auf den Speicher!
Virtual Threads sollten jedoch ab jetzt immer der Standard sein.
Fazit
Virtual Threads sind neu, wurden super in das bestehende Ökosystem integriert und erleichtern einem die Arbeit mit asynchronen Aufgaben. Im nächsten (und letzten, richtigen) Post dieser Serie, schauen wir uns noch einmal “Structured Concurrency” an.
Als, auf Virtual Threads basierender, Mechanismus zum Aufteilen und Vereinigen von Aufgaben geben sie uns, in ein paar Szenarien, noch etwas mehr Kontrolle als ExecutorServices. Aber das schauen wir uns hier näher an.