Zum Inhalt

Java Concurrency Masterclass: JMM, Lock-Free Atomics & Virtual Threads (Loom)

Erstellt: 07.11.2026 Zuletzt aktualisiert: 07.11.2026

Executive Summary: Moderne Mehrkernprozessoren erzielen enormen Durchsatz durch aggressive Out-of-Order-Befehlsausführung, spekulative Pipelines und mehrstufige Cache-Hierarchien (L1, L2, L3). Parallel laufende Threads können sich nicht auf intuitive Lese-/Schreibreihenfolgen verlassen; ihr Verhalten wird formal durch das Java Memory Model (JMM / JSR-133) und Hardware-Speicherbarrieren bestimmt. Bei stark umkämpften Ressourcen eliminieren sperrenfreie Compare-And-Swap (CAS) Primitiven teure Kernel-Futex-Kontextwechsel, während Project Loom Virtual Threads die Skalierung der JVM vollständig von nativen Linux-Thread-Limits entkoppeln. Dieser Leitfaden bildet Teil 3.1 (Advanced & Deep Dive) unseres Java-Curriculums: Er dekonstruiert Speicherbarrieren und Happens-Before-Garantien, analysiert lock-freie Atomics und StampedLock, erklärt Continuations im Loom-Scheduler und führt einen Benchmark mit 50.000 parallelen blockierenden Tasks durch.

  • Java Memory Model (JMM)


    Happens-Before-Garantien, Cache-Kohärenz (MESI), Store-Buffer, Hardware-Speicherbarrieren (mfence) und False Sharing analysieren.

  • Lock-Free Atomics & CAS


    Hardware-Instruktionen (cmpxchg) nutzen, das ABA-Problem mit Versions-Stamps lösen und Contention mit LongAdder eliminieren.

  • Explizite Locks & StampedLock


    AQS-Warteschlangen, Monitor-Inflation (vom Thin Lock zum Fat OS Futex) und sperrenfreie optimistische Leseoperationen via StampedLock verstehen.

  • 50k Virtual Threads Praxislabor


    50.000 parallele blockierende I/O-Tasks benchmarken und die Sättigung klassischer Plattform-Threadpools mit Project Loom vergleichen.


1. Das Java Memory Model (JMM) & CPU-Hardware-Realitäten

Auf moderner Multiprozessor-Hardware (SMP) schreiben CPU-Kerne Daten nicht sofort in das zentrale RAM, sondern in lokale Store Buffers und Zwischen-Caches (L1/L2), die über Cache-Kohärenzprotokolle (wie MESI) synchronisiert werden:

%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
    subgraph MultiCoreCPU ["Multi-Core CPU Hardware-Topologie"]
        subgraph Core1 ["CPU Core 0"]
            REG1["Register"]
            SB1["Store Buffer (Out-of-Order)"]
            L1_1["L1 Daten-Cache (32 KB)"]
            REG1 --> SB1 --> L1_1
        end

        subgraph Core2 ["CPU Core 1"]
            REG2["Register"]
            SB2["Store Buffer (Out-of-Order)"]
            L1_2["L1 Daten-Cache (32 KB)"]
            REG2 --> SB2 --> L1_2
        end

        L3["Gemeinsamer L3 Cache (32 MB)"]
        L1_1 --> L3
        L1_2 --> L3
    end

    RAM["Hauptspeicher (DDR5 RAM)"]
    L3 ==> RAM

Weil CPU-Kerne ihre Store-Buffer asynchron leeren, um die Rechenwerke nicht zu blockieren, entstehen ohne explizite Barrieren Befehlsumordnungen (Instruction Reordering) und Sichtbarkeitsfehler:

Die Happens-Before-Garantie (JSR-133)

Das JMM definiert eine partielle Ordnung über alle Speicherzugriffe. Wenn Operation $A$ happens-before Operation $B$ gilt ($A \prec B$), ist garantiert, dass $B$ alle Zustandsänderungen von $A$ sieht:

  1. Programmreihenfolge-Regel: Jede Aktion in einem Thread geschieht vor jeder späteren Aktion in demselben Thread.
  2. Monitor-Lock-Regel: Das Freigeben (unlock) eines Monitors geschieht vor jedem nachfolgenden Sperren (lock) desselben Monitors.
  3. Volatile-Variablen-Regel: Ein Schreibzugriff auf ein volatile-Feld geschieht vor jedem nachfolgenden Lesezugriff auf dasselbe Feld.
  4. Thread-Start/Join-Regel: Der Aufruf von thread.start() geschieht vor allen Aktionen im gestarteten Thread; alle Aktionen eines Threads geschehen vor der Rückkehr aus thread.join().

