W3docs

Java ReentrantLock

ReentrantLock in Java: tryLock, lockInterruptibly, Fairness-Policy und die Diagnose-API erklärt.

ReentrantLock ist die Standardimplementierung des Lock-Interface. „Reentrant" bedeutet, dass derselbe Thread die Sperre mehrfach erwerben kann, ohne sich selbst zu blockieren – dieselbe Eigenschaft, die auch synchronized besitzt. Alles, was im Lock-Kapitel beschrieben wurde – tryLock, lockInterruptibly, Condition – wird von dieser einen Klasse bereitgestellt.

Dieses Kapitel geht in die Tiefe: die zwei Konstruktoren, die Fairness-Option, die Diagnosemethoden, die Condition-Kopplung für Producer/Consumer und die konkreten Fälle, in denen ReentrantLock den zusätzlichen try/finally-Boilerplate gegenüber einfachem synchronized rechtfertigt.

Was diese Seite behandelt

Zwei Konstruktoren

Lock lock = new ReentrantLock();        // non-fair (default) — high throughput
Lock fair = new ReentrantLock(true);    // fair — FIFO wait queue

Nicht-fair (der Standard) bedeutet: Wenn die Sperre verfügbar wird, gewinnt der wartende Thread, den der Scheduler zuerst ausführt. Neu ankommende Threads können sich auch „vordrängeln" – sie können die Sperre erwerben, ohne in der Warteschlange zu stehen, sofern sie im Moment ihres Aufrufs frei ist. Das ist schnell: keine Warteschlangenmanipulation, kein Scheduler-Hinweis. Der Nachteil ist die mögliche Verhungerung – ein Thread kann lange in der Warteschlange sitzen, während sich Vordrängler immer wieder die Sperre nehmen.

Fair bedeutet, dass die Sperre dem Thread gewährt wird, der am längsten wartet. Die Warteschlange ist ein echtes FIFO. Dies verhindert Verhungerung. Der Preis: merklich geringerer Durchsatz, weil jede Akquisition eine Scheduler-Entscheidung erfordert und die JVM keine Schnellpfad-Abkürzungen nehmen kann.

Der richtige Standard ist nicht-fair. Verwende fair nur, wenn du ein echtes Verhungerungsproblem festgestellt hast (typischerweise erkannt durch eine getWaitQueueLength-Abfrage, die ständig wächst) oder wenn die Korrektheit der Anwendung von der Verarbeitungsreihenfolge abhängt.

Reentranz und getHoldCount

Wie synchronized kann ein ReentrantLock vom Thread, der ihn bereits hält, erneut erworben werden:

ReentrantLock lock = new ReentrantLock();
lock.lock();
lock.lock();                          // same thread re-enters — fine
try {
  doStuff();
} finally {
  lock.unlock();
  lock.unlock();                      // must unlock as many times as locked
}

Jedes lock() erhöht einen internen Hold-Count; jedes unlock() verringert ihn. Die Sperre wird tatsächlich freigegeben – sichtbar für andere Threads – erst wenn der Count null erreicht. Der Diagnose-Aufruf:

int n = lock.getHoldCount();          // how many times THIS thread has acquired without unlocking

getHoldCount ist nützlich für Assertions („diese Methode muss mit gehaltener Sperre aufgerufen werden") und zum Überprüfen von Invarianten in Tests.

Die passende unlock-Regel ist streng. Wenn du zweimal lock und einmal unlock aufrufst, bleibt die Sperre gehalten – ein stiller Leak. Wenn du öfter unlock als lock aufrufst, wird sofort IllegalMonitorStateException geworfen. Paare Erwerb und Freigabe möglichst in derselben Methode; verteilst du sie über mehrere Methoden, wird die Buchführung schnell fehleranfällig.

Weitere Diagnosemethoden

ReentrantLock bietet einige Introspektion, die synchronized nicht hat:

lock.isLocked();                       // is anybody holding it?
lock.isHeldByCurrentThread();          // do I hold it?
lock.getQueueLength();                 // how many threads are waiting?
lock.hasQueuedThreads();               // are any waiting?
lock.hasQueuedThread(t);               // is thread t waiting?
lock.getHoldCount();                   // how many times have I re-entered?

Diese dienen hauptsächlich zum Monitoring und Testen – Produktionslogik sollte nicht davon abhängen, ob gerade jemand wartet, da die Antwort zum Zeitpunkt der Prüfung bereits veraltet sein kann. Für Metriken ist das „Verhältnis umkämpfter Akquisitionen" jedoch ein nützliches Signal, dass die Sperrgranularität falsch ist.

Wann ReentrantLock synchronized schlägt

Die vier Gründe, ReentrantLock zu wählen:

  1. Zeitbegrenzte Akquisition. Du musst schnell scheitern oder zurückweichen, wenn die Sperre nicht innerhalb einer begrenzten Zeit verfügbar ist. tryLock(timeout) kann das; synchronized nicht.
  2. Unterbrechung. Du musst einen Thread unterbrechen können, der auf die Sperre wartet. lockInterruptibly kann das; synchronized ignoriert Interrupts beim Eintreten des Monitors.
  3. Mehrere Condition-Variablen. Du musst verschiedene Kategorien von Wartern gezielt benachrichtigen. Lock.newCondition() kann das; der intrinsische Monitor hat genau einen Wartesatz.
  4. Fairness. Du brauchst FIFO-Reihenfolge der Warter. new ReentrantLock(true) ist der einzige eingebaute Weg.

Alles außerhalb dieser vier Fälle – solider, typisierter Code ohne besondere Anforderungen – sollte bei synchronized bleiben. Die JVM-Optimierungen für intrinsische Monitore sind real, und die try/finally-Disziplin, die Lock erfordert, kann leicht vergessen werden. Greif nicht nach Lock „weil es moderner ist".

Das Condition-Paar im Detail

Das Producer/Consumer-Muster mit einem ReentrantLock und zwei Conditions ist das klassische Beispiel. Hier in eigenständiger Form, weil es auch die Struktur ist, die ArrayBlockingQueue intern verwendet:

class BoundedBuffer<T> {
  private final ReentrantLock lock = new ReentrantLock();
  private final Condition notFull  = lock.newCondition();
  private final Condition notEmpty = lock.newCondition();
  private final Object[] items;
  private int count, head, tail;

  BoundedBuffer(int cap) { items = new Object[cap]; }

  public void put(T x) throws InterruptedException {
    lock.lock();
    try {
      while (count == items.length) notFull.await();          // release lock, park, re-acquire on wake
      items[tail] = x;
      tail = (tail + 1) % items.length;
      count++;
      notEmpty.signal();                                       // wake exactly one consumer
    } finally { lock.unlock(); }
  }

  @SuppressWarnings("unchecked")
  public T take() throws InterruptedException {
    lock.lock();
    try {
      while (count == 0) notEmpty.await();
      T x = (T) items[head];
      items[head] = null;
      head = (head + 1) % items.length;
      count--;
      notFull.signal();                                        // wake exactly one producer
      return x;
    } finally { lock.unlock(); }
  }
}

Der Vorteil gegenüber wait/notifyAll: Ein Producer weckt genau einen Consumer, nicht jeden wartenden Thread. Bei starker Konkurrenz ist das der Unterschied zwischen notifyAll-Stürmen (jeder Warter wacht auf, kämpft um die Sperre, alle bis auf einen gehen wieder schlafen) und einer sauberen Übergabe.

signal vs. signalAll folgt derselben Regel wie notify vs. notifyAll: Bevorzuge signalAll, es sei denn, du kannst beweisen, dass alle Warter auf dieser Condition austauschbar sind. In diesem Puffer ist jeder Warter auf notEmpty ein Consumer, der einen Slot möchte – sie sind austauschbar; signal ist sicher.

Der CAS-Schleifen-/Monitor-Kompromiss

Eine häufige Frage: Wann gewinnt eine atomare Variable wie AtomicInteger gegenüber einem ReentrantLock? Grob gesagt:

  • Für ein einzelnes Feld mit einer einfachen Aktualisierung gewinnen Atomics – sie sind CAS-Instruktionen, kein Kernel-Aufruf, kein Parken. AtomicInteger.incrementAndGet ist schneller als ReentrantLock.lock + int++ + unlock.
  • Für mehrere Felder, die zusammen aktualisiert werden müssen oder für Blocking-Semantik (warten, bis eine Warteschlange nicht leer ist), gewinnt die Sperre – du kannst die Arbeit gruppieren und darüber signalisieren.

Eine schreibgeschützte Prüfung wie „ist der Cache gültig?" ist volatile; ein Inkrement ist atomic; ein „tausche ein Element gegen ein anderes in einer Warteschlange" ist eine Sperre. Verwende das leichteste Werkzeug, das die Arbeit erfordert.

tryLock für deadlock-freie Komposition

Das einfachste Muster zum Kombinieren zweier Sperren ohne Reihenfolge:

boolean done = false;
while (!done) {
  lockA.lock();
  try {
    if (lockB.tryLock(50, TimeUnit.MILLISECONDS)) {           // bounded wait for the second lock
      try {
        doCriticalWork();
        done = true;
      } finally { lockB.unlock(); }
    }
    // else: couldn't get lockB in time — fall through, release lockA, retry
  } finally {
    lockA.unlock();                                           // always release lockA before looping
  }
}

Wenn tryLock auf lockB abläuft, gibt das finally lockA frei und die Schleife versucht es von vorne. Da kein Thread jemals eine Sperre hält, während er für immer auf eine andere wartet, ist die klassische Hold-and-Wait-Deadlock-Bedingung aufgebrochen.

Dies vermeidet die Deadlock-Falle durch Reihenfolge, ohne eine globale Sperrreihenfolge zu benötigen. Der Kompromiss ist mehr Code, Livelock-Potenzial bei starker Konkurrenz und schlechteres Cache-Verhalten. Verwende es für bereichsübergreifende Sperren (Sperren auf Objekten, die du nicht geschrieben hast); bevorzuge eine feste Sperr-Erwerbsreihenfolge, wenn du beide Seiten kontrollierst.

Ein Praxisbeispiel: Konkurrenz, Fairness und Reentranz

Das folgende Programm vergleicht einen fairen und einen nicht-fairen ReentrantLock unter hoher Konkurrenz, demonstriert dann Reentranz und Hold-Count-Buchführung.

java— editable, runs on the server

Was aus dem Lauf zu entnehmen ist:

  • Die nicht-faire Sperre verteilte den gemeinsamen Pool an Akquisitionen ungleichmäßig – der gemeldete spread = max-min war groß (oft mehrere Tausend von 50.000 pro Thread). Das ist der Schnellpfad in Aktion: Die JVM erzwingt keine Reihenfolge, sodass ein Thread, der die Sperre gerade freigegeben hat, sie sofort wieder erwerben und gewinnen kann, bevor ein in der Warteschlange stehender Thread geplant wird.
  • Die faire Sperre verteilte Akquisitionen fast gleichmäßig – ihre Streuung war ein kleiner Bruchteil der nicht-fairen Streuung, weil die FIFO-Warteschlange jedem Thread seinen Turn gibt. Die gesamte Wanduhrzeit war merklich länger. Faire Reihenfolge tauscht Durchsatz gegen vorhersehbaren Fortschritt. Zahle diesen Preis nur, wenn du ein Verhungerungsproblem gemessen hast. (Genaue Zahlen variieren je Lauf und Maschine; stabil ist, dass die nicht-faire Streuung weit größer als die faire ist.)
  • Der Reentranz-Abschnitt zeigte, wie der Hold-Count mit jedem lock/unlock stieg und fiel. Die Sperre wird tatsächlich erst freigegeben, wenn der Count auf null fällt; bis dahin bleiben andere Threads, die darauf warten, BLOCKED. Das sind identische Semantiken wie bei synchronized; der Unterschied ist, dass ReentrantLock den Count zur Inspektion freigibt.
  • Das zusätzliche unlock() nach dem Erreichen von Hold-Count null warf sofort IllegalMonitorStateException – es gibt kein stilles „Doppel-Unlock", das erfolgreich ist. Die JVM erzwingt damit die Invariante der Sperre: Nur der Halter kann freigeben, und nur so oft, wie er erworben hat.
  • Das getQueueLength-Ergebnis von 3 bestätigte, dass die drei wartenden Threads wirklich hinter uns in der Warteschlange eingereiht waren. In der Produktion ist diese Methode nützlich für Alarme „baut sich Konkurrenz auf?" – eine Warteschlangenlänge, die über die Zeit wächst, ist ein Zeichen, dass die Arbeit innerhalb der Sperre zu langsam ist.

Was als Nächstes kommt

Das nächste Kapitel, Java ReadWriteLock, behandelt ReentrantReadWriteLock – die Sperre, die Akquisitionen in „viele Leser ODER ein Schreiber" aufteilt, für leselastige Workloads, bei denen exklusive Sperren übertrieben sind.

Übungen

Übung
Du rufst `lock.lock()` zweimal aus demselben Thread auf einem `ReentrantLock` auf und dann einmal `lock.unlock()`. Ist die Sperre freigegeben, sodass andere Threads sie erwerben können?
Du rufst `lock.lock()` zweimal aus demselben Thread auf einem `ReentrantLock` auf und dann einmal `lock.unlock()`. Ist die Sperre freigegeben, sodass andere Threads sie erwerben können?
Was this page helpful?