W3docs

Java Synchronized-Methoden und -Blöcke

Synchronized-Methoden und -Blöcke in Java zum Schutz kritischer Abschnitte einsetzen und das richtige Lock-Objekt wählen.

Das vorherige Kapitel hat erklärt, was synchronized bewirkt. Dieses Kapitel behandelt die Syntax — die drei Formen, die das Schlüsselwort annehmen kann, welches Lock jede Form verwendet und wie man die richtige wählt. Die gewählte Form hat Auswirkungen auf Leistung und Korrektheit; „einfach synchronized auf die Methode setzen" funktioniert in einfachen Fällen und versagt, wenn die Klasse wächst.

Drei Formen, drei Lock-Objekte

FormLock-ObjektWann verwenden
synchronized void method()thisSchnelle, kleine Klassen. Öffentliches Lock ist in Ordnung.
synchronized static void method()ClassName.classMutation von Zustand pro Klasse aus beliebiger Instanz.
synchronized (obj) { ... }objFast alles andere. Privates Lock für Sicherheit verwenden.

Die dritte Form ist die flexibelste. Die ersten beiden sind syntaktischer Zucker dafür.

synchronized auf einer Instanzmethode

public synchronized void deposit(int x) {
  balance += x;
}

Wird zu einem Block kompiliert, der auf this lockt. Nur ein Thread darf gleichzeitig irgendeine synchronisierte Instanzmethode auf diesem bestimmten Objekt ausführen. (Verschiedene Account-Instanzen haben unterschiedliche this-Referenzen und daher unterschiedliche Monitore.) Statische Methoden und unsynchronisierte Methoden sind davon unberührt.

Die Falle. this ist Teil der öffentlichen Referenz. Jeder Code, der einen Verweis auf das Objekt hat, kann synchronized (account) { ... } ausführen und denselben Lock wie account.deposit() halten. Dazu gehören Test-Harnesses, Debugger, Framework-Code und jede andere Aufrufstelle, die man nicht kontrolliert. Ein fehlerhafter Aufrufer kann den Lock so lange halten, wie er möchte, und man wird ausgehungert.

In kleinen Klassen ist man der einzige Aufrufer — gut. In Bibliotheken, in Code, den andere Personen nutzen werden, oder in Klassen, die man später refaktorieren könnte, sollte man ein privates Lock-Objekt bevorzugen.

synchronized auf einer statischen Methode

public class Counters {
  private static int total;

  public static synchronized void bump() {
    total++;
  }
}

Wird zu einem Block kompiliert, der auf Counters.class lockt. Der Monitor ist global pro Klasse — jeder Thread, jede Instanz, konkurriert um denselben Lock beim Aufruf von bump(). Derselbe Vorbehalt wie bei this gilt: Jeder andere Code kann auch synchronized (Counters.class) { ... } ausführen und den Lock halten.

Für Zustand pro Klasse ist diese Form in kleinen Hilfsklassen in Ordnung. Für größere bevorzugt man einen privaten statischen Lock:

public class Counters {
  private static final Object LOCK = new Object();
  private static int total;

  public static void bump() {
    synchronized (LOCK) { total++; }
  }
}

synchronized auf einem expliziten Objekt — die Produktionsform

public class Cache {
  private final Object lock = new Object();
  private final Map<String, String> data = new HashMap<>();

  public String get(String k) {
    synchronized (lock) {
      return data.get(k);
    }
  }

  public void put(String k, String v) {
    synchronized (lock) {
      data.put(k, v);
    }
  }
}

Zwei Eigenschaften, die diese Form bietet:

  • Privates Lock. Kein Aufrufer kann es erwerben; niemand kann einen aushungern.
  • Chirurgischer Geltungsbereich. Nur das Innere des Blocks hält den Lock. Alles außerhalb — Argumentvalidierung, Rückgabewertformatierung, Logging — läuft ohne Konflikte.

Aus demselben Grund, aus dem man private final-Felder privat hält, hält man auch den Lock privat. Das Lock-Objekt ist Teil der Implementierung, nicht der Schnittstelle.

Regel: Den kritischen Abschnitt eng halten

Je mehr Code ausgeführt wird, während ein Lock gehalten wird, desto mehr Konflikte entstehen. Das richtige Muster ist, das Minimum Notwendige innerhalb des Blocks zu tun:

// Bad: I/O inside the lock — everybody waits while one thread talks to disk
public synchronized void load(String k) {
  String v = Files.readString(Path.of("/tmp/" + k));         // bad
  cache.put(k, v);
}

// Good: read outside the lock, lock only the mutation
public void load(String k) throws IOException {
  String v = Files.readString(Path.of("/tmp/" + k));
  synchronized (lock) {
    cache.put(k, v);
  }
}

Das allgemeine Prinzip: Nur locken, während der gemeinsame Zustand verändert wird, nie während beliebiger Arbeit, die blockieren könnte.

Zusammengesetzte Aktionen und doppeltes Locken

synchronized schützt einen Block. Wenn zwei Operationen zusammen atomar sein müssen, müssen beide im selben Block sein:

// Wrong: the if and the put are individually synchronised by HashMap... no they're not,
// but even if they were, the gap between them is not.
if (!map.containsKey(k)) {                            // someone else could insert here
  map.put(k, v);
}

// Right: one block protects both ops
synchronized (lock) {
  if (!map.containsKey(k)) {
    map.put(k, v);
  }
}

// Even better: a single atomic operation
map.putIfAbsent(k, v);                                // for ConcurrentHashMap, fully atomic

Der Race-Condition zwischen containsKey und put — bekannt als check-then-act-Race — ist die Quelle mehr Nebenläufigkeitsfehler als das Locken selbst. Immer wenn man if (...) doThing() schreibt, sollte man fragen: Kann zwischen dem if und dem doThing ein anderer Thread die Antwort ändern? Falls ja, atomisieren.

Locks lassen sich nicht kombinieren — Vorsicht bei der Lock-Reihenfolge

Zwei synchronized-Blöcke, die von verschiedenen Threads in unterschiedlicher Reihenfolge erworben werden, können zu einem Deadlock führen:

// Thread A
synchronized (account1) {
  synchronized (account2) { transfer(account1, account2, 100); }
}

// Thread B simultaneously
synchronized (account2) {
  synchronized (account1) { transfer(account2, account1, 100); }
}

Jeder Thread hält einen Lock und wartet auf den anderen. Beide Threads sind für immer BLOCKED. Die Lösung ist konsistente Reihenfolge — Locks immer in derselben globalen Reihenfolge erwerben:

void transfer(Account a, Account b, int x) {
  Account first  = a.id() < b.id() ? a : b;          // ordering by stable key
  Account second = a.id() < b.id() ? b : a;
  synchronized (first) {
    synchronized (second) {
      a.debit(x);
      b.credit(x);
    }
  }
}

Hash-basierte Reihenfolge, System.identityHashCode-basierte Reihenfolge oder ein Tie-Breaker-Lock sind die drei gängigen Ansätze. Das Deadlock-Kapitel behandelt sie ausführlich.

Was ist mit synchronized auf einem primitiven Typ?

Das geht nicht. synchronized erfordert ein Objekt — ein long oder int hat keinen Monitor. Man kann es einboxen (Long/Integer) und syntaktisch darauf locken, aber das sollte man nie tun: Geboxte primitive Typen im Auto-Boxing-Cache werden geteilt. Zwei Code-Stellen, die auf Integer.valueOf(1) locken, locken dasselbe Objekt — auch wenn sie nichts miteinander zu tun haben.

synchronized (Integer.valueOf(1)) {                   // never do this
  ...
}

Für Lock-Objekte sollte man immer ein privates Object allozieren. Der Sinn eines Monitors ist Identität, nicht Wert.

