CodeGym /Kurse /JAVA 25 SELF /Java Memory Model (JMM)

Java Memory Model (JMM)

JAVA 25 SELF
Level 58, Lektion 4
Verfügbar

1. Einstieg in das Java Memory Model (JMM)

Sichtbarkeit und Reihenfolge

In einem Single-Thread-Programm ist alles einfach: Sie schreiben einen Wert in eine Variable – und können ihn sofort lesen. In der Multithread-Realität funktioniert das anders. Prozessoren cachen Werte, Compiler und JVM ändern mitunter die Reihenfolge von Instruktionen, und ein Thread kann einen „alten“ Wert sehen, selbst wenn ein anderer Thread ihn gerade geändert hat.

Java Memory Model (JMM) beschreibt genau, wie Threads über Speicher kommunizieren: wann Änderungen eines Threads für andere sichtbar werden und in welcher Reihenfolge Operationen stattfinden. Wenn man das nicht beachtet, kann sich ein Programm unvorhersehbar verhalten, obwohl auf den ersten Blick alles normal aussieht.

Das Verständnis des JMM erklärt, warum ein Thread manchmal keine frischen Daten sieht, wie man volatile, synchronized und atomare Klassen richtig einsetzt und warum Bugs in nebenläufigem Code oft erst in der Produktion auftreten. Kurz gesagt: Das JMM sind die Spielregeln des Speichers – ignoriert man sie, kann selbst der sorgfältigste Code gegen einen arbeiten.

Analogie

Stellen Sie sich zwei Personen (Threads) vor, die Notizen (Variablen) auf eine Tafel (Speicher) schreiben und von dort lesen. Manchmal schreibt die eine Person, während die andere die neue Notiz noch nicht sieht – weil sie auf ihre eigene Kopie der Tafel (Cache) schaut. Das JMM legt fest, wann und wie diese Notizen für alle sichtbar werden.

2. happens-before: das Fundament des JMM

Was ist happens-before?

happens-before ist eine Beziehung zwischen zwei Aktionen in einem Programm: Wenn Aktion A happens-before Aktion B ist, sind alle in A gemachten Änderungen in B garantiert sichtbar.

Wichtig: happens-before bedeutet nicht einfach „ist vorher passiert“, sondern „ist garantiert sichtbar“.

Wesentliche happens-before-Regeln

1. Innerhalb eines einzelnen Threads

Alles, was in einem Thread passiert, ist geordnet: Wenn Sie in eine Variable schreiben und sie danach lesen, sehen Sie Ihre eigene Änderung.

2. Synchronisierte Blöcke/Monitore

Alles, was vor dem Verlassen eines synchronisierten Blocks (synchronized) passiert, wird für den Thread sichtbar, der später in diesen Block eintritt.

synchronized(lock) {
    sharedVar = 42; // Schreiben
}
// ...
synchronized(lock) {
    System.out.println(sharedVar); // wir sehen garantiert 42
}

3. volatile-Schreiben/-Lesen

Ein Schreiben in ein volatile-Feld steht in happens-before-Beziehung zu jedem nachfolgenden Lesen dieses Feldes durch einen anderen Thread.

volatile boolean ready = false;

// Thread 1
data = 123;
ready = true; // volatile write

// Thread 2
if (ready) { // volatile read
    System.out.println(data); // wir sehen garantiert data = 123
}

4. Starten und Beenden von Threads

  • Der Aufruf von Thread.start() steht in happens-before zur Aufnahme der Arbeit des Threads.
  • Das Ende eines Threads steht in happens-before zur Rückkehr aus Thread.join().

5. Abschluss einer Aufgabe im Executor

Wenn Sie eine Aufgabe an einen Executor übergeben und auf ihren Abschluss warten (Future.get()), sind alle in der Aufgabe gemachten Änderungen nach get() sichtbar.

6. Finale Felder

Die Initialisierung von Feldern mit dem Modifikator final im Konstruktor steht in happens-before zur Veröffentlichung der Referenz auf das Objekt. Das ist wichtig für unveränderliche Objekte.

3. Sichere Veröffentlichung von Objekten

Problem: „veraltetes“ Objekt

Wenn ein Thread ein Objekt erzeugt und es ohne Synchronisation an einen anderen Thread übergibt, kann der andere Thread „rohe“ Feldwerte sehen (z. B. nicht initialisierte oder alte Werte).

class Holder {
    int value;
    Holder() { value = 42; }
}

Holder holder = null;

// Thread 1
holder = new Holder(); // Objekt erstellen

// Thread 2
if (holder != null) {
    System.out.println(holder.value); // kann 0 statt 42 sehen!
}

Wie veröffentlicht man Objekte korrekt?

1. Über final-Felder

Wenn alle Felder eines Objekts final sind und im Konstruktor initialisiert werden, kann das Objekt ohne zusätzliche Synchronisation sicher veröffentlicht werden.

class SafeHolder {
    final int value;
    SafeHolder() { value = 42; }
}

