W3docs

Java ReadWriteLock

Gleichzeitige Lesezugriffe und exklusive Schreibzugriffe in Java mit ReadWriteLock — und wann StampedLock die bessere Wahl ist.

Ein ReentrantLock (oder ein synchronized-Block) gewährt einem Thread exklusiven Zugriff — Leser und Schreiber teilen denselben Slot. Bei leselastigen Workloads ist das verschwenderisch: Wenn hundert Threads einen Wert lesen möchten und einer schreiben will, gibt es unter den Lesern keinen echten Konflikt, sondern nur zwischen Lesern und dem Schreiber. Das Interface ReadWriteLock und seine Standardimplementierung ReentrantReadWriteLock teilen die Sperre in zwei Teile auf — ein Read Lock, das mehrere Threads gleichzeitig halten können, und ein Write Lock, das gegenüber allem exklusiv ist. Korrekt eingesetzt reduziert es die Contention erheblich. Falsch eingesetzt ist es langsamer als eine einfache Sperre.

Das Interface

public interface ReadWriteLock {
  Lock readLock();
  Lock writeLock();
}

Zwei Locks, fest miteinander durch ihren übergeordneten Typ verbunden: Das Read Lock und das Write Lock befolgen die Regel, dass entweder das Write Lock von genau einem Thread gehalten wird oder das Read Lock von null oder mehr Threads gehalten wird. Niemals beides, niemals eines von jedem.

Die Standardimplementierung:

ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
Lock r = rw.readLock();
Lock w = rw.writeLock();

Beide Sperren haben die Standard-Lock-API — lock, tryLock, lockInterruptibly, unlock. Der Vertrag für das rw-Paar lautet:

  • Viele Threads können r gleichzeitig halten. Keiner von ihnen blockiert den anderen.
  • Nur ein Thread kann w gleichzeitig halten.
  • Ein Thread, der w anfordern möchte, wartet, bis alle aktuellen Leser freigeben.
  • Threads, die r anfordern möchten, warten, wenn w gehalten wird oder (je nach Richtlinie) wenn bereits ein Schreiber in der Warteschlange steht.

Der letzte Punkt ist die Write-Starvation-Prevention-Richtlinie — weiter unten erläutert.

Der Cache-Anwendungsfall

Das klassische Beispiel. Ein Cache, der ständig gelesen und gelegentlich aktualisiert wird:

class ConfigCache {
  private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
  private final Lock r = rw.readLock();
  private final Lock w = rw.writeLock();
  private Map<String, String> data = new HashMap<>();

  public String get(String k) {
    r.lock();
    try {
      return data.get(k);                               // many readers, no contention
    } finally { r.unlock(); }
  }

  public void reload(Map<String, String> fresh) {
    w.lock();
    try {
      data = new HashMap<>(fresh);                       // exclusive: blocks readers and other writers
    } finally { w.unlock(); }
  }
}

Bei einem typischen Workload mit 99 % Lesezugriffen skaliert das deutlich besser als ein einzelnes ReentrantLock. Leser blockieren sich nicht gegenseitig; der seltene Schreiber hält die Welt kurz an, dann läuft alles weiter.

Dieselbe try/finally-Disziplin wie bei Lock gilt — jedes lock() muss mit einem unlock() in einem finally gepaart werden. Das Read Lock ist bei Lecks genauso unnachgiebig wie das Write Lock.

Fairness und Write Starvation

ReentrantReadWriteLock hat zwei Richtlinien:

new ReentrantReadWriteLock();          // non-fair (default)
new ReentrantReadWriteLock(true);      // fair (FIFO)

Die standardmäßige Non-Fair-Richtlinie erlaubt es neuen eingehenden Lesern, die Sperre zu erwerben, auch wenn bereits ein Schreiber wartet — hoher Durchsatz, aber Schreiber können bei kontinuierlicher Leselast verhungern. Die faire Richtlinie reiht jeden Anfragenden in FIFO-Reihenfolge ein: Ein wartender Schreiber blockiert nachfolgende Leser, und die Leser warten an der Reihe.

Non-Fair ist noch immer der richtige Standard. Wenn Sie beobachten, dass Schreiber in der Produktion ewig in der Warteschlange sitzen (eines der Dinge, die getQueueLength aufdeckt), wechseln Sie zu Fair.

Es gibt auch einen subtileren Schutz. Selbst im Non-Fair-Modus werden eingehende Leser blockiert, wenn ein Schreiber "als nächstes in der Reihe" steht (am Kopf der Warteschlange). Das verhindert die schlimmste Form von Starvation; neue Leser können sich trotzdem vordrängeln, wenn kein Schreiber in der Warteschlange steht.

Lock-Downgrading: Write → Read

Ein nützlicher Trick: Man kann das Write Lock halten, das Read Lock ebenfalls anfordern und dann das Write Lock freigeben — ohne jemals einen anderen Schreiber hereinzulassen. Das nennt sich Downgrading:

w.lock();
try {
  data = recompute();                                   // exclusive write
  r.lock();                                              // before releasing w
} finally { w.unlock(); }
// now holding only r — readers can join, but no writer can sneak in until I release r
try {
  process(data);                                         // read-only work, multiple threads can do it
} finally { r.unlock(); }

Der Zweck des Downgradings: Die eigentliche Mutation unter dem Write Lock durchführen, dann das Ergebnis lesen, ohne andere vom Zugriff auf die Daten auszuschließen. Das zwischengeschaltete "Read Lock anfordern, während Write Lock gehalten wird" funktioniert, weil die Sperre das erlaubt — man "upgradet" die Reservierung des Write Locks von "exklusiv" auf "geteilt, aber man selbst darf noch zugreifen."

Das Gegenteil — Upgrading Read → Write — funktioniert nicht. Der Versuch, w anzufordern, während man r hält, führt zum Deadlock: Das Write Lock wartet, bis alle Leser freigeben, und man selbst ist einer davon. Die Sperre blockiert einen für immer, während man auf sich selbst wartet.

r.lock();
try {
  if (needsRefresh()) {
    w.lock();                                            // DEADLOCK on the same thread
    ...
  }
} finally { r.unlock(); }

Um von Read → Write zu wechseln, muss man zuerst das Read Lock freigeben, dann das Write Lock anfordern und die Bedingung erneut prüfen (jemand anderes könnte aktualisiert haben, während man entsperrt war).

Wann ReadWriteLock besser ist als ReentrantLock

Eine grobe Faustregel. ReentrantReadWriteLock gewinnt, wenn:

  • Lesezugriffe Schreibzugriffe bei weitem überwiegen (z. B. 100:1 oder mehr).
  • Der lesegeschützte Abschnitt nicht trivial ist — lang genug, dass das gleichzeitige Ausführen durch viele Threads einen echten Gewinn bringt.
  • Der Schreibvorgang ebenfalls lang genug ist, dass das kurzzeitige Blockieren von Lesern akzeptabel ist.

Es verliert (oder bricht gleich ab), wenn:

  • Lesevorgänge extrem kurz sind (eine einzelne Map-Abfrage). Der Overhead der Sperranforderung ist vergleichbar mit der eigentlichen Arbeit; ein einfaches ReentrantLock oder ein unveränderlicher Snapshot über AtomicReference wären besser.
  • Das Leser/Schreiber-Verhältnis nicht extrem ist.
  • Es viele Threads gibt. Die interne Buchhaltung, die das Read/Write Lock durchführt, um Leser zu zählen, wird teurer, wenn sie skaliert. Für sehr leselastige Datenstrukturen auf vielen Kernen ist StampedLock oder Copy-on-Write normalerweise die bessere Wahl.

StampedLock — die moderne Alternative

Java 8 hat java.util.concurrent.locks.StampedLock mit drei Modi eingeführt — Write, Read und Optimistic Read. Der Optimistic-Modus erlaubt es einem Leser, fortzufahren, ohne eine Sperre anzufordern; nach dem Lesen überprüft er, ob der Wert sich über einen Stamp geändert hat. Falls ja, fällt der Leser auf eine ordentliche Read-Lock-Anforderung zurück.

StampedLock sl = new StampedLock();
long stamp = sl.tryOptimisticRead();
String val = data.get(k);                                // read without locking
if (!sl.validate(stamp)) {                                // somebody wrote during our read
  stamp = sl.readLock();
  try {
    val = data.get(k);                                    // re-read under proper lock
  } finally { sl.unlockRead(stamp); }
}

Für lesedominierende Workloads ist StampedLock normalerweise schneller als ReentrantReadWriteLock. Der Preis: Es ist nicht reentrant, unterstützt kein Condition, und die API ist viel leichter falsch zu verwenden. Greifen Sie darauf zurück, wenn ein Profiler auf ein ReadWriteLock zeigt; verwenden Sie standardmäßig ReentrantReadWriteLock für bessere Ergonomie.

