Diesen Artikel (beziehungsweise die ganze Reihe, die daraus geworden ist) habe ich vor ziemlich langer Zeit angefangen. Java 19 war gerade frisch raus, „Virtuelle Threads“ stand als Preview-Feature in den Release Notes, und ich dachte mir „das schau ich mir mal genauer an“. Dann ist, wie das halt so ist, der Entwurf einfach liegen geblieben, unangetastet, ohne Inhalt.
Aktuell baue ich an einer OCR-Software, bei der viele Batches parallel abgearbeitet werden sollen, und bin dabei über den alten, nie fertiggestellten Entwurf gestolpert. Virtuelle Threads sind für genau dieses Problem inzwischen mein Werkzeug der Wahl. Und aus der ursprünglichen Idee ist eine kleine Reihe geworden 🙂
Wie hat sich Nebenläufigkeit in Java historisch entwickelt (dieser Artikel), wo stehen wir mit virtuellen Threads heute (Teil 2), und wohin geht die Reise (Teil 3)?
Bevor es aber richtig in die Gegenwart geht, holen wir hier erstmal die Vorgeschichte nach: Wie sind wir eigentlich zu dem Punkt gekommen, an dem virtuelle Threads überhaupt nötig wurden?
Am Anfang: Green Threads
Ganz am Anfang, in den ersten JDK-Versionen (1.0/1.1), hatte Java auf manchen Plattformen (allen voran Solaris) gar keine echten Betriebssystem-Threads im Einsatz. Sondern sogenannte „grüne Threads“ (Green Threads): vom JVM-Runtime selbst verwaltete, kooperative Threads, die alle auf einem einzigen Betriebssystem-Thread liefen. Der Vorteil: portabel und leichtgewichtig. Der Nachteil: Ein blockierender Systemaufruf in einem Green Thread konnte die komplette JVM lahmlegen, und von echter Parallelität auf Mehrkern-CPUs war da auch nichts zu spüren.
Schon mit Java 1.2/1.3 hat sich das erledigt: Die JVM war auf native Betriebssystem-Threads umgestiegen, ein java.lang.Thread entsprach fortan 1:1 einem OS-Thread. Genau dieses Modell ist bis heute die Grundlage, und genau dieses Modell ist es auch, dessen Grenzen mit virtuellen Threads irgendwann sichtbar wurden (dazu gleich mehr).
synchronized, wait und notify: das raue Fundament
Für die Koordination zwischen Threads gab es von Anfang an genau ein eingebautes Werkzeug: das synchronized-Schlüsselwort, dazu wait(), notify() und notifyAll() auf jedem Object. Funktioniert hat das, aber angenehm war es nie:
class Warteschlange<T> {
private final Queue<T> queue = new LinkedList<>();
public synchronized void put(T element) {
queue.add(element);
notifyAll();
}
public synchronized T take() throws InterruptedException {
while (queue.isEmpty()) {
wait(); // muss zwingend in einer Schleife stehen, wegen spurious wakeups
}
return queue.poll();
}
}
Wer mit wait/notify gearbeitet hat, kennt die Fallstricke. wait() muss in einer while-Schleife stehen, weil ein Thread ohne echten Grund aufwachen kann (spurious wakeup). Vergisst man notifyAll() statt notify(), wachen unter Umständen die falschen Threads auf oder gar keiner. Und Deadlocks, weil zwei Threads sich gegenseitig auf ein synchronized-Objekt des jeweils anderen warten lassen, waren (und sind) ein Klassiker.
java.util.concurrent: Doug Lea räumt auf (Java 5)
Der große Sprung kam 2004 mit Java 5 und dem java.util.concurrent-Paket (JSR 166, maßgeblich von Doug Lea entwickelt). Auf einen Schlag gab es fertige, durchdachte Bausteine, die man vorher händisch und fehleranfällig selbst bauen musste:
- Executors und Thread Pools, statt Threads manuell zu erzeugen und zu verwalten
- Locks (
ReentrantLock) als flexiblere Alternative zusynchronized - Threadsichere Collections wie
ConcurrentHashMapundCopyOnWriteArrayList - Atomare Variablen (
AtomicInteger& Co.) für lock-freie Zähler - Synchronisationshilfen wie
CountDownLatchundCyclicBarrier
Ein simples Beispiel, wie viel angenehmer Nebenläufigkeit damit wurde:
ExecutorService pool = Executors.newFixedThreadPool(4);
for (String url : urls) {
pool.submit(() -> downloadUndVerarbeite(url));
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
Kein manuelles new Thread(...).start() mehr, kein Nachdenken über die richtige Anzahl an Threads für jede einzelne Aufgabe: Der Pool übernimmt das Scheduling.
Fork/Join: Teile und Herrsche (Java 7)
2011, mit Java 7, kam das Fork/Join-Framework dazu (ebenfalls von Doug Lea, JSR 166y). Es war gedacht für Aufgaben, die sich rekursiv in kleinere Teilaufgaben zerlegen lassen und am Ende wieder zusammengeführt werden:
class SummeAufgabe extends RecursiveTask<Long> {
private final long[] zahlen;
private final int von, bis;
SummeAufgabe(long[] zahlen, int von, int bis) {
this.zahlen = zahlen;
this.von = von;
this.bis = bis;
}
@Override
protected Long compute() {
if (bis - von <= 1_000) {
long summe = 0;
for (int i = von; i < bis; i++) summe += zahlen[i];
return summe;
}
int mitte = (von + bis) / 2;
var links = new SummeAufgabe(zahlen, von, mitte).fork();
var rechts = new SummeAufgabe(zahlen, mitte, bis).compute();
return links.join() + rechts;
}
}
Das Besondere daran war der sogenannte Work-Stealing-Scheduler. Damit sind Threads gemeint, die mit ihren eigenen Teilaufgaben fertig sind und sich Arbeit von anderen, noch beschäftigten Threads stehlen, statt untätig zu warten. Bis heute nutzt zum Beispiel Stream.parallel() genau diesen Fork/Join-Pool.
CompletableFuture: Nebenläufigkeit wird deklarativ (Java 8)
Mit Java 8 (2014) kam dann CompletableFuture: der Versuch, asynchrone Abläufe nicht mehr in Callbacks zu verschachteln, sondern als Kette deklarativer Schritte zu beschreiben:
CompletableFuture.supplyAsync(() -> ladeBenutzer(userId))
.thenApply(Benutzer::getBestellungen)
.thenCombine(ladeVersandkostenAsync(), (bestellungen, versandkosten) -> berechneGesamt(bestellungen, versandkosten))
.exceptionally(fehler -> { log.warn("Fehler bei der Bestellabfrage", fehler); return BigDecimal.ZERO; });
Deutlich lesbarer als verschachtelte Callbacks, keine Frage. Aber ehrlich gesagt auch nicht ganz ohne. Wer mal versucht hat, in einer längeren CompletableFuture-Kette vernünftige Stacktraces zu bekommen oder eine Exception aus der Mitte der Kette sauber zu behandeln, weiß, dass „deklarativ“ nicht automatisch „einfach“ bedeutet.
Was blieb: Das Thread-per-Request-Problem
Bei all diesen Verbesserungen blieb ein Problem bestehen, das sich mit keinem der genannten Bausteine wirklich lösen ließ. Ein java.lang.Thread ist und bleibt ein Wrapper um einen echten Betriebssystem-Thread, mit spürbarem Speicher-Overhead. Wer klassisch synchron programmieren und dabei zehntausende gleichzeitige Verbindungen offen halten wollte (das klassische Thread-per-Request-Modell von Webservern), ist damit mehr früher als später an die Grenzen des Betriebssystems gestoßen. Unabhängig davon, wie gut Executors, Fork/Join oder CompletableFuture das Programmiermodell drumherum gemacht haben.
Die einzige Alternative war lange Zeit, komplett auf asynchrone bzw. reaktive Programmierung umzusteigen. Mit allen Vorteilen bei der Skalierbarkeit, aber auch mit dem typischen Preis. Mit Code, der sich schwerer lesen, debuggen und in Stacktraces nachvollziehen lässt.
Fazit
Von Green Threads über synchronized/wait/notify, java.util.concurrent, Fork/Join bis zu CompletableFuture. Jede Generation hat das Programmiermodell verbessert, aber das grundlegende Kostenproblem echter Betriebssystem-Threads nie angetastet. Genau da setzt Project Loom an. In Teil 2 dieser Reihe geht es darum, wie virtuelle Threads dieses jahrzehntealte Problem lösen. Aber ohne dass man dafür das gewohnte, synchrone Programmiermodell aufgeben muss, und warum das für mich sehr praktisch ist.