synchronized und Ausnahmen

Wenn der Rumpf eines synchronisierten Blocks eine Ausnahme wirft, wird der Monitor freigegeben, sobald die Ausnahme den Stack abwickelt. Man braucht kein finally für das Entsperren — die JVM kümmert sich darum. Das ist einer der Hauptgründe, warum synchronized schwer zu missbrauchen ist: Es gibt kein „Lock-Leck" wie beim expliziten Lock-API's lock()/unlock() (wo das Entsperren ein separater Methodenaufruf ist, den man in einem finally nicht vergessen darf).

Die Kehrseite: Jeder gemeinsame Zustand, den man innerhalb des Blocks halb verändert hat, ist sichtbar für den nächsten Erwerber. Wenn die Ausnahme die Invarianten bricht, schützt der Lock allein nicht — Invarianten im catch wiederherstellen oder die Mutation so gestalten, dass sie nicht halb abgeschlossen werden kann.

Ein ausgearbeitetes Beispiel: Die vier Formen nebeneinander

Das folgende Programm verwendet jede Form gegen denselben gemeinsamen Zustand und endet mit einem Vergleich nebeneinander.

java— editable, runs on the server

Was man aus dem Lauf mitnehmen kann:

  • Alle drei Zählerformen haben die erwartete Anzahl 800.000 produziert. Jede Form wählte ein anderes Lock-Objekt (this, ein privates Object, die Class), aber jede schützte das Lesen-Modifizieren-Schreiben auf dieselbe Weise. synchronized ist egal, was das Lock-Objekt ist — nur dass jeder konkurrierende Thread dasselbe verwendet.
  • Die statisch-Methoden-Form (V3) verwendete den V3.class-Monitor als Lock. Jeder Thread, jeder Test, jeder andere Code, der auf V3.class synchronisiert, würde um denselben Lock konkurrieren. Das ist angemessen für Zustand pro Klasse; es für Zustand pro Instanz zu verwenden, ist ein Konflikts-Bug — man würde unabhängige Arbeit gegenseitig blocken.
  • Die statischen und Instanz-Methoden-Formen sind bequem, locken aber auf einem öffentlich zugänglichen Objekt (this oder die Class). Jeder kann synchronized (someObject) ausführen und denselben Monitor halten. Die privat-Lock-Objekt-Form (V2) ist das, was Produktionscode verwendet, genau weil niemand außerhalb der Klasse den Lock erreichen kann.
  • Die V4-Klasse (oben definiert, aber nicht gebenchmarkt) zeigt die falsche Form: I/O-ähnliche Arbeit innerhalb des kritischen Abschnitts. Die nächste korrekte Version zieht die Formatierung und den (simulierten) blockierenden Aufruf außerhalb des synchronized-Blocks, sodass der Konflikt nur über das eigentliche put besteht. Dieselbe Korrektheit, mit viel höherem Durchsatz unter Last.
  • Der Doppel-Lock-Block am Ende erwarb zwei unabhängige Locks in der Reihenfolge, die durch System.identityHashCode bestimmt wird. Diese Sortierungsregel, überall im Programm angewendet, ist die einfachste Deadlock-Präventionsstrategie, wenn man zwei Locks gleichzeitig halten muss. Wir werden sie im Kapitel über Deadlocks wiedersehen.

Was kommt als Nächstes

Das nächste Kapitel, Java Inter-Thread-Kommunikation, stellt die andere Hälfte der intrinsischen Monitor-API vor — wait, notify und notifyAll — die Art und Weise, wie Threads sich innerhalb eines kritischen Abschnitts signalisieren.

Übungen

Übung
Man schreibt `public synchronized void deposit(int x)` auf eine Methode von `class Account`. Welchen Monitor erwirbt die Methode?
Man schreibt `public synchronized void deposit(int x)` auf eine Methode von `class Account`. Welchen Monitor erwirbt die Methode?
Was this page helpful?