2. Über eine volatile-Referenz

Wenn die Referenz auf das Objekt als volatile deklariert ist, ist das Objekt nach der Zuweisung garantiert für andere Threads sichtbar.

volatile Holder holder;

// Thread 1
holder = new Holder();

// Thread 2
if (holder != null) {
    System.out.println(holder.value); // wir sehen garantiert 42
}

3. Durch Single-Thread-Initialisierung vor der Veröffentlichung

Wenn das Objekt erzeugt und initialisiert wird, bevor die Referenz darauf für andere Threads sichtbar wird, ist alles sicher.

Holder holder = new Holder(); // nur in einem Thread
// ... danach wird holder für andere Threads sichtbar

4. Über Sperren

Wenn das Objekt innerhalb eines synchronisierten Blocks erstellt wird und die Referenz darauf nur innerhalb eines solchen Blocks gelesen wird, ist alles sicher.

Holder holder;

synchronized(lock) {
    if (holder == null) {
        holder = new Holder();
    }
}

// ... in einem anderen Thread
synchronized(lock) {
    if (holder != null) {
        // sicher
    }
}

4. Double-Checked Locking und volatile

Was ist Double-Checked Locking?

Das ist ein Pattern für die Lazy-Initialisierung eines Singleton-Objekts:

class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) { // 1. Prüfung
            synchronized (Singleton.class) {
                if (instance == null) { // 2. Prüfung
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Problem: Ohne eine volatile-Referenz auf instance funktioniert dieser Code nicht korrekt! Ein Thread kann ein nicht vollständig initialisiertes Objekt sehen.

Warum ist es ohne volatile problematisch?

Die JVM kann Instruktionen so umordnen, dass die Referenz auf das Objekt vor Abschluss des Konstruktors zugewiesen wird. Ein anderer Thread sieht dann ein nicht initialisiertes Objekt.

Wie macht man es richtig?

Deklarieren Sie instance als volatile:

private static volatile Singleton instance;

Jetzt funktioniert Double-Checked Locking korrekt: volatile garantiert die Beziehung happens-before zwischen Schreiben und Lesen der Referenz.

Alternative: statische Initialisierung

Der einfachste und sicherste Weg, einen Singleton zu erstellen – statische Initialisierung:

class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    public static Singleton getInstance() { return INSTANCE; }
}

Hier garantiert die JVM von selbst die korrekte Initialisierung.

5. VarHandle: moderner Low-Level-Zugriff

Was ist VarHandle?

VarHandle ist eine moderne API (Java 9+), die Low-Level-Zugriff auf Variablen erlaubt: lesen, schreiben, atomare Operationen ausführen sowie Sichtbarkeit und Instruktionsreihenfolge steuern.

Warum VarHandle, wenn es atomare Klassen gibt?
– VarHandle ermöglicht die Arbeit mit beliebigen Feldern (nicht nur mit int/long/Reference).
– Ermöglicht die explizite Wahl der Zugriffssemantik: volatile, acquire/release, opaque.
– Wird zur Implementierung hochperformanter Datenstrukturen eingesetzt.

Zugriffssemantiken

  • Volatile: volle happens-before-Garantie (wie bei einem volatile-Feld).
  • Acquire/Release: schwächere Garantie, aber schneller (wird für lock-freie Strukturen verwendet).
  • Opaque: minimale Sichtbarkeitsgarantie bei maximaler Performance.

Beispiel für die Verwendung von VarHandle

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

class Counter {
    int value;
    static final VarHandle VALUE_HANDLE;

    static {
        try {
            VALUE_HANDLE = MethodHandles.lookup().findVarHandle(Counter.class, "value", int.class);
        } catch (Exception e) {
            throw new Error(e);
        }
    }
}

Counter counter = new Counter();
Counter.VALUE_HANDLE.setVolatile(counter, 42);
int v = (int) Counter.VALUE_HANDLE.getVolatile(counter);

Wann sollte man VarHandle verwenden?

  • Zur Implementierung eigener lock-freier Datenstrukturen.
  • Wenn maximale Performance und Kontrolle über die Instruktionsreihenfolge benötigt werden.
  • In gewöhnlichen Anwendungen reichen meist atomare Klassen und synchronized aus.

6. False Sharing und Cache-Ausrichtung

False Sharing ist eine Situation, in der zwei Threads mit unterschiedlichen Variablen arbeiten, diese Variablen aber in derselben Cache-Line des Prozessors liegen. Infolgedessen stören sich die Threads gegenseitig, weil die Änderung einer Variable zur Invalidierung des Caches für die andere führt.

Analogie: Zwei Personen sitzen an einem Tisch (Cache-Line), aber jede schreibt auf ihrer eigenen Hälfte. Wenn eine etwas ändert, muss die andere das gesamte Blatt erneut „lesen“.

Warum ist das schlecht?

  • Die Performance fällt stark ab: Prozessoren verbringen Zeit mit der Synchronisation ihrer Caches.
  • Besonders kritisch für „heiße“ Variablen, die von verschiedenen Threads häufig geändert werden.

Wie vermeidet man das?

Verteilen Sie „heiße“ Felder auf verschiedene Objekte oder verwenden Sie spezielle Annotationen/Strukturen zur Ausrichtung (z. B. @Contended). In modernen JVMs kann man die Option -XX:-RestrictContended aktivieren und @sun.misc.Contended (Java 8+) zur Ausrichtung von Feldern verwenden.

Beispiel:

@sun.misc.Contended
public volatile long value1;

@sun.misc.Contended
public volatile long value2;

Hinweis: Die Annotation @Contended gehört nicht zum Standard-API, wird aber im JDK zur Optimierung atomarer Klassen verwendet.

7. Praxis: einen Singleton korrigieren und Mini-Benchmarks mit JMH

Einen fehlerhaften Singleton korrigieren

Schlecht (ohne volatile):

class BrokenSingleton {
    private static BrokenSingleton instance;
    public static BrokenSingleton getInstance() {
        if (instance == null) {
            synchronized (BrokenSingleton.class) {
                if (instance == null) {
                    instance = new BrokenSingleton();
                }
            }
        }
        return instance;
    }
}

Gut (mit volatile):

class SafeSingleton {
    private static volatile SafeSingleton instance;
    public static SafeSingleton getInstance() {
        if (instance == null) {
            synchronized (SafeSingleton.class) {
                if (instance == null) {
                    instance = new SafeSingleton();
                }
            }
        }
        return instance;
    }
}

Am besten – statische Initialisierung:

class StaticSingleton {
    private static final StaticSingleton INSTANCE = new StaticSingleton();
    public static StaticSingleton getInstance() { return INSTANCE; }
}

Mini-Benchmarks mit JMH: Sichtbarkeit und Atomizität

Achtung: JMH ist ein spezielles Framework für Mikrobenchmarks in Java. Ziehen Sie keine Performance-Schlüsse ohne JMH – die Ergebnisse können trügerisch sein!

Beispiel: Sichtbarkeit mit volatile

public class VolatileVisibility {
    volatile boolean flag = false;

    public void writer() {
        flag = true;
    }

    public void reader() {
        while (!flag) {
            // Schleife, bis wir true sehen
        }
        // Änderung gesehen
    }
}

Beispiel: volatile ist nicht atomar

public class VolatileNotAtomic {
    volatile int counter = 0;

    public void increment() {
        counter++; // nicht atomar!
    }
}

Trotz volatile ist der Endwert bei gleichzeitiger Inkrementierung aus mehreren Threads kleiner als erwartet (die Operation counter++ zerfällt in Lesen, Berechnen und Schreiben).

8. Typische Fehler im Umgang mit JMM, volatile und Veröffentlichung

Fehler Nr. 1: Von volatile Atomizität erwarten.
volatile garantiert nur die Sichtbarkeit von Änderungen, nicht die Atomizität von Operationen. Die Operation counter++ wird nicht atomar, nur weil die Variable volatile ist.

Fehler Nr. 2: Objekte ohne Synchronisation veröffentlichen.
Wenn Sie ein Objekt in einem Thread erstellen und es ohne volatile, synchronized oder final-Felder an einen anderen übergeben, kann der andere Thread „rohe“ Werte sehen.

Fehler Nr. 3: Double-Checked Locking ohne volatile.
Ohne eine volatile-Referenz auf den Singleton kann in einem anderen Thread ein nicht initialisiertes Objekt beobachtet werden.

Fehler Nr. 4: Alte Sperren mit virtuellen Threads verwenden.
Einige ältere Synchronisationsmechanismen (native monitor) können die JVM daran hindern, virtuelle Threads effizient zu verwalten.

Fehler Nr. 5: False Sharing ignorieren.
Wenn „heiße“ Variablen im Speicher dicht beieinander liegen, stören sich Threads aufgrund von Cache-Lines gegenseitig.

Fehler Nr. 6: Performance-Schlüsse ohne JMH ziehen.
Mikrobenchmarks ohne JMH liefern häufig irreführende Ergebnisse – wegen Optimierungen der JVM und der Prozessor-Caches.

1
Aufgabe
JAVA 25 SELF, Level 58, Lektion 4
Gesperrt
Smart Home: Sichere Veröffentlichung der Konfiguration 🏡
Smart Home: Sichere Veröffentlichung der Konfiguration 🏡
1
Aufgabe
JAVA 25 SELF, Level 58, Lektion 4
Gesperrt
Ressourcenmanager: Der Einzige Im Universum 🌌
Ressourcenmanager: Der Einzige Im Universum 🌌
1
Umfrage/Quiz
Tauchen wir tiefer in Multithreading ein, Level 58, Lektion 4
Nicht verfügbar
Tauchen wir tiefer in Multithreading ein
Tauchen wir tiefer in Multithreading ein
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION