Nebenläufigkeit
Software-Muster jenseits von GoF · Gruppe 6 von 7
Sobald mehrere Threads gleichzeitig arbeiten, entstehen neue Arten von Fehlern: verlorene Änderungen, Deadlocks, Threads, die ewig warten. Sie treten nur manchmal auf und sind schwer zu finden. Die Muster dieser Gruppe sind bewährte Wege, Nebenläufigkeit beherrschbar zu machen. Sie stammen großteils aus Pattern-Oriented Software Architecture, Band 2 (2000) und aus Doug Leas Concurrent Programming in Java.
Alle Beispiele nutzen Java 21, wo es passt mit virtuellen Threads. Weil nebenläufige Programme bei jedem Lauf etwas anders ablaufen, geben die Beispiele vor allem Ergebnisse und Zählerstände aus, die immer gleich sind, keine Zwischenstände. Gemessene Zeiten sind gerundet.
- Anwendung / Objekt
- Muster-Baustein
- Speicher / Ressource
- 36. Producer-Consumer
- 37. Thread Pool
- 38. Future / Promise
- 39. Active Object
- 40. Monitor Object
- 41. Read-Write Lock
- 42. Reactor
- 43. Balking / Guarded Suspension
- 44. Immutable Object
36. Producer-Consumer · Erzeuger und Verbraucher
Die Idee
Wer Arbeit erzeugt und wer sie erledigt, wird durch eine Warteschlange getrennt. Erzeuger legen Aufgaben hinein, Verbraucher nehmen sie heraus. Ist die Schlange voll, warten die Erzeuger. Ist sie leer, warten die Verbraucher. So passen sich beide Seiten automatisch aneinander an, ohne voneinander zu wissen. Es ist das Messaging-Prinzip der Enterprise Integration Patterns, nur innerhalb eines Programms.
Im Code
Eine ArrayBlockingQueue mit fünf Plätzen verbindet zwei schnelle Erzeuger mit drei langsameren Verbrauchern, alle als virtuelle Threads. put() blockiert bei voller, take() bei leerer Schlange. Am Ende bekommt jeder Verbraucher eine „Giftpille“, ein besonderes Objekt, das ihm sagt, dass keine Arbeit mehr kommt.
Java-21-Code: ProducerConsumer.java
Direkt ausführbar mit java ProducerConsumer.java
// Producer-Consumer
// Erzeuger und Verbraucher sind über eine Warteschlange entkoppelt und arbeiten in ihrem eigenen Tempo.
// Start: java ProducerConsumer.java
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class ProducerConsumer {
record Bestellung(String quelle, int nr) {}
static final Bestellung ENDE = new Bestellung("ENDE", -1); // "Giftpille": signalisiert das Ende
public static void main(String[] args) throws Exception {
BlockingQueue<Bestellung> warteschlange = new ArrayBlockingQueue<>(5); // begrenzt: höchstens 5
AtomicInteger maxFuellstand = new AtomicInteger();
Map<String, Integer> verarbeitet = new ConcurrentHashMap<>();
int verbraucher = 3;
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
// --- Erzeuger: Shop und App liefern je 10 Bestellungen, schnell ---
for (String quelle : new String[] { "Shop", "App" }) {
ex.submit(() -> {
for (int i = 1; i <= 10; i++) {
warteschlange.put(new Bestellung(quelle, i)); // blockiert, wenn die Schlange voll ist
maxFuellstand.accumulateAndGet(warteschlange.size(), Math::max);
Thread.sleep(5);
}
return null;
});
}
// --- Verbraucher: drei Lager-Worker, langsamer ---
for (int w = 1; w <= verbraucher; w++) {
String name = "Worker " + w;
ex.submit(() -> {
while (true) {
Bestellung b = warteschlange.take(); // blockiert, wenn die Schlange leer ist
if (b == ENDE) return null;
Thread.sleep(30); // Verarbeitung dauert
verarbeitet.merge(b.quelle(), 1, Integer::sum);
}
});
}
// --- Ende signalisieren, sobald alle Erzeuger fertig sind ---
ex.submit(() -> {
while (verarbeitet.values().stream().mapToInt(Integer::intValue).sum() + warteschlange.size() < 20) Thread.sleep(10);
for (int i = 0; i < verbraucher; i++) warteschlange.put(ENDE);
return null;
});
}
System.out.println("Verarbeitet: " + new java.util.TreeMap<>(verarbeitet));
System.out.println("Größter Füllstand: " + Math.min(maxFuellstand.get(), 5) + " (Kapazität 5)");
System.out.println("Erzeuger haben gewartet, wenn die Schlange voll war, statt den Speicher zu fluten.");
}
}Ausgabe
Verarbeitet: {App=10, Shop=10}
Größter Füllstand: 5 (Kapazität 5)
Erzeuger haben gewartet, wenn die Schlange voll war, statt den Speicher zu fluten.Worauf achten
Die Warteschlange sollte begrenzt sein. Eine unbegrenzte Schlange nimmt alles an, bis der Speicher voll ist, wenn die Verbraucher dauerhaft langsamer sind. Die Begrenzung bremst die Erzeuger aus, man spricht von Backpressure. Für das saubere Beenden gibt es zwei übliche Wege: eine Giftpille pro Verbraucher wie im Beispiel oder das Unterbrechen der Verbraucher-Threads. Und wie bei Competing Consumers gilt: Mit mehreren Verbrauchern ist die Reihenfolge der Verarbeitung nicht garantiert.
In der Praxis
BlockingQueue mit ihren Implementierungen ArrayBlockingQueue, LinkedBlockingQueue und SynchronousQueue. Jeder ExecutorService hat intern eine Warteschlange. Die LMAX Disruptor-Bibliothek ist eine extrem schnelle Variante. Über Prozessgrenzen hinweg übernehmen Message Broker diese Rolle.
37. Thread Pool · Thread-Pool
Die Idee
Statt für jede Aufgabe einen neuen Thread zu starten, gibt es eine feste Zahl von Threads, die eine Warteschlange von Aufgaben abarbeiten. Ist ein Thread fertig, nimmt er sich die nächste Aufgabe. Threads werden so wiederverwendet, und ihre Zahl ist nach oben begrenzt. Der Thread Pool ist eine Kombination aus Object Pool (Gruppe 1) und Producer-Consumer (Muster 36).
Im Code
Ein Executors.newFixedThreadPool(4) erledigt zwölf Aufgaben à 100 ms. Es werden genau vier Threads benutzt, und es dauert drei Runden, also etwa 300 ms. Im zweiten Teil laufen 10.000 wartende Aufgaben mit virtuellen Threads, je einem pro Aufgabe, in deutlich unter einer Sekunde.
Java-21-Code: ThreadPool.java
Direkt ausführbar mit java ThreadPool.java
// Thread Pool
// Eine feste Anzahl von Threads arbeitet eine Warteschlange von Aufgaben ab.
// Start: java ThreadPool.java
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ThreadPool {
static long ms(long start) { return Math.round((System.nanoTime() - start) / 1e8) * 100; }
public static void main(String[] args) throws Exception {
// --- 1. Klassischer Thread Pool: 4 Plattform-Threads für 12 Aufgaben ---
Set<String> benutzteThreads = ConcurrentHashMap.newKeySet();
long start = System.nanoTime();
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int i = 1; i <= 12; i++) {
pool.submit(() -> {
benutzteThreads.add(Thread.currentThread().getName());
Thread.sleep(100); // z. B. Rechnung als PDF erzeugen
return null;
});
}
pool.shutdown(); // keine neuen Aufgaben mehr annehmen
pool.awaitTermination(10, TimeUnit.SECONDS); // auf die laufenden warten
System.out.println("Fester Pool: 12 Aufgaben à 100 ms mit 4 Threads");
System.out.println(" verschiedene Threads benutzt: " + benutzteThreads.size());
System.out.println(" Dauer ca. " + ms(start) + " ms (3 Runden à 4 Aufgaben)");
// --- 2. Java 21: virtuelle Threads, ein Thread pro Aufgabe, kein Pool nötig ---
start = System.nanoTime();
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
ex.submit(() -> { Thread.sleep(100); return null; }); // wartende Aufgaben, z. B. auf eine Antwort
}
}
System.out.println("\nVirtuelle Threads: 10.000 wartende Aufgaben à 100 ms");
System.out.println(" Dauer deutlich unter 1 Sekunde: " + (ms(start) < 1_000));
}
}Ausgabe
Fester Pool: 12 Aufgaben à 100 ms mit 4 Threads
verschiedene Threads benutzt: 4
Dauer ca. 300 ms (3 Runden à 4 Aufgaben)
Virtuelle Threads: 10.000 wartende Aufgaben à 100 ms
Dauer deutlich unter 1 Sekunde: trueWorauf achten
Die richtige Poolgröße hängt von der Art der Arbeit ab: Für rechenintensive Aufgaben etwa so viele Threads wie Prozessorkerne, mehr bringen nichts. Für Aufgaben, die überwiegend warten (Datenbank, Netzwerk, Dateien), waren früher große Pools nötig. Seit Java 21 sind dafür virtuelle Threads die bessere Wahl: Sie sind so billig, dass man sie nicht mehr poolen muss, Millionen gleichzeitig sind möglich. Plattform-Thread-Pools bleiben sinnvoll für rechenintensive Arbeit und um die Last auf eine Ressource bewusst zu begrenzen. Pools müssen immer mit shutdown() beendet werden, sonst läuft das Programm nicht zu Ende. Die try-with-resources-Form von ExecutorService nimmt einem das seit Java 19 ab.
In der Praxis
ExecutorService mit newFixedThreadPool, newCachedThreadPool, ForkJoinPool (für Streams mit parallel()), Thread-Pools in Webservern wie Tomcat, die seit Spring Boot 3.2 per Einstellung durch virtuelle Threads ersetzt werden können.
38. Future / Promise · Zukünftiges Ergebnis
Die Idee
Ein Future ist ein Platzhalter für ein Ergebnis, das es noch nicht gibt. Wer eine langwierige Aufgabe startet, bekommt sofort ein Future zurück und kann weiterarbeiten. Später kann er auf das Ergebnis warten, oder er hängt gleich Folgeschritte an, die automatisch laufen, sobald das Ergebnis da ist. „Promise“ bezeichnet die Seite, die das Ergebnis später einlöst. In Java vereint CompletableFuture beides.
Im Code
Eine Produktseite braucht Preis, Bestand und Versandkosten von drei langsamen Diensten. Nacheinander abgefragt dauert das etwa 900 ms, als drei Futures parallel etwa 300 ms. thenCombine setzt die Ergebnisse zusammen, thenApply verarbeitet weiter, exceptionally liefert bei einem Fehler einen Ersatzwert, und completeOnTimeout begrenzt die Wartezeit.
Java-21-Code: FuturePromise.java
Direkt ausführbar mit java FuturePromise.java
// Future / Promise
// Ein Platzhalter für ein Ergebnis, das erst später vorliegt. Man kann darauf warten oder Folgeschritte anhängen.
// Start: java FuturePromise.java
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class FuturePromise {
static void warte(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { throw new RuntimeException(e); } }
static long ms(long start) { return Math.round((System.nanoTime() - start) / 1e8) * 100; }
// Drei langsame Dienste, z. B. entfernte Aufrufe
static long preisCent(String artikel) { warte(300); return 3_990; }
static int bestand(String artikel) { warte(300); return 7; }
static long versandCent(String plz) { warte(300); return 490; }
static long empfehlungen() { warte(200); throw new IllegalStateException("Empfehlungsdienst nicht erreichbar"); }
record Produktseite(long preis, int bestand, long versand) {}
public static void main(String[] args) {
var ex = Executors.newVirtualThreadPerTaskExecutor();
// --- Nacheinander: die Zeiten addieren sich ---
long start = System.nanoTime();
var seq = new Produktseite(preisCent("Lampe"), bestand("Lampe"), versandCent("20457"));
System.out.println("Nacheinander: " + seq + ", Dauer ca. " + ms(start) + " ms");
// --- Parallel mit Futures: die Zeiten überlappen sich ---
start = System.nanoTime();
CompletableFuture<Long> preis = CompletableFuture.supplyAsync(() -> preisCent("Lampe"), ex);
CompletableFuture<Integer> lager = CompletableFuture.supplyAsync(() -> bestand("Lampe"), ex);
CompletableFuture<Long> versand = CompletableFuture.supplyAsync(() -> versandCent("20457"), ex);
// Hier könnte das Programm etwas anderes tun, während die drei Ergebnisse unterwegs sind
// Folgeschritt, der erst läuft, wenn alle drei fertig sind
CompletableFuture<Produktseite> seite = preis.thenCombine(lager, (p, b) -> new long[] { p, b })
.thenCombine(versand, (pb, v) -> new Produktseite(pb[0], (int) pb[1], v));
System.out.println("Parallel: " + seite.join() + ", Dauer ca. " + ms(start) + " ms");
// --- Folgeschritte anhängen und Fehler abfangen ---
String text = seite.thenApply(s -> String.format(Locale.GERMANY, "%.2f € + %.2f € Versand", s.preis() / 100.0, s.versand() / 100.0)).join();
System.out.println("Weiterverarbeitet: " + text);
String empf = CompletableFuture.supplyAsync(FuturePromise::empfehlungen, ex)
.thenApply(n -> n + " Empfehlungen")
.exceptionally(fehler -> "keine Empfehlungen (" + fehler.getCause().getMessage() + ")")
.join();
System.out.println("Mit Fehler: " + empf);
// --- Zeitlimit ---
String mitLimit = CompletableFuture.supplyAsync(() -> { warte(2_000); return "zu spät"; }, ex)
.completeOnTimeout("Standardwert nach 500 ms", 500, TimeUnit.MILLISECONDS)
.join();
System.out.println("Mit Zeitlimit: " + mitLimit);
ex.shutdownNow();
}
}Ausgabe
Nacheinander: Produktseite[preis=3990, bestand=7, versand=490], Dauer ca. 900 ms
Parallel: Produktseite[preis=3990, bestand=7, versand=490], Dauer ca. 300 ms
Weiterverarbeitet: 39,90 € + 4,90 € Versand
Mit Fehler: keine Empfehlungen (Empfehlungsdienst nicht erreichbar)
Mit Zeitlimit: Standardwert nach 500 msWorauf achten
Ein blockierendes get() oder join() zu früh im Code macht den Vorteil zunichte. Fehler in Futures werden leicht übersehen, weil sie erst beim Abholen des Ergebnisses auftauchen. Jede Kette braucht deshalb eine Fehlerbehandlung. Mit virtuellen Threads in Java 21 wird blockierender Code wieder billig, und viele asynchrone Ketten lassen sich durch einfachen, linearen Code ersetzen. Für strukturierte Parallelität gibt es Structured Concurrency, seit Java 21 als Preview-Funktion.
In der Praxis
java.util.concurrent.Future und CompletableFuture, Promises in JavaScript mit async/await, Mono in Project Reactor, Task in C#. Spring liefert aus @Async-Methoden CompletableFuture zurück.
39. Active Object · Aktives Objekt
Die Idee
Ein Active Object hat einen eigenen Thread. Ruft jemand eine seiner Methoden auf, wird die Arbeit nicht sofort im Thread des Aufrufers erledigt, sondern als Auftrag in eine Warteschlange gestellt. Der Aufrufer bekommt sofort ein Future zurück. Der Thread des Objekts arbeitet die Aufträge nacheinander ab. Weil nur dieser eine Thread den Zustand des Objekts berührt, sind keine Sperren nötig.
Im Code
Das Lager hat einen SingleThreadExecutor als eigenen Thread und Warteschlange. Seine öffentlichen Methoden verpacken den Auftrag und reihen ihn ein. 1.000 virtuelle Threads entnehmen gleichzeitig Bücher und liefern Tassen. Am Ende stimmt der Bestand exakt, obwohl die interne HashMap nicht threadsicher ist und kein einziges synchronized im Code steht.
Java-21-Code: ActiveObject.java
Direkt ausführbar mit java ActiveObject.java
// Active Object
// Methodenaufrufe werden nicht sofort ausgeführt, sondern in eine Warteschlange gestellt
// und von einem eigenen Thread des Objekts nacheinander abgearbeitet.
// Start: java ActiveObject.java
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
public class ActiveObject {
// --- Das Active Object: eigener Thread, eigener Zustand, keine Sperren nötig ---
static final class Lager implements AutoCloseable {
private final ExecutorService eigenerThread = Executors.newSingleThreadExecutor(); // Scheduler + Queue
private final Map<String, Integer> bestand = new HashMap<>(); // nur dieser Thread greift zu
private int auftraege = 0;
// Öffentliche Methoden: nehmen den Auftrag an und kehren sofort zurück (Proxy)
CompletableFuture<Void> liefere(String artikel, int menge) {
return einreihen(() -> { bestand.merge(artikel, menge, Integer::sum); auftraege++; return null; });
}
CompletableFuture<Boolean> entnehme(String artikel, int menge) {
return einreihen(() -> {
auftraege++;
int da = bestand.getOrDefault(artikel, 0);
if (da < menge) return false;
bestand.put(artikel, da - menge);
return true;
});
}
CompletableFuture<String> bericht() {
return einreihen(() -> "Bestand " + new java.util.TreeMap<>(bestand) + " nach " + auftraege + " Aufträgen");
}
private <T> CompletableFuture<T> einreihen(Supplier<T> auftrag) {
return CompletableFuture.supplyAsync(auftrag, eigenerThread); // Method Request in die Warteschlange
}
public void close() { eigenerThread.shutdown(); }
}
public static void main(String[] args) {
try (Lager lager = new Lager();
var aufrufer = Executors.newVirtualThreadPerTaskExecutor()) {
lager.liefere("Buch", 1_000).join();
// 1.000 Aufrufer gleichzeitig, jeder entnimmt 1 Buch und liefert 1 Tasse
var erfolge = new java.util.concurrent.atomic.AtomicInteger();
var futures = new java.util.ArrayList<CompletableFuture<?>>();
for (int i = 0; i < 1_000; i++) {
futures.add(CompletableFuture.runAsync(() -> {
if (lager.entnehme("Buch", 1).join()) erfolge.incrementAndGet();
lager.liefere("Tasse", 1).join();
}, aufrufer));
}
futures.forEach(CompletableFuture::join);
System.out.println("Erfolgreiche Entnahmen: " + erfolge.get());
System.out.println(lager.bericht().join());
System.out.println("Keine einzige Sperre im Code: der Zustand gehört genau einem Thread.");
}
}
}Ausgabe
Erfolgreiche Entnahmen: 1000
Bestand {Buch=0, Tasse=1000} nach 2001 Aufträgen
Keine einzige Sperre im Code: der Zustand gehört genau einem Thread.Worauf achten
Das Muster tauscht Sperren gegen Serialisierung: Alle Aufträge laufen nacheinander. Bei sehr vielen Aufrufen wird der eine Thread zum Engpass. Dauert ein Auftrag lange, warten alle anderen. Und ruft das Active Object aus einem Auftrag heraus blockierend sich selbst auf, wartet es auf sich selbst, ein Deadlock. Das GoF-nahe Original von Douglas Schmidt (POSA 2) unterscheidet die Rollen Proxy, Method Request, Activation List, Scheduler, Servant und Future. Im Beispiel übernimmt der ExecutorService mehrere davon.
In der Praxis
Der Event-Dispatch-Thread in Swing und der Application Thread in JavaFX (Änderungen an der Oberfläche nur über invokeLater bzw. Platform.runLater), Aktoren in Akka bzw. Pekko, Erlang-Prozesse und die Verticles in Vert.x arbeiten alle nach diesem Prinzip.
40. Monitor Object · Monitorobjekt
Die Idee
Ein Monitor Object schützt seinen Zustand selbst: Alle seine Methoden sind so gesichert, dass immer nur ein Thread gleichzeitig darin arbeiten kann. Andere Threads warten, bis er fertig ist. Der Aufrufer muss sich um nichts kümmern. Zusätzlich kann ein Monitor Bedingungen bieten, auf die Threads warten können, siehe Guarded Suspension (Muster 43). Das Konzept stammt von Tony Hoare und Per Brinch Hansen aus den 1970ern.
Im Code
Ein ungeschützter Zähler, von acht Threads je 200.000-mal erhöht, verliert Änderungen, weil wert++ aus drei Schritten besteht: lesen, erhöhen, schreiben. Mit synchronized stimmt das Ergebnis. Das Konto nutzt eine ReentrantLock und prüft und ändert den Saldo als eine geschützte Einheit. 5.000 gleichzeitige Abbuchungen von 1 € auf ein Konto mit 1.000 € ergeben genau 1.000 erfolgreiche, und der Saldo wird nie negativ.
Java-21-Code: MonitorObject.java
Direkt ausführbar mit java MonitorObject.java
// Monitor Object
// Ein Objekt schützt seinen Zustand selbst: Es lässt immer nur einen Thread gleichzeitig in seine Methoden.
// Start: java MonitorObject.java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class MonitorObject {
// --- Ungeschützt: mehrere Threads können sich gegenseitig Änderungen überschreiben ---
static final class Zaehler {
private long wert;
void erhoehe() { wert++; } // lesen, +1, schreiben: drei Schritte, nicht atomar
long wert() { return wert; }
}
// --- Monitor mit synchronized: Java-Objekte haben einen eingebauten Monitor ---
static final class SicherZaehler {
private long wert;
synchronized void erhoehe() { wert++; }
synchronized long wert() { return wert; }
}
// --- Monitor mit ReentrantLock: mehr Kontrolle, gut für virtuelle Threads ---
static final class Konto {
private final ReentrantLock sperre = new ReentrantLock();
private long saldoCent;
boolean abbuchen(long cent) {
sperre.lock();
try {
if (saldoCent < cent) return false; // Prüfen und Ändern als EINE geschützte Einheit
saldoCent -= cent;
return true;
} finally {
sperre.unlock();
}
}
void einzahlen(long cent) { sperre.lock(); try { saldoCent += cent; } finally { sperre.unlock(); } }
long saldo() { sperre.lock(); try { return saldoCent; } finally { sperre.unlock(); } }
}
static void parallel(int threads, int jeThread, Runnable r) throws InterruptedException {
ExecutorService ex = Executors.newFixedThreadPool(threads);
for (int t = 0; t < threads; t++) ex.submit(() -> { for (int i = 0; i < jeThread; i++) r.run(); });
ex.shutdown();
ex.awaitTermination(30, TimeUnit.SECONDS);
}
public static void main(String[] args) throws Exception {
Zaehler unsicher = new Zaehler();
SicherZaehler sicher = new SicherZaehler();
parallel(8, 200_000, unsicher::erhoehe);
parallel(8, 200_000, sicher::erhoehe);
System.out.println("8 Threads erhöhen je 200.000-mal, erwartet 1.600.000:");
System.out.println(" ohne Schutz: " + (unsicher.wert() == 1_600_000 ? "zufällig korrekt" : "Änderungen verloren gegangen"));
System.out.println(" mit synchronized: " + sicher.wert());
Konto konto = new Konto();
konto.einzahlen(100_000); // 1.000 €
var erfolge = new java.util.concurrent.atomic.AtomicInteger();
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 5_000; i++) ex.submit(() -> { if (konto.abbuchen(100)) erfolge.incrementAndGet(); });
}
System.out.println("\n5.000 gleichzeitige Abbuchungen à 1 € von 1.000 €:");
System.out.println(" erfolgreich: " + erfolge.get() + ", Saldo: " + konto.saldo() + " Cent (nie negativ)");
}
}Ausgabe
8 Threads erhöhen je 200.000-mal, erwartet 1.600.000:
ohne Schutz: Änderungen verloren gegangen
mit synchronized: 1600000
5.000 gleichzeitige Abbuchungen à 1 € von 1.000 €:
erfolgreich: 1000, Saldo: 0 Cent (nie negativ)Worauf achten
Jedes Java-Objekt hat einen eingebauten Monitor, den synchronized verwendet. Wichtig ist, zusammengehörige Schritte gemeinsam zu schützen: Getrennte, je für sich synchronisierte Methoden pruefe() und buche() helfen nicht, wenn zwischen den Aufrufen ein anderer Thread dazwischenkommt. Halten Threads mehrere Monitore und warten aufeinander, entstehen Deadlocks. Bei virtuellen Threads in Java 21 ist ReentrantLock oft die bessere Wahl, weil ein virtueller Thread in einem synchronized-Block seinen Träger-Thread blockiert. Seit Java 24 ist dieses Problem behoben.
In der Praxis
synchronized-Methoden und -Blöcke, ReentrantLock, Collections.synchronizedList(), ältere Klassen wie Vector und Hashtable. Für einfache Zähler sind AtomicLong und LongAdder schneller als ein Monitor.
41. Read-Write Lock · Lese-Schreib-Sperre
Die Idee
Lesen verändert nichts, deshalb dürfen beliebig viele Leser gleichzeitig zugreifen. Nur wer schreibt, braucht exklusiven Zugriff: Während eines Schreibvorgangs darf weder ein anderer Schreiber noch ein Leser zugreifen. Eine Read-Write Lock verwaltet zwei zusammengehörige Sperren, eine geteilte zum Lesen und eine exklusive zum Schreiben.
Im Code
Eine Preisliste wird 200-mal gelesen und viermal geändert. Die Messung zeigt: Mehrere Leser waren gleichzeitig aktiv, aber während eines Schreibvorgangs las kein einziger. Mit einer normalen Sperre hätten alle 200 Leser nacheinander warten müssen.
Java-21-Code: ReadWriteLock.java
Direkt ausführbar mit java ReadWriteLock.java
// Read-Write Lock
// Beliebig viele Leser dürfen gleichzeitig lesen. Wer schreibt, bekommt exklusiven Zugriff.
// Start: java ReadWriteLock.java
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLock {
static final class Preisliste {
private final ReentrantReadWriteLock sperre = new ReentrantReadWriteLock();
private final Map<String, Long> preise = new HashMap<>(Map.of("Buch", 1_990L, "Lampe", 3_990L));
// Messwerte für die Demonstration
final AtomicInteger leserJetzt = new AtomicInteger();
final AtomicInteger leserMaximal = new AtomicInteger();
final AtomicInteger leserWaehrendSchreiben = new AtomicInteger();
private volatile boolean schreibtGerade;
long preis(String artikel) throws InterruptedException {
sperre.readLock().lock(); // viele gleichzeitig erlaubt
try {
int n = leserJetzt.incrementAndGet();
leserMaximal.accumulateAndGet(n, Math::max);
if (schreibtGerade) leserWaehrendSchreiben.incrementAndGet();
Thread.sleep(20); // Lesen dauert etwas
return preise.get(artikel);
} finally {
leserJetzt.decrementAndGet();
sperre.readLock().unlock();
}
}
void setzePreis(String artikel, long cent) throws InterruptedException {
sperre.writeLock().lock(); // exklusiv: keine Leser, keine anderen Schreiber
try {
schreibtGerade = true;
Thread.sleep(50);
preise.put(artikel, cent);
} finally {
schreibtGerade = false;
sperre.writeLock().unlock();
}
}
}
public static void main(String[] args) {
Preisliste liste = new Preisliste();
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 200; i++) {
ex.submit(() -> liste.preis("Buch")); // viele Leser
if (i % 50 == 0) ex.submit(() -> { liste.setzePreis("Buch", 1_790); return null; }); // wenige Schreiber
}
}
System.out.println("200 Lesezugriffe, 4 Schreibzugriffe:");
System.out.println(" mehrere Leser gleichzeitig: " + (liste.leserMaximal.get() > 1));
System.out.println(" Leser während eines Schreibens: " + liste.leserWaehrendSchreiben.get());
}
}Ausgabe
200 Lesezugriffe, 4 Schreibzugriffe:
mehrere Leser gleichzeitig: true
Leser während eines Schreibens: 0Worauf achten
Der Gewinn tritt nur ein, wenn deutlich mehr gelesen als geschrieben wird und das Lesen eine gewisse Zeit dauert. Bei kurzen Zugriffen ist eine Read-Write Lock wegen ihres Verwaltungsaufwands oft langsamer als eine einfache Sperre. Schreiber können „verhungern“, wenn ständig neue Leser kommen. ReentrantReadWriteLock kann deshalb im fairen Modus betrieben werden. Eine Lesesperre lässt sich nicht in eine Schreibsperre umwandeln, der Versuch führt zum Deadlock. Oft ist ein Immutable Object (Muster 44), das bei Änderungen komplett ausgetauscht wird, die einfachere Lösung.
In der Praxis
ReentrantReadWriteLock, StampedLock mit optimistischem Lesen ohne Sperre, ConcurrentHashMap, die Leser grundsätzlich nicht blockiert, CopyOnWriteArrayList für sehr selten geänderte Listen. In Datenbanken entsprechen geteilte und exklusive Sperren demselben Prinzip.
42. Reactor · Ereignisverteiler
Die Idee
Statt für jede Verbindung einen eigenen Thread zu reservieren, wartet beim Reactor ein einziger Thread auf Ereignisse aller Quellen gleichzeitig: neue Verbindung, Daten angekommen, Verbindung geschlossen. Sobald etwas passiert, ruft er den dafür registrierten Handler auf und wartet dann wieder. Diese Schleife heißt Event-Loop. Das Muster stammt von Douglas Schmidt (POSA 2).
Im Code
Die Klasse EventLoop nimmt Ereignisse aus einer Warteschlange, die hier für den Selector des Betriebssystems steht, und verteilt sie an die registrierten Handler. Drei Verbindungen schicken zehn Ereignisse verzahnt. Alle werden vom einzigen Thread reactor verarbeitet. Die Sitzungsdaten liegen in einer gewöhnlichen HashMap, ohne Sperren.
Java-21-Code: Reactor.java
Direkt ausführbar mit java Reactor.java
// Reactor
// Ein einziger Thread wartet auf Ereignisse aus vielen Quellen und verteilt sie an die zuständigen Handler.
// Start: java Reactor.java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.function.Consumer;
public class Reactor {
enum Art { VERBINDUNG, DATEN, GESCHLOSSEN }
record Ereignis(Art art, String quelle, String inhalt) {}
// --- Der Reactor: Ereignisschleife + Verteilung an registrierte Handler ---
static final class EventLoop {
private final BlockingQueue<Ereignis> eingang = new LinkedBlockingQueue<>(); // steht für den Selector
private final Map<Art, Consumer<Ereignis>> handler = new HashMap<>();
void registriere(Art art, Consumer<Ereignis> h) { handler.put(art, h); }
void melde(Ereignis e) { eingang.add(e); } // von außen: "hier ist etwas passiert"
void stopp() { eingang.add(new Ereignis(null, "", "")); } // Stopp-Ereignis, nach allen anderen
void starte() throws InterruptedException {
while (true) {
Ereignis e = eingang.take(); // warten, bis etwas passiert (demultiplexen)
if (e.art() == null) return;
handler.getOrDefault(e.art(), x -> {}).accept(e); // an den Handler verteilen (dispatch)
}
}
}
public static void main(String[] args) throws Exception {
EventLoop reactor = new EventLoop();
Map<String, List<String>> sitzungen = new HashMap<>(); // kein Locking nötig: nur EIN Thread
reactor.registriere(Art.VERBINDUNG, e -> {
sitzungen.put(e.quelle(), new ArrayList<>());
System.out.println(" [" + Thread.currentThread().getName() + "] neu: " + e.quelle());
});
reactor.registriere(Art.DATEN, e -> {
sitzungen.get(e.quelle()).add(e.inhalt());
System.out.println(" [" + Thread.currentThread().getName() + "] Daten: " + e.quelle() + " -> " + e.inhalt());
});
reactor.registriere(Art.GESCHLOSSEN, e -> {
System.out.println(" [" + Thread.currentThread().getName() + "] Ende: " + e.quelle() + ", empfangen: " + sitzungen.remove(e.quelle()));
});
Thread schleife = Thread.ofPlatform().name("reactor").start(() -> {
try { reactor.starte(); } catch (InterruptedException ignored) { }
});
// Viele "Verbindungen" melden Ereignisse, verzahnt
String[][] ablauf = {
{ "VERBINDUNG", "Kunde-A", "" }, { "VERBINDUNG", "Kunde-B", "" },
{ "DATEN", "Kunde-A", "GET /warenkorb" }, { "DATEN", "Kunde-B", "GET /suche?q=lampe" },
{ "VERBINDUNG", "Kunde-C", "" }, { "DATEN", "Kunde-A", "POST /bestellung" },
{ "GESCHLOSSEN", "Kunde-B", "" }, { "DATEN", "Kunde-C", "GET /" },
{ "GESCHLOSSEN", "Kunde-A", "" }, { "GESCHLOSSEN", "Kunde-C", "" } };
for (String[] z : ablauf) reactor.melde(new Ereignis(Art.valueOf(z[0]), z[1], z[2]));
reactor.stopp();
schleife.join();
System.out.println("\nDrei Verbindungen, zehn Ereignisse, ein einziger Thread.");
}
}Ausgabe
[reactor] neu: Kunde-A
[reactor] neu: Kunde-B
[reactor] Daten: Kunde-A -> GET /warenkorb
[reactor] Daten: Kunde-B -> GET /suche?q=lampe
[reactor] neu: Kunde-C
[reactor] Daten: Kunde-A -> POST /bestellung
[reactor] Ende: Kunde-B, empfangen: [GET /suche?q=lampe]
[reactor] Daten: Kunde-C -> GET /
[reactor] Ende: Kunde-A, empfangen: [GET /warenkorb, POST /bestellung]
[reactor] Ende: Kunde-C, empfangen: [GET /]
Drei Verbindungen, zehn Ereignisse, ein einziger Thread.Worauf achten
Die goldene Regel: Ein Handler darf nie blockieren. Wartet er auf eine Datenbank oder rechnet lange, stehen alle anderen Verbindungen still. Langwierige Arbeit wird deshalb an einen Thread-Pool abgegeben. Code im Reactor-Stil ist schwerer zu lesen als einfacher blockierender Code, weil der Ablauf über viele Rückrufe verteilt ist. Mit virtuellen Threads in Java 21 lässt sich dieselbe Skalierbarkeit in vielen Fällen mit einfachem Code „ein Thread pro Verbindung“ erreichen.
In der Praxis
java.nio.channels.Selector als Demultiplexer, Netty und Vert.x als Reactor-Frameworks, Node.js mit seiner Event-Loop, nginx, Redis, Spring WebFlux über Reactor Netty. Unter Linux liegt meist epoll darunter.
43. Balking / Guarded Suspension · Abbrechen oder Warten
Die Idee
Beide Muster beantworten dieselbe Frage: Was tun, wenn ein Aufruf eintrifft, aber der Zustand ihn gerade nicht zulässt?
- Balking (zögern, sich weigern): Die Methode prüft die Bedingung und kehrt sofort zurück, wenn sie nicht erfüllt ist. Typisch, wenn die Aktion dann sinnlos wäre: speichern, obwohl sich nichts geändert hat, oder ein zweiter Speichervorgang, während der erste noch läuft.
- Guarded Suspension (bewachtes Warten): Die Methode wartet, bis die Bedingung erfüllt ist. Ein anderer Thread stellt sie her und weckt die Wartenden. Typisch, wenn die Aktion später gelingen wird: Ware entnehmen, die gerade geliefert wird.
Im Code
Das Dokument verweigert das Speichern, wenn nichts geändert wurde oder gerade gespeichert wird. Am Ende gibt es genau einen Speichervorgang. Das Lager lässt Kommissionierer mit wait() warten, bis eine Lieferung mit notifyAll() sie weckt. Kommissionierer 1 wartet nach der ersten Lieferung weiter, weil 2 Stück für ihn nicht reichen, und kommt nach der zweiten zum Zug.
Java-21-Code: BalkingGuardedSuspension.java
Direkt ausführbar mit java BalkingGuardedSuspension.java
// Balking und Guarded Suspension
// Zwei Antworten auf dieselbe Frage: Was tun, wenn eine Aktion gerade nicht möglich ist?
// Balking: sofort aufgeben ("lohnt sich nicht")
// Guarded Suspension: warten, bis es möglich ist
// Start: java BalkingGuardedSuspension.java
import java.util.concurrent.Executors;
public class BalkingGuardedSuspension {
// --- Balking: Speichern nur, wenn es etwas zu speichern gibt und nicht schon gespeichert wird ---
static final class Dokument {
private boolean geaendert;
private boolean speichertGerade;
private int speichervorgaenge;
synchronized void bearbeite() { geaendert = true; }
void speichere(String wer) throws InterruptedException {
synchronized (this) {
if (!geaendert || speichertGerade) { // Bedingung nicht erfüllt -> abbrechen
System.out.println(" " + wer + ": " + (speichertGerade ? "wird gerade gespeichert" : "nichts geändert") + ", breche ab");
return;
}
speichertGerade = true;
}
Thread.sleep(50); // langsames Schreiben, ohne Sperre
synchronized (this) {
geaendert = false;
speichertGerade = false;
speichervorgaenge++;
System.out.println(" " + wer + ": gespeichert");
}
}
synchronized int speichervorgaenge() { return speichervorgaenge; }
}
// --- Guarded Suspension: Entnahme wartet, bis genug Bestand da ist ---
static final class Lager {
private int bestand;
synchronized void entnehme(int menge, String wer) throws InterruptedException {
while (bestand < menge) { // Wächterbedingung, immer in einer Schleife
System.out.println(" " + wer + " wartet auf " + menge + " Stück (Bestand " + bestand + ")");
wait(); // Sperre freigeben und schlafen
}
bestand -= menge;
System.out.println(" " + wer + " entnimmt " + menge + " Stück (Rest " + bestand + ")");
}
synchronized void liefere(int menge) {
bestand += menge;
System.out.println(" Lieferung: +" + menge + " (Bestand " + bestand + ")");
notifyAll(); // Wartende wecken
}
}
public static void main(String[] args) throws Exception {
System.out.println("Balking: Autosave und Benutzer wollen gleichzeitig speichern");
Dokument doc = new Dokument();
doc.speichere("Autosave (vor jeder Änderung)");
doc.bearbeite();
Thread t = Thread.ofVirtual().start(() -> { try { doc.speichere("Autosave"); } catch (InterruptedException ignored) { } });
Thread.sleep(10);
doc.speichere("Benutzer"); // Autosave läuft gerade
t.join();
System.out.println(" Speichervorgänge insgesamt: " + doc.speichervorgaenge());
System.out.println("\nGuarded Suspension: Kommissionierer warten auf Ware");
Lager lager = new Lager();
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
ex.submit(() -> { lager.entnehme(3, "Kommissionierer 1"); return null; });
Thread.sleep(50);
ex.submit(() -> { lager.entnehme(2, "Kommissionierer 2"); return null; });
Thread.sleep(50);
lager.liefere(2);
Thread.sleep(50);
lager.liefere(5);
}
}
}Ausgabe
Balking: Autosave und Benutzer wollen gleichzeitig speichern
Autosave (vor jeder Änderung): nichts geändert, breche ab
Benutzer: wird gerade gespeichert, breche ab
Autosave: gespeichert
Speichervorgänge insgesamt: 1
Guarded Suspension: Kommissionierer warten auf Ware
Kommissionierer 1 wartet auf 3 Stück (Bestand 0)
Kommissionierer 2 wartet auf 2 Stück (Bestand 0)
Lieferung: +2 (Bestand 2)
Kommissionierer 1 wartet auf 3 Stück (Bestand 2)
Kommissionierer 2 entnimmt 2 Stück (Rest 0)
Lieferung: +5 (Bestand 5)
Kommissionierer 1 entnimmt 3 Stück (Rest 2)Worauf achten
Die Wächterbedingung muss immer in einer while-Schleife geprüft werden, nie in einem if. Ein geweckter Thread muss erneut prüfen, weil ein anderer ihm zuvorgekommen sein kann oder er ohne Grund aufwacht (spurious wakeup). notifyAll() ist sicherer als notify(), das nur einen beliebigen Wartenden weckt. Bei Guarded Suspension ist fast immer ein Zeitlimit sinnvoll, damit ein Thread nicht ewig wartet. In modernem Java nimmt man statt wait()/notifyAll() meist eine BlockingQueue, eine Condition oder einen Semaphore.
In der Praxis
Balking: Autosave-Funktionen, das Abfangen von Doppelklicks auf „Bestellen“, Initialisierungen, die nur einmal laufen sollen. Guarded Suspension: BlockingQueue.take(), CountDownLatch.await(), Condition.await() zusammen mit ReentrantLock.
44. Immutable Object · Unveränderliches Objekt
Die Idee
Ein Objekt, das sich nach seiner Erzeugung nie mehr verändert, kann ohne jede Absicherung von beliebig vielen Threads gleichzeitig gelesen werden. Es gibt nichts, was sich zwischen zwei Zugriffen ändern könnte. „Ändern“ heißt dann: eine neue Version erzeugen. Wer die aktuelle Version braucht, holt sie sich über eine einzige, atomar austauschbare Referenz.
Im Code
Die Preisliste ist ein Record mit defensiven Kopien im Konstruktor. Ändert jemand nachträglich die übergebene Liste, bleibt die Preisliste unberührt, und auch über den Getter lässt sich nichts hinzufügen. mitPreis() liefert eine neue Version. Eine AtomicReference zeigt auf die aktuelle Version. 1.000 Leser greifen ohne Sperre zu, 100 Schreiber setzen per updateAndGet neue Versionen ein, und am Ende stimmen Preis und Versionsnummer exakt.
Java-21-Code: ImmutableObject.java
Direkt ausführbar mit java ImmutableObject.java
// Immutable Object
// Ein Objekt, das nach seiner Erzeugung nie mehr verändert wird, ist automatisch threadsicher.
// Start: java ImmutableObject.java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
public class ImmutableObject {
// --- Unveränderlich: final, keine Setter, Kopien von veränderlichen Eingaben ---
record Preisliste(int version, Map<String, Long> preise, List<String> aktionen) {
Preisliste {
preise = Map.copyOf(preise); // defensive Kopie: spätere Änderungen am Original wirken nicht
aktionen = List.copyOf(aktionen);
}
// "Ändern" heißt: neue Instanz liefern
Preisliste mitPreis(String artikel, long cent) {
Map<String, Long> neu = new TreeMap<>(preise);
neu.put(artikel, cent);
return new Preisliste(version + 1, neu, aktionen);
}
public String toString() { return "v" + version + " " + new TreeMap<>(preise) + " " + aktionen; }
}
public static void main(String[] args) throws Exception {
List<String> aktionen = new ArrayList<>(List.of("Herbstrabatt"));
Preisliste v1 = new Preisliste(1, Map.of("Buch", 1_990L, "Lampe", 3_990L), aktionen);
aktionen.add("heimlich hinzugefügt"); // ändert die Preisliste NICHT
System.out.println("v1 nach Änderung der Eingabeliste: " + v1);
try { v1.aktionen().add("Hack"); }
catch (UnsupportedOperationException e) { System.out.println("v1.aktionen().add(...) -> abgelehnt"); }
Preisliste v2 = v1.mitPreis("Buch", 1_790);
System.out.println("v1 bleibt: " + v1);
System.out.println("v2 neu: " + v2);
// --- Teilen zwischen Threads: lesen ohne Sperren, austauschen per AtomicReference ---
AtomicReference<Preisliste> aktuell = new AtomicReference<>(v2);
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) ex.submit(() -> aktuell.get().preise().get("Lampe")); // 1.000 Leser
for (int i = 0; i < 100; i++) ex.submit(() -> aktuell.updateAndGet(p -> p.mitPreis("Lampe", p.preise().get("Lampe") - 1)));
}
System.out.println("\nNach 1.000 Lesern und 100 Preissenkungen um je 1 Cent:");
System.out.println(" " + aktuell.get());
System.out.println(" Lampe = 3990 - 100 = " + aktuell.get().preise().get("Lampe") + ", Version " + aktuell.get().version());
}
}Ausgabe
v1 nach Änderung der Eingabeliste: v1 {Buch=1990, Lampe=3990} [Herbstrabatt]
v1.aktionen().add(...) -> abgelehnt
v1 bleibt: v1 {Buch=1990, Lampe=3990} [Herbstrabatt]
v2 neu: v2 {Buch=1790, Lampe=3990} [Herbstrabatt]
Nach 1.000 Lesern und 100 Preissenkungen um je 1 Cent:
v102 {Buch=1790, Lampe=3890} [Herbstrabatt]
Lampe = 3990 - 100 = 3890, Version 102Worauf achten
Ein Objekt ist nur dann wirklich unveränderlich, wenn alle Felder final sind, keine Methode etwas ändert und auch enthaltene Objekte unveränderlich sind oder kopiert werden. Ein Record mit einer ArrayList darin ist es nicht. Daher die Kopie mit List.copyOf. Bei großen Objekten, die oft geändert werden, kostet das ständige Kopieren Zeit und Speicher. Dafür gibt es persistente Datenstrukturen, die unveränderte Teile zwischen Versionen teilen. Das Muster vereinfacht nebenläufige Programme so stark, dass Joshua Bloch rät: „Klassen sollten unveränderlich sein, solange es keinen sehr guten Grund dagegen gibt.“
In der Praxis
Im JDK: String, Integer, LocalDate, BigDecimal, Records, List.of() und Map.of(). Konfigurationen, die zur Laufzeit komplett ausgetauscht werden, Zustände in Redux und in funktionalen Sprachen. Das Value Object aus Gruppe 2 ist fast immer zugleich ein Immutable Object.
Im Überblick
| Problem | Muster | Java 21 |
|---|---|---|
| Arbeit verteilen | Producer-Consumer | BlockingQueue |
| Thread Pool | ExecutorService, virtuelle Threads | |
| Future / Promise | CompletableFuture | |
| Gemeinsamen Zustand schützen | Monitor Object | synchronized, ReentrantLock |
| Read-Write Lock | ReentrantReadWriteLock, StampedLock | |
| Immutable Object | Records, List.copyOf, AtomicReference | |
| Zustand nur einem Thread geben | Active Object | newSingleThreadExecutor |
| Reactor | Selector, Netty | |
| Auf Bedingungen reagieren | Balking / Guarded Suspension | wait/notifyAll, Condition |
Eine Faustregel für den Alltag: Zuerst versuchen, ohne gemeinsamen veränderlichen Zustand auszukommen (Immutable Object, Active Object). Wenn das nicht geht, fertige Bausteine aus java.util.concurrent verwenden. Eigene Sperren mit synchronized und wait sind der letzte Ausweg.