Ein ausgearbeitetes Beispiel: leselastiger Cache, drei Konkurrenten

Das folgende Programm vergleicht drei Implementierungen desselben leselastigen Caches unter 16 Lesern und 2 Schreibern: eine synchronized-Map, eine mit ReentrantLock gesicherte Map und eine mit ReentrantReadWriteLock gesicherte Map.

java— editable, runs on the server

Was aus dem Lauf zu lernen ist — und das Ergebnis ist wahrscheinlich nicht das, was man erwarten würde:

  • Der kritische Abschnitt hier ist ein einzelnes HashMap.get — ein paar Nanosekunden. Bei dieser Größe verliert ReentrantReadWriteLock tatsächlich gegen ein einfaches ReentrantLock (und gegen synchronized). In einem typischen Durchlauf macht das rwlock weniger Lesevorgänge, nicht mehr, weil die Arbeit innerhalb der Sperre durch die Kosten für das Anfordern der Sperre in den Schatten gestellt wird. Das ist die wichtigste Erkenntnis: Ein Read-Write-Lock ist kein kostenloses Upgrade.
  • Warum es hier verliert: r.lock() muss atomar einen gemeinsamen Leserzähler erhöhen, und dieser umkämpfte Zähler wird zum neuen Engpass. Sechzehn Kerne, die alle auf ein AtomicInteger-ähnliches Feld hämmern, verursachen Cache-Bounce genauso schlimm wie bei einem einzelnen Mutex — manchmal schlimmer, weil die Buchhaltung des rwlocks schwerer ist als die einer einfachen Sperre. Der theoretische Gewinn "Leser blockieren sich nicht gegenseitig" materialisiert sich nie, wenn der Lesevorgang zu kurz ist, um sich sinnvoll zu überlappen.
  • Das rwlock zieht nur dann voran, wenn jeder Lesevorgang die Sperre lange genug hält, damit sich echte Überlappung auszahlt — denken Sie an eine Suche, eine Deserialisierung oder alles im Bereich von Mikrosekunden und darüber, nicht eine einzelne Map-Abfrage. Ersetzen Sie den Body von get() durch echte aufwändige Read-only-Arbeit und führen Sie es erneut aus: Jetzt gewinnen die gleichzeitigen Leser entschieden. Profilieren Sie Ihren echten Workload, bevor Sie zum ReadWriteLock greifen.
  • Die Kosten von ReadWriteLock sind mehr Zustand zum Verwalten (ein Leserzähler, ein Schreiber-Warte-Flag, die Fairness-Richtlinie) — jedes r.lock() ist teurer als ein ReentrantLock.lock(). Bei geringer Contention oder sehr kurzen kritischen Abschnitten ist die einfachere Sperre schneller. Bei extrem leselastiger Last auf vielen Kernen schlägt StampedLock (Optimistic Reads fordern gar nichts an) oder ein unveränderlicher Snapshot hinter einer AtomicReference normalerweise beide.
  • Welche Sperre auch gewählt wird — die Schreiber erhalten weiterhin exklusiven Zugriff über den Schreibpfad und beschädigen die Map nie — die Korrektheit ist bei allen dreien gleich. Der Unterschied ist reiner Durchsatz, und der Durchsatz hängt vollständig davon ab, was sich innerhalb der Sperre befindet.
  • Downgrading (wrw freigeben) ist das, was Produktionscode verwendet, wenn das Neuaufbauen unter einem Write Lock erfolgen muss, der Rest der Anfrage aber unter einem Read Lock bleiben kann. Das Upgrading in die andere Richtung führt zum Deadlock; zuerst r freigeben, w nehmen, den Zustand erneut prüfen, dann fortfahren.

Was kommt als Nächstes

Das nächste Kapitel, Java Thread Pools, beginnt die Geschichte des übergeordneten Executor-Frameworks — die Idee, dass man aufhört, Threads manuell zu erstellen, und stattdessen Arbeit an einen Pool übergibt, der die Threads für einen verwaltet.

Übungen

Übung
Sie halten das Read Lock eines `ReentrantReadWriteLock` und entscheiden sich, auf das Write Lock zu upgraden, indem Sie `w.lock()` aufrufen, während Sie noch `r` halten. Was passiert?
Sie halten das Read Lock eines `ReentrantReadWriteLock` und entscheiden sich, auf das Write Lock zu upgraden, indem Sie `w.lock()` aufrufen, während Sie noch `r` halten. Was passiert?
Was this page helpful?