Assembly-Mechanik von volatile

Auf x86_64-Architekturen ist das Lesen von RAM von Natur aus geordnet. Ein Schreibzugriff auf eine volatile-Variable veranlasst HotSpot jedoch, eine Hardware-Speicherbarriere zu emittieren:

lock addl $0x0,(%rsp)   # Hardware-Speicherbarriere leert den CPU-Store-Buffer

Dies zwingt die CPU, alle vorangegangenen Schreibzugriffe in den L1/L2-Cache zu flushen und verbietet JIT-Compiler-Umordnungen.


2. Lock-Free Programmierung: Compare-And-Swap (CAS) & Atomics

Herkömmliche Sperren schläfern Threads über Betriebssystem-Kernel-Kontextwechsel (futex-Syscalls unter Linux) ein, was pro Konflikt ca. 1.000–5.000 CPU-Taktzyklen kostet. Sperrenfreie (Lock-Free) Algorithmen nutzen stattdessen atomare Hardware-Instruktionen.

Das Compare-And-Swap (CAS) Primitiv

Die CPU führt Vergleich und Zuweisung in einem einzigen atomaren Hardware-Zyklus aus:

$$\text{CAS}(V, E, N): \quad \text{wenn } V == E \implies V = N, \text{ liefere true; sonst liefere false;}$$

Auszug aus AtomicInteger.incrementAndGet()
public final int incrementAndGet() {
    return U.getAndAddInt(this, VALUE, 1) + 1; // Bildet direkt auf die CPU-Instruktion 'lock cmpxchg' ab!
}

Das ABA-Problem & Versions-Stamps

Liest Thread 1 den Wert $A$, ändert Thread 2 den Wert auf $A \to B \to A$, dann gelingt das CAS von Thread 1 fälschlicherweise, obwohl der Zwischenzustand manipuliert wurde.

  • Lösung: AtomicStampedReference<V> koppelt die Objektreferenz mit einer ganzzahligen Versionsnummer, die bei jeder Änderung streng monoton ansteigt.

Skalierung unter Hochlast: LongAdder vs. AtomicLong

Unter extremer Last (z.B. 64 CPU-Kerne inkrementieren dieselbe Variable) bricht AtomicLong ein, weil alle Kerne um dieselbe Cache-Line kämpfen (Cache-Line-Bouncing).

LongAdder löst dies durch Cell-Striping:

%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
    subgraph LongAdderTopology ["LongAdder Cell-Striping Architektur"]
        BASE["Basis-Zähler (Geringe Last)"]
        C1["Cell [0] (Core 0 Zähler)"]
        C2["Cell [1] (Core 1 Zähler)"]
        C3["Cell [2] (Core 2 Zähler)"]
        C4["Cell [3] (Core 3 Zähler)"]
    end

    SUM["Gesamtsumme = Basis + Cell[0] + Cell[1] + Cell[2] + Cell[3]"]
    BASE --> SUM
    C1 --> SUM
    C2 --> SUM
    C3 --> SUM
    C4 --> SUM

Threads hashen auf unabhängige, gepaddete Cell-Objekte. Konflikte werden eliminiert; aufsummiert wird erst bei Aufruf von .sum().


3. Explizite Locks & Optimistisches Lesen (StampedLock)

Während ReentrantLock über den AbstractQueuedSynchronizer (AQS) Timeout-fähige, faire Sperren ermöglicht, setzt man bei extrem vielen Lesezugriffen auf StampedLock.

Das optimistische Lese-Idiom

Im Gegensatz zu ReentrantReadWriteLock, wo ein geteiltes Lese-Lock schreibende Threads blockiert, erlaubt StampedLock optimistisches Lesen völlig ohne echtes Sperren:

Optimistisches Lesen mit StampedLock
public class Point {
    private double x, y;
    private final StampedLock sl = new StampedLock();

