Software-Muster jenseits von GoF

Nebenläufigkeit

Software-Muster jenseits von GoF · Gruppe 6 von 7

Lesestand

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 · Erzeuger und Verbraucher

ShopAppWarteschlange, Kapazität 5Lager-Worker 1Lager-Worker 2Lager-Worker 3voll: Erzeuger wartetleer: Verbraucher wartetschnelllangsamer
Shop und App legen Bestellungen in eine Warteschlange mit fünf Plätzen. Drei Worker nehmen sie heraus, jeder in seinem Tempo.

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

Aufgaben56789101112Pool: 4 ThreadsThread 1: Aufgabe 1Thread 2: Aufgabe 2Thread 3: Aufgabe 3Thread 4: Aufgabe 4fertigwarten auf einenfreien ThreadThreads werden wiederverwendet, nicht neu erzeugtJava 21: für wartende Aufgaben lieber ein virtueller Thread pro Aufgabe
Vier Threads arbeiten die Aufgaben nacheinander ab. Die übrigen warten in der Schlange, bis ein Thread frei wird.

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: true

Worauf 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

nacheinanderPreisBestandVersand≈ 900 msparallelPreisBestandVersandthenCombine≈ 300 ms0300600900 msDas Programm kann weiterarbeiten, während die Ergebnisse unterwegs sind
Drei langsame Abfragen nacheinander dauern so lange wie alle zusammen. Als Futures parallel gestartet, so lange wie die längste.

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 ms

Worauf 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

Aufrufer 1Aufrufer 2Aufrufer 3Active Object: LagerProxyliefert FutureAuftragswarteschlangeeigener ThreadBestand(nur er greift zu)Aufrufe kehren sofort zurück, ausgeführt wird nacheinanderkeine Sperren nötig: der Zustand gehört genau einem Thread
Viele Aufrufer greifen gleichzeitig auf das Lager zu. Ihre Aufträge werden eingereiht und vom einzigen Thread des Lagers nacheinander ausgeführt.

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

Thread 2: wartetThread 3: wartetThread 4: wartetThread 5: wartetKonto (Monitor)synchronized abbuchen()Thread 1: drinnensaldoprüfen und ändernals eine EinheitSaldo nie negativ
Das Konto lässt immer nur einen Thread in seine Methoden. Die anderen warten vor der Tür.

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

LesenLeser 1Leser 2Leser 3Leser 4alle gleichzeitig: okSchreibenSchreiberLeser 5: wartetLeser 6: wartetLeser 7: wartetDaten ändern sich selten, werden aber oft gelesen
Links lesen vier Threads gleichzeitig. Rechts schreibt einer, und alle Leser warten, bis er fertig ist.

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: 0

Worauf 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

Verbindung AVerbindung BVerbindung CVerbindung DDemultiplexer(Selector)Event-Loopein Threadneue VerbindungDaten lesenVerbindung zuwartet auf alle Quellen zugleichHandler: kurz, nie blockieren
Viele Verbindungen, ein einziger Thread: Der Reactor wartet auf Ereignisse aller Quellen und ruft jeweils den passenden Handler auf.

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

Balkingspeichere()geändert?neinsofort zurückz. B. Autosave,DoppelklickGuarded Suspensionentnehme(3)Bestand ≥ 3?nein: wait()anderer Thread: liefere() → notifyAll()wartet, bis dieBedingung erfüllt ist
Ist eine Bedingung nicht erfüllt, bricht Balking sofort ab. Guarded Suspension wartet, bis ein anderer Thread die Bedingung herstellt.

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

Preisliste v1Buch 19,90 · Lampe 39,90mitPreis(...)Preisliste v2Buch 17,90 · Lampe 39,90bleibt unverändertAtomicReferencezeigt aufLeser 1Leser 2Leser 3Leser 4lesen ohne Sperreändern = neue Version erzeugen und Zeiger umsetzen
Eine Preisänderung erzeugt eine neue Version. Leser greifen ohne Sperre auf die aktuelle Version zu, die alte bleibt unberührt.

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 102

Worauf 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

ProblemMusterJava 21
Arbeit verteilenProducer-ConsumerBlockingQueue
Thread PoolExecutorService, virtuelle Threads
Future / PromiseCompletableFuture
Gemeinsamen Zustand schützenMonitor Objectsynchronized, ReentrantLock
Read-Write LockReentrantReadWriteLock, StampedLock
Immutable ObjectRecords, List.copyOf, AtomicReference
Zustand nur einem Thread gebenActive ObjectnewSingleThreadExecutor
ReactorSelector, Netty
Auf Bedingungen reagierenBalking / Guarded Suspensionwait/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.