W3docs

Java Synchronisierung

Thread-Zugriff auf gemeinsamen Zustand in Java mit dem Schlüsselwort synchronized und intrinsischen Sperren koordinieren.

Die Multithreading-Einführung warnte vor drei Fehlerarten — Wettlaufbedingungen, Sichtbarkeitsfehler und Deadlocks. synchronized ist Javas erste Antwort auf die ersten beiden. Es gibt einem Codeblock gleichzeitig zwei Garantien: gegenseitiger Ausschluss (nur ein Thread darf sich zu einem Zeitpunkt darin befinden) und Speichersichtbarkeit (Schreibvorgänge, die ein Thread innerhalb des Blocks ausführt, sind für den nächsten Thread sichtbar, der ihn betritt). Diese beiden Garantien zusammen reichen aus, um eine große Menge von Multithread-Code korrekt zu machen.

Dieses Kapitel ist das konzeptionelle — was synchronized macht, was ein intrinsischer Monitor ist, welche Arten von Wettlaufbedingungen es behebt und welche nicht. Das nächste Kapitel, synchronized blocks, zeigt die syntaktischen Formen und wie man zwischen ihnen wählt.

Die Wettlaufbedingung, für die das Schlüsselwort existiert

class Counter {
  int n;
  void increment() { n++; }
}

Counter c = new Counter();
// Thread A and Thread B both call c.increment() a million times.
// After both finish, what is c.n?

n++ ist eine Quellzeile und drei Bytecode-Operationen: n laden, 1 addieren, n speichern. Wenn Thread A n=42 lädt, dann Thread B n=42 lädt, bevor A speichert, addieren beide 1 und speichern beide 43. Ein Inkrement geht verloren. Führt man das Programm aus, wobei jeder Thread eine Million Mal läuft, ist c.n konsistent kleiner als 2_000_000.

synchronized ist die Lösung:

class Counter {
  int n;
  synchronized void increment() { n++; }
}

Jetzt führt nur ein Thread gleichzeitig increment auf diesem Counter aus. Der andere wartet vor der Tür. Ergebnis: c.n == 2_000_000, bei jedem Durchlauf.

Was ein Monitor ist

Jedes Java-Objekt hat im JVM verborgen eine zugehörige Sperre, den sogenannten intrinsischen Monitor (oder Monitor-Lock). Es ist lediglich eine long-artige Datenstruktur mit zwei Zustandsteilen: einem besitzenden Thread (oder null) und einer Warteschlange. Ein Thread, der einen synchronized-Block betritt:

  1. Versucht den Monitor des Objekts zu erwerben, auf das synchronized zeigt.
  2. Wenn der Monitor unbesessen ist, nimmt er ihn (jetzt owner == self) und macht weiter.
  3. Wenn der Monitor einem anderen Thread gehört, wechselt dieser Thread in den Zustand BLOCKED und reiht sich in die Warteschlange ein.
  4. Wenn der Besitzer den Block verlässt, gibt das JVM den Monitor frei und einer der Wartenden gewinnt ihn.

Der Monitor gehört zum Objekt. Zwei Counter-Instanzen haben zwei separate Monitore; Threads, die auf verschiedenen Counter-Instanzen arbeiten, blockieren sich nicht gegenseitig. Das ist wichtig — die Synchronisierung gilt für das Objekt, nicht für "die Methode."

synchronized (someObject) {
  // critical section: only one thread at a time
  // holds someObject's monitor inside this block
}

synchronized bei einer Instanzmethode ist syntaktischer Zucker für synchronized (this). Bei einer statischen Methode ist es Zucker für synchronized (Counter.class) — den Monitor des Class-Objekts.

Sichtbarkeit, nicht nur Ausschluss

Gegenseitiger Ausschluss ist der offensichtliche Teil. Der weniger offensichtliche — und wichtigere — Teil ist die happens-before-Beziehung, die das JVM kostenlos bietet:

Alles, was ein Thread tut, bevor er einen Monitor freigibt, ist garantiert für jeden Thread sichtbar, der danach denselben Monitor erwirbt.

Dieser Satz macht synchronized korrekt und nicht nur "Wer zuerst kommt, mahlt zuerst." Ohne ihn können zwei Threads einen synchronized-Block verwenden, gegenseitigen Ausschluss einhalten und trotzdem gegenseitige Schreibvorgänge in der falschen Reihenfolge sehen — weil CPU-Caches und der JIT ansonsten frei umsortieren dürfen. Das Release/Acquire-Paar setzt eine Speicherbarriere, die CPU und JIT zwingt, zu leeren und neu zu laden.

Die Konsequenz: Jedes Feld, das ein Multithread-Programm außerhalb eines synchronized-Blocks liest oder schreibt (und nicht über volatile, ein Atomic oder ein anderes java.util.concurrent-Primitiv), hat keine Sichtbarkeitsgarantie. Ein Thread kann done = true schreiben und ein anderer Thread kann für immer done = false sehen. Wir kommen darauf zurück, wenn wir volatile und das Java-Speichermodell behandeln.

Was synchronized nicht behebt

Vier Dinge, die Neulinge oft von synchronized erwarten, die es aber nicht liefert:

  1. Es sperrt keine Daten. synchronized (list) verhindert nicht, dass anderer Code list berührt; es verhindert, dass ein anderer Thread denselben Monitor hält. Wenn ein anderer Codepfad list betreibt, ohne denselben Monitor zu erwerben, ist der Schutz aufgehoben.
  2. Es komponiert nicht über Objekte. synchronized (a); synchronized (b); sind zwei separate Erwerbungen; wenn ein anderer Thread sie in umgekehrter Reihenfolge erwirbt, entsteht ein Deadlock.
  3. Es beschleunigt nichts. Sperren sind reiner Overhead. Verwende sie nur, wo Korrektheit es erfordert.
  4. Es behebt nicht alle Wettlaufbedingungen. Zusammengesetzte Aktionen wie "prüfen dann handeln" haben immer noch Wettlaufbedingungen, selbst wenn jede einzelne Operation synchronisiert ist. if (map.containsKey(k)) map.put(k, v) ist fehlerhaft, selbst wenn containsKey und put einzeln thread-sicher sind — die Lücke zwischen den beiden Aufrufen ist ungeschützt. Verwende putIfAbsent oder einen einzigen synchronisierten Block um beide.

Wiedereintritt (Reentrancy)

Der intrinsische Monitor ist wiedereintrittsfähig: Ein Thread, der bereits einen Monitor hält, kann einen weiteren synchronized-Block auf demselben Objekt betreten, ohne sich selbst zu blockieren. Deshalb funktioniert das:

class Account {
  synchronized void deposit(int x) { balance += x; }
  synchronized void transferTo(Account other, int x) {
    deposit(-x);                                     // re-enters same monitor — fine
    other.deposit(x);                                // acquires other's monitor too
  }
}

Wären Monitore nicht wiedereintrittsfähig, würde der innere Aufruf von deposit am Monitor blockieren, den der äußere Aufruf bereits hält — sofortiger Selbst-Deadlock. Die Wiedereintrittsfähigkeit macht es sicher, eine andere synchronisierte Methode auf demselben Objekt aufzurufen.

Die Kehrseite: Jeder Erwerb benötigt eine entsprechende Freigabe. Das JVM führt einen Zähler; der Monitor wird freigegeben, wenn der Zähler auf null fällt.

Worauf synchronisiert werden soll

Einige Regeln, die die meisten Fehler bei der Sperrenverwendung verhindern:

  • Synchronisiere auf ein privates Sperrobjekt, nicht auf this. Externer Code kann ebenfalls synchronized (yourInstance) verwenden; das erlaubt einem Aufrufer, deine Sperre so lange zu halten, wie er möchte. Ein privates final Object lock = new Object(); gehört dir allein und niemand sonst kann es sich greifen.
  • Synchronisiere nicht auf String-Literale oder geboxt primitive Typen. Sie werden intern zwischengespeichert; zwei synchronized ("foo")-Blöcke an verschiedenen Stellen des Codes teilen einen Monitor mit jedem anderen, der ebenfalls "foo" gesagt hat.
  • Synchronisiere nicht auf eine Referenz, die sich ändern kann. synchronized (myField), wo myField neu zugewiesen werden kann, sind im Laufe der Zeit zwei verschiedene Monitore. Der Compiler kann das nicht erkennen; der Fehler ist still.
  • Halte den kritischen Abschnitt klein. Je mehr du innerhalb eines synchronized-Blocks tust, desto länger warten alle anderen. Halte die Sperre, während du den gemeinsamen Zustand änderst, nicht während du die umgebenden I/O-Operationen ausführst.