    public double distanceFromOrigin() {
        // 1. Optimistischen Lese-Stempel abrufen (KEIN Lock gesetzt, Schreiber laufen unblockiert!)
        long stamp = sl.tryOptimisticRead();
        double currentX = x, currentY = y;

        // 2. Prüfen, ob während des Lesens ein Schreiber aktiv war
        if (!sl.validate(stamp)) {
            // 3. Fallback auf ein pessimistisches echtes Lese-Lock
            stamp = sl.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
}

4. Project Loom: Virtual Threads Architektur (Java 21 LTS)

Historisch bildete Java jeden java.lang.Thread 1:1 auf einen nativen Linux-Kernel-Thread (pthread) ab. Da jeder Thread standardmäßig 1 MB Stack-Speicher (-Xss) reserviert, ist ein Server nach 5.000–10.000 Threads speicherseitig erschöpft.

Das M:N User-Mode Scheduling

Java 21 führt Virtual Threads ein: extrem leichtgewichtige Userspace-Threads, die von der HotSpot-JVM auf einen kleinen Pool von Carrier-OS-Threads gemountet werden (ForkJoinPool):

%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
    subgraph VirtualThreadsLayer ["1.000.000+ Virtual Threads (Heap-Continuations)"]
        VT1["Virtual Thread #1 (Socket I/O)"]
        VT2["Virtual Thread #2 (Datenbankabfrage)"]
        VT3["Virtual Thread #3 (HTTP-Request)"]
        VT4["Virtual Thread #4 (Blockiert auf epoll)"]
    end

    subgraph CarrierPool ["Carrier Plattform-Threads (ForkJoinPool = CPU Cores)"]
        C1["Carrier Thread 0 (OS Thread)"]
        C2["Carrier Thread 1 (OS Thread)"]
    end

    subgraph LinuxKernel ["Debian Linux Kernel"]
        EPOLL["Nicht-blockierender epoll Deskriptor-Monitor"]
    end

    VT1 ==>|"Gemountet"| C1
    VT2 ==>|"Gemountet"| C2
    VT3 -.->|"Stack auf Heap unmountet"| EPOLL
    VT4 -.->|"Stack auf Heap unmountet"| EPOLL
  1. Mounting & Ausführung: Ein virtueller Thread mountet auf einen Carrier-Thread und führt normalen synchronen Code aus.
  2. Unmounting bei blockierendem I/O: Sobald der virtuelle Thread eine blockierende Operation ausführt (Socket, JDBC, Thread.sleep), friert die JVM seinen Stack-Frame auf dem JVM-Heap ein, unmountet den Thread und gibt den Träger-OS-Thread sofort frei!
  3. Epoll-Benachrichtigung: Sobald Linux epoll meldet, dass Daten vorliegen, plant die JVM die Continuation auf einem beliebigen freien Carrier-Thread wieder ein.

[!WARNING] Die Thread-Pinning-Gefahr Betritt ein virtueller Thread einen synchronized-Block oder eine native JNI-Methode und blockiert darin auf I/O, wird er an seinen Carrier-Thread gepinnt und blockiert diesen für alle anderen Virtual Threads! Industrieregel: Ersetze synchronized in Hochlast-Pfaden durch ReentrantLock, das voll Loom-kompatibel ist und niemals Threads pinnt.


5. Hands-On Systems Lab: VirtualThreadBenchmarkLab.java

Wir implementieren nun einen Benchmark mit 50.000 parallelen blockierenden I/O-Tasks, um die Überlegenheit von Project Loom live zu messen: - Simuliert realistische 50 ms Netzwerk-/Datenbank-Latenz pro Task. - Führt 50.000 Tasks auf einem klassischen Plattform-Thread-Pool (fest 200 OS-Threads) aus. - Führt 50.000 Tasks mit Virtual Threads (Executors.newVirtualThreadPerTaskExecutor()) aus. - Misst Ausführungsdauer, Durchsatz und Systemverhalten. - Vollständig lauffähig via JEP 330 (java VirtualThreadBenchmarkLab.java).

~/java_advanced/VirtualThreadBenchmarkLab.java
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class VirtualThreadBenchmarkLab {

    private static final int TOTAL_TASKS = 50_000;
    private static final int SIMULATED_IO_LATENCY_MS = 50;

    public static void main(String[] args) throws InterruptedException {
        System.out.println("=== PROJECT LOOM VIRTUAL THREADS BENCHMARK LAB ===");
        System.out.println("Verfügbare CPU-Kerne: " + Runtime.getRuntime().availableProcessors());
        System.out.println("Parallele blockierende Tasks: " + TOTAL_TASKS + " (je 50ms I/O Latenz)\n");

        // 1. Ausführung mit herkömmlichem Plattform-Threadpool (fest 200 OS Threads)
        System.out.println("--- Test A: Plattform-Thread-Pool (Fest 200 OS-Threads) ---");
        try (ExecutorService platformExecutor = Executors.newFixedThreadPool(200)) {
            runBenchmark(platformExecutor, "Plattform-Threads (Pool=200)");
        }

        // Kurze Pause zur Stabilisierung
        Thread.sleep(1000);

        // 2. Ausführung mit Project Loom Virtual Threads
        System.out.println("\n--- Test B: Project Loom Virtual Threads (1 Task = 1 Virtual Thread) ---");
        try (ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
            runBenchmark(virtualExecutor, "Virtual Threads (Unbeschränkt)");
        }

        System.out.println("\nVirtual Threads Systems-Benchmark erfolgreich abgeschlossen.");
    }

    private static void runBenchmark(ExecutorService executor, String testName) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(TOTAL_TASKS);
        AtomicInteger successCounter = new AtomicInteger(0);

        Instant start = Instant.now();

        for (int i = 0; i < TOTAL_TASKS; i++) {
            executor.submit(() -> {
                try {
                    // Simuliert blockierendes Socket-Read oder Datenbankabfrage
                    Thread.sleep(Duration.ofMillis(SIMULATED_IO_LATENCY_MS));
                    successCounter.incrementAndGet();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    latch.countDown();
                }
            });
        }

        // Warten, bis alle 50.000 Tasks beendet sind
        latch.await();
        Instant finish = Instant.now();

        long durationMs = Duration.between(start, finish).toMillis();
        double throughputPerSec = ((double) TOTAL_TASKS / durationMs) * 1000.0;

        System.out.printf("Ergebnisse für [%s]:\n", testName);
        System.out.printf("  Erfolgreiche Tasks: %d / %d\n", successCounter.get(), TOTAL_TASKS);
        System.out.printf("  Benötigte Zeit:    %d ms (%.2f Sekunden)\n", durationMs, durationMs / 1000.0);
        System.out.printf("  Durchsatz:         %.2f Tasks/Sekunde\n", throughputPerSec);
    }
}

Kompilieren und Ausführen unter Linux

Führe das Benchmark-Programm direkt ohne manuelles Kompilieren aus:

Ausführen von VirtualThreadBenchmarkLab.java
java VirtualThreadBenchmarkLab.java

Erwartete Terminal-Ausgabe:

=== PROJECT LOOM VIRTUAL THREADS BENCHMARK LAB ===
Verfügbare CPU-Kerne: 8
Parallele blockierende Tasks: 50000 (je 50ms I/O Latenz)

--- Test A: Plattform-Thread-Pool (Fest 200 OS-Threads) ---
Ergebnisse für [Plattform-Threads (Pool=200)]:
  Erfolgreiche Tasks: 50000 / 50000
  Benötigte Zeit:    12840 ms (12.84 Sekunden)
  Durchsatz:         3894.08 Tasks/Sekunde

--- Test B: Project Loom Virtual Threads (1 Task = 1 Virtual Thread) ---
Ergebnisse für [Virtual Threads (Unbeschränkt)]:
  Erfolgreiche Tasks: 50000 / 50000
  Benötigte Zeit:    382 ms (0.38 Sekunden)
  Durchsatz:         130890.05 Tasks/Sekunde

Virtual Threads Systems-Benchmark erfolgreich abgeschlossen.

[!NOTE] Architektonische Erkenntnis In Test A stauen sich 50.000 Tasks hinter 200 Betriebssystem-Threads und benötigen ~13 Sekunden. In Test B unmounten die Virtual Threads bei sleep() verzögerungsfrei, wodurch alle 50.000 Tasks in 382 Millisekunden abgearbeitet werden – eine 33-fache Durchsatzsteigerung bei minimalem Speicherverbrauch!


Nächste Schritte

Nachdem asynchrone Nebenläufigkeit und Skalierung gemeistert sind, steigen wir tief in die Interna der HotSpot-Engine und des JIT-Compilers ein:

  • Teil 3.2: HotSpot-Interna, JIT & Garbage Collection Tuning


    Tiered Compilation (C1/C2 JIT), Escape Analysis, G1GC vs. Generational ZGC und Container-Speicherlimits beherrschen.

    Weiter zu Teil 3.2