1. Kurz das Wichtigste
Sie sind bereits etwas mit den Klassen aus dem Paket java.util.concurrent vertraut, insbesondere mit ExecutorService. Das ist so etwas wie ein „Aufgabenmanager“: Sie schicken ihm Arbeit (z. B. über submit()), und er entscheidet selbst, wann und in welchem Thread sie ausgeführt wird. Unter der Haube läuft normalerweise ein Thread-Pool fester Größe, der Ressourcen spart und nicht für jede Aufgabe einen neuen Thread erstellt.
Mit virtuellen Threads ändert sich das jedoch! Jetzt kann man sich den Luxus leisten: für jede Aufgabe – ein eigener Thread, ohne befürchten zu müssen, dass die JVM überfordert wird.
Neue Methode: Executors.newVirtualThreadPerTaskExecutor()
In Java 21 ist eine neue Möglichkeit hinzugekommen, einen ExecutorService zu erstellen, der jede Aufgabe in einem eigenen virtuellen Thread startet:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Wichtiger Unterschied:
- Alte Thread-Pools (Executors.newFixedThreadPool, Executors.newCachedThreadPool) begrenzten die Anzahl gleichzeitiger Aufgaben wegen der hohen Kosten von Betriebssystem-Threads.
- Der neue virtuelle Executor ist nahezu unbegrenzt: für jede Aufgabe – ein eigener, leichtgewichtiger virtueller Thread.
Ein einfaches Beispiel
Senden wir 10 Aufgaben an den virtuellen Executor:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10; i++) {
int taskId = i; // Variable für die Lambda erfassen
executor.submit(() -> {
System.out.println("Task " + taskId + " is running in thread: " +
Thread.currentThread());
});
}
executor.shutdown();
}
}
Was passiert?
Jede Aufgabe wird in ihrem eigenen virtuellen Thread gestartet, und Sie sehen Zeilen wie:
Task 1 is running in thread: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1
...
2. Massive Parallelität: Tausende Aufgaben – kein Problem!
Um die ganze Stärke virtueller Threads zu spüren, schicken wir in den ExecutorService nicht 10, sondern beispielsweise 100_000 Aufgaben. In klassischen Pools wäre das etwa so, als wollte man einen Elefanten in einen Kühlschrank stopfen: Der JVM würde schnell der Speicher ausgehen oder sie würde extrem ausbremsen. Mit virtuellen Threads ist das anders!
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorMassiveDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 100_000; i++) {
int taskId = i;
executor.submit(() -> {
// Zum Beispiel – wir schlafen einfach 1 ms
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// System.out.println("Task " + taskId + " done."); // Nicht ausgeben, sonst wären es zu viele Zeilen!
});
}
executor.shutdown();
}
}
Achtung: 100_000 Zeilen auf den Bildschirm auszugeben, ist eine schlechte Idee – die Konsole ist schneller überlastet als die virtuellen Threads. Besser gar nichts in die Konsole schreiben oder nur die ersten wenigen Aufgaben ausgeben.
3. Wie newVirtualThreadPerTaskExecutor funktioniert
Kurz: Dieser ExecutorService erstellt für jede übergebene Aufgabe einen neuen virtuellen Thread. Anders als beim festen Pool gibt es hier keine Aufgabenwarteschlange und keine strikten Grenzen für die Anzahl gleichzeitiger Threads (abgesehen von den Limits Ihrer JVM und der Hardware).
Architektonisch:
- Virtuelle Threads werden auf einen kleinen Pool echter Carrier-Threads des Betriebssystems abgebildet.
- Die JVM entscheidet selbst, wann welcher virtuelle Thread gestartet, pausiert und fortgesetzt wird.
- Wenn ein Thread blockiert (z. B. beim Lesen einer Datei oder beim Warten auf das Netzwerk), kann die JVM den virtuellen Thread „einfrieren“ und den Carrier-Thread für andere Aufgaben freigeben.
4. Beispiel: Ergebnisse mit Future verarbeiten
ExecutorService gibt ein Objekt vom Typ Future zurück, wenn eine Aufgabe ein Ergebnis liefert. Alles funktioniert genauso wie bei gewöhnlichen Threads:
import java.util.concurrent.*;
public class VirtualExecutorWithResult {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(500);
return "Hello from virtual thread!";
});
System.out.println("Result: " + future.get()); // Auf das Ergebnis warten
executor.shutdown();
}
}
Alles wie gewohnt: Man kann Aufgaben mit Rückgabewert senden, das Ergebnis über get() abwarten, und Ausnahmen werden standardmäßig behandelt.
5. Executor korrekt beenden
Es ist sehr wichtig, den ExecutorService zu beenden, damit das Programm nicht hängen bleibt (auch wenn die Threads virtuell und nicht „echte“ sind).
shutdown() und awaitTermination
executor.shutdown(); // Sagen: keine neuen Aufgaben mehr annehmen
executor.awaitTermination(1, TimeUnit.MINUTES); // Auf das Ende aller Aufgaben warten (maximal 1 Minute)
Warum ist das wichtig?
Wenn man shutdown() nicht aufruft, können virtuelle Threads weiterleben und das Programm beendet sich nicht einmal nach dem Ende von main(). Das ist ein typischer Anfängerfehler.
6. Nützliche Details
Vergleich: virtueller Executor vs. klassischer Thread-Pool
| Klassischer Pool (newFixedThreadPool) | Virtueller Executor (newVirtualThreadPerTaskExecutor) | |
|---|---|---|
| Anzahl der Threads | Begrenzt durch die Poolgröße | Ein virtueller Thread pro Aufgabe, nahezu unbegrenzt |
| Aufgaben in Warteschlange | Ja, wenn alle Threads ausgelastet sind | In der Regel nein: die Aufgabe erhält sofort einen Thread |
| Kosten eines Threads | Hoch (Stack, Ressourcen des Betriebssystems) | Sehr niedrig (Scheduling durch die JVM) |
| Skalierbarkeit | Begrenzt | Nahezu unbegrenzt |
| Geeignet für | CPU-bound-Aufgaben, begrenzte Parallelität | I/O-bound-Aufgaben, massiver Parallelismus |
Integration mit Webservern
Moderne Webserver (z. B. Tomcat, Jetty, Undertow) beginnen bereits, virtuelle Threads zu unterstützen. Das bedeutet, dass man jede HTTP-Anfrage in einem eigenen virtuellen Thread verarbeiten kann, ohne bei einem Nutzeransturm zu „ersticken“.
Vorteil: Man braucht keine komplexen asynchronen Muster mit Callbacks und CompletableFuture; der Code wird einfacher – man kann gewohnten blockierenden Code schreiben, und die Anwendung skaliert trotzdem.
Massentests und Lastsimulation
Virtuelle Threads eignen sich hervorragend für Tests, in denen Tausende gleichzeitige Nutzer, Anfragen oder Operationen „simuliert“ werden müssen. Zum Beispiel ein Test, der 10_000 parallele Anfragen an einen Server sendet, jede in ihrem eigenen virtuellen Thread.
Parallele Verarbeitung von Dateien und Netzwerkverbindungen
Wenn eine Anwendung mit vielen Dateien oder Netzwerkverbindungen arbeitet, kann jede Verbindung in einem eigenen virtuellen Thread verarbeitet werden, ohne sich um die manuelle Verwaltung von Pools kümmern zu müssen.
7. Typische Fehler im Umgang mit virtuellen Executors
Fehler Nr. 1: shutdown() vergessen. Wenn der Executor nicht geschlossen wird, beendet sich das Programm nicht – virtuelle Threads warten weiterhin auf neue Aufgaben. Fügen Sie bei Bedarf awaitTermination(...) hinzu.
Fehler Nr. 2: Virtuelle Threads für schwere Berechnungen verwenden. Virtuelle Threads beschleunigen Aufgaben nicht, die die CPU vollständig auslasten. Für CPU-bound besser einen festen Pool (Executors.newFixedThreadPool) verwenden und die Größe sorgfältig wählen.
Fehler Nr. 3: Ausnahmen innerhalb von Aufgaben ignorieren. Wenn eine Aufgabe eine Ausnahme wirft, gelangt sie nicht in den Hauptthread – behandeln Sie sie über Future (Methode get()) oder per try/catch innerhalb der Lambda.
Fehler Nr. 4: Alten und neuen Syntax/Version der JDK verwechseln. Prüfen Sie, dass Sie eine passende JDK-Version (Java 21+) verwenden und dass die IDE auf die Unterstützung virtueller Threads eingestellt ist. Die konkrete Methode – Executors.newVirtualThreadPerTaskExecutor().
Fehler Nr. 5: Sich auf ThreadLocal zur Kontextweitergabe verlassen. Virtuelle Threads werden häufig erstellt und wieder verworfen; ThreadLocal kann sich dabei anders verhalten als erwartet. Für die Kontextweitergabe verwenden Sie ScopedValue (Scoped Values; mehr dazu – in der nächsten Vorlesung).
GO TO FULL VERSION