Ein ausgearbeitetes Beispiel: mit und ohne Sperre

Das folgende Programm führt dieselbe gemeinsame Zähler-Arbeitslast auf drei Arten aus: ohne Synchronisierung, mit synchronized-Methode und mit synchronized-Block auf einem dedizierten Sperrobjekt. Die Zahlen zeigen, dass die erste Form Aktualisierungen verliert, die anderen beiden jedoch nicht.

java— editable, runs on the server

Was aus dem Durchlauf zu entnehmen ist:

  • Die unsafe-Zeile verlor konsistent Aktualisierungen — der Endwert lag irgendwo unter dem erwarteten 1_000_000. Zwei Threads, die n++ ausführen, haben eine Wettlaufbedingung bei Lesen-Modifizieren-Schreiben; manche Inkremente verschwinden. Selbst wenn der Test bei einem einzigen Durchlauf zufällig besteht, werden der JIT, der OS-Scheduler oder eine andere CPU es irgendwann aufdecken. Unsynchronisierte Mutation eines gemeinsamen Felds ist fehlerhaft.
  • Beide sicheren Varianten erzeugten jedes Mal die exakt erwartete Zählung. Gegenseitiger Ausschluss ist der offensichtliche Teil dessen, was synchronized tut; der weniger sichtbare Teil ist, dass der Wert, den value() liest, der neueste von increment geschriebene ist — das ist die Sichtbarkeitsgarantie. Ohne das Monitor-Paar könnte der Lesevorgang legitimerweise eine veraltete gecachte Kopie sehen.
  • Die Wanduhrzeit für sync method und sync block war beide deutlich höher als unsafe. Sperren sind nicht kostenlos — jeder Ein-/Austritt setzt eine Speicherbarriere und (unter Konkurrenz) einen Thread-Kontextwechsel. Synchronisiere, wo Korrektheit es erfordert; streue nicht aus "Sicherheitsgründen."
  • Die Variante sync block on private lock ist das, was Produktionscode verwendet. Die sync method-Form sperrt auf this, das jeder externe Aufrufer ebenfalls erwerben kann — er kann dich aushungern, indem er deine eigene Sperre hält. Ein privates Sperrobjekt, das du niemals freigibst, gehört dir allein.
  • Der Wiedereintrittsblock lief ohne Deadlock. outer() hielt bereits den Monitor von this; inner() betrat ihn erneut, ohne zu blockieren. Deshalb kann eine synchronisierte Methode frei eine andere synchronisierte Methode auf demselben Objekt aufrufen — ohne Wiedereintritt würde die halbe Standardbibliothek in einem Deadlock enden.

Was als nächstes kommt

Das nächste Kapitel, Java Synchronized Blocks, vertieft die syntaktischen Formen — Methode, Block, statisch — und die Regeln zur Wahl des richtigen Sperrobjekts. Für die übergeordnete Koordination zwischen Threads, siehe Inter-Thread-Kommunikation (wait/notify).

Übungen

Übung
Zwei Threads rufen jeweils `counter.increment()` auf einem `Counter` auf, dessen `n`-Feld ein unsynchronisiertes `int` ist. Nachdem beide 1.000.000 Inkremente abgeschlossen haben, was zeigt `counter.n` typischerweise?
Zwei Threads rufen jeweils `counter.increment()` auf einem `Counter` auf, dessen `n`-Feld ein unsynchronisiertes `int` ist. Nachdem beide 1.000.000 Inkremente abgeschlossen haben, was zeigt `counter.n` typischerweise?
Was this page helpful?