W3docs

Java Inter-Thread-Kommunikation

Java-Threads mit wait, notify und notifyAll über gemeinsame Monitor-Locks koordinieren – und wann höhere Abstraktionen vorzuziehen sind.

Gegenseitiger Ausschluss sorgt für einen sicheren gemeinsamen Zustand. Er ermöglicht es einem Thread jedoch nicht, einem anderen zu signalisieren, dass sich der Zustand geändert hat. Genau dafür ist das Trio wait, notify und notifyAll auf java.lang.Object gedacht. Sie sind das elementarste Koordinationsprimitive, das Java bereitstellt – jeder übergeordnete Mechanismus (Blocking Queues, Latches, Semaphoren, Condition) baut auf dieser Idee auf: Ein Thread wartet innerhalb eines Monitors, bis ein anderer Thread ihn aufweckt.

Moderner Code ruft wait/notify selten direkt auf. Stattdessen verwendet man BlockingQueue, CountDownLatch oder Condition. Dennoch muss man den zugrunde liegenden Mechanismus kennen, weil (a) er intern von all diesen Klassen verwendet wird, (b) er in jeder Bibliothek auftaucht, die man jemals liest, und (c) die Diagnose bei Problemen mit High-Level-Code häufig bis zu einem vergessenen notify zurückverfolgt werden muss.

Das Trio

Definiert auf java.lang.Object, sodass jedes Objekt sie besitzt:

void wait() throws InterruptedException;
void wait(long timeoutMillis) throws InterruptedException;
void notify();
void notifyAll();

Die wichtigste Regel: Diese Methoden können nur aufgerufen werden, während man den Monitor des Objekts besitzt, auf dem man sie aufruft. Das bedeutet, man muss sich innerhalb eines synchronized (obj) { ... }-Blocks befinden (oder einer synchronized-Methode, die denselben obj sperrt). Der Aufruf von obj.wait() ohne den Monitor von obj zu halten, wirft sofort eine IllegalMonitorStateException.

synchronized (lock) {
  lock.wait();                                  // ok — we hold lock
  lock.notify();                                // ok — same
}
lock.wait();                                    // IllegalMonitorStateException

Diese Regel macht die API funktionsfähig: Das Warten und das Benachrichtigen sind garantiert mit gehaltener Sperre zu geschehen, sodass der Zustand, über den sie sprechen, konsistent ist.

Was wait() tatsächlich tut

wait() ist kein "Schlafen". Es führt atomisch drei Dinge aus:

  1. Gibt den Monitor des Objekts frei, auf dem es aufgerufen wurde.
  2. Parkt den aktuellen Thread in der Warteschlange dieses Monitors.
  3. Wenn aufgeweckt (durch notify/notifyAll/interrupt/Timeout) re-akquiriert es den Monitor, bevor es zurückkehrt.

Der "atomisch freigibt und parkt"-Teil macht wait sicher: Ein notify, das zwischen "wir haben beschlossen zu warten" und "wir haben tatsächlich angefangen zu warten" eintrifft, würde sonst verloren gehen. Bei wait existiert diese Lücke nicht.

Nach der Rückkehr von wait() befindet man sich wieder innerhalb des synchronized-Blocks mit gehaltener Sperre – deshalb kann der Code nach wait() den gemeinsamen Zustand sicher lesen.

Was notify() und notifyAll() tun

notify() wählt einen Thread (welchen, bestimmt die JVM – typischerweise nicht FIFO) aus der Warteschlange aus und verschiebt ihn von WAITING/TIMED_WAITING nach BLOCKED. Der benachrichtigte Thread wartet immer noch auf den Monitor; der Benachrichtiger hält den Monitor immer noch. Der benachrichtigte Thread kann ihn nur re-akquirieren, wenn der Benachrichtiger den synchronized-Block verlässt.

notifyAll() weckt jeden Thread in der Warteschlange auf dieselbe Weise. Sie alle werden BLOCKED; sie alle reihen sich für die Sperre an; sie re-akquirieren nacheinander, sobald die Sperre verfügbar wird.

notify ist schneller (ein Thread wird geweckt), aber gefährlich: Wenn man den falschen Thread aufweckt (einen, dessen Bedingung gar nicht erfüllt ist), kehrt dieser zu wait() zurück und nichts Nützliches passiert. notifyAll ist sicherer (ein Wartender, der Fortschritte machen kann, wird es tun), aber teurer. Standardmäßig notifyAll verwenden; auf notify wechseln nur dann, wenn alle Wartenden nachweislich austauschbar sind.

Das obligatorische while-Schleifen-Muster

Die einzeln wichtigste Regel bei wait:

wait() immer innerhalb einer while-Schleife aufrufen, die die Bedingung erneut prüft.

synchronized (lock) {
  while (!conditionHolds()) {
    lock.wait();
  }
  // now condition holds AND we own the lock
}

Drei Gründe für die Schleife statt eines if:

  1. Spurious wakeups. Die JVM darf einen wait ohne jeglichen Grund aufwecken. Die Schleife fängt diese ab.
  2. notifyAll weckt mehr als einen. Wenn alle um die Sperre wetteifern, hat der Gewinner möglicherweise nichts Nützliches zu tun – jemand anderes hat die Ressource bereits verbraucht. Die Schleife schickt ihn zurück zu wait.
  3. Anderer Zustand kann sich ändern. Zwischen notify und dem Zeitpunkt, zu dem man die Sperre wieder akquiriert, kann jemand anderes mit der Sperre das aufgehoben haben, worauf man gewartet hat. Die Schleife prüft es erneut.

if (!condition) wait() ist der häufigste einzelne Fehler in wait/notify-Code. Er funktioniert in Tests; er bricht in der Produktion um 3 Uhr nachts.

Der klassische Producer–Consumer

Der kanonische Anwendungsfall für wait/notify ist ein begrenzter Puffer:

class Buffer<T> {
  private final Object lock = new Object();
  private final Object[] data;
  private int count, head, tail;

  Buffer(int capacity) { data = new Object[capacity]; }

  void put(T item) throws InterruptedException {
    synchronized (lock) {
      while (count == data.length) lock.wait();             // wait for room
      data[tail] = item;
      tail = (tail + 1) % data.length;
      count++;
      lock.notifyAll();                                      // wake any consumer
    }
  }

  @SuppressWarnings("unchecked")
  T take() throws InterruptedException {
    synchronized (lock) {
      while (count == 0) lock.wait();                        // wait for an item
      T item = (T) data[head];
      data[head] = null;
      head = (head + 1) % data.length;
      count--;
      lock.notifyAll();                                      // wake any producer
      return item;
    }
  }
}

Einige Dinge, die dieser Code richtig macht:

  • Dieselbe Sperre für beide Methoden (lock). Ein Monitor schützt den gesamten Zustand.
  • Beide Warte-Aufrufe befinden sich innerhalb von while-Schleifen.
  • notifyAll auf beiden Seiten – weil sowohl Producer als auch Consumer auf demselben Monitor warten und das Aufwecken nur eines Thread möglicherweise der falsche Typ ist.
  • Sperre gehalten, während gewartet wird (das wait gibt sie intern frei und re-akquiriert sie, bevor es zurückkehrt).

In der Produktion würde man stattdessen BlockingQueue verwenden. Aber das Muster ist das, was BlockingQueue intern macht.

Warum notifyAll der sicherere Standard ist

Wenn man im obigen Puffer notifyAll durch notify ersetzen würde, entstünde ein subtiler Fehler. Zwei Consumer und ein Producer warten auf demselben Monitor. Der Producer ruft notify auf; die JVM wählt einen Thread aus; wenn sie einen Consumer auswählt, obwohl das Aufwecken für "die Queue hat Platz" gedacht war (für Consumer irrelevant), prüft der Consumer seine Bedingung erneut (Queue ist möglicherweise noch leer), kehrt zu wait zurück, und der Producer, der eigentlich aufgeweckt werden sollte, wird nie aufgeweckt. Blockierte Queue, keine Ausnahme.

Um notify sicher zu verwenden, muss gelten: Alle Wartenden warten auf dieselbe Bedingung, alle sind austauschbar, und das Protokoll stellt Fortschritt sicher. Das ist eine strenge Anforderung. Standardmäßig notifyAll verwenden; notify einsetzen, wenn der Performance-Gewinn wichtig ist und die Invariante beweisbar ist.

Die veralteten Alternativen

Es gibt alten Code, der Thread.suspend() und Thread.resume() verwendet. Nicht verwenden. Sie wurden in Java 1.2 als veraltet markiert, weil sie Sperren gehalten lassen und Invarianten brechen. Der wait/notify-Mechanismus ist der einzige sichere Weg, um einen Thread mit reinen Object-Methoden auf einen anderen warten zu lassen.

Es gibt auch Thread.sleep – aber sleep gibt keine Sperren frei. Ein Thread, der innerhalb eines synchronized-Blocks schläft, blockiert alle anderen Threads, die dieselbe Sperre benötigen, bis er aufwacht. wait (das die Sperre freigibt) für jedes "Warten auf ein Ereignis"-Szenario verwenden; sleep für "eine feste Zeit warten, ohne etwas Wichtiges zu halten" reservieren.

Was in der Produktion stattdessen verwenden

wait/notify sind korrekt, aber fehleranfällig. Moderner Code bevorzugt die übergeordneten Bausteine:

BedarfVerwenden
Begrenzter Producer–ConsumerArrayBlockingQueue, LinkedBlockingQueue
Auf N Abschlüsse wartenCountDownLatch
Auf das Treffen aller N Parteien wartenCyclicBarrier, Phaser
Mehrere Bedingungsvariablen auf einer SperreCondition (von ReentrantLock.newCondition())
RessourcenerlaubnisseSemaphore
Einmaliges Future-ErgebnisCompletableFuture

Jede dieser Klassen hat die richtige while-Schleife, die richtige notifyAll/signalAll-Semantik und die richtige Unterbrechungsbehandlung eingebaut. Alle werden in diesem Teil des Buchs behandelt.

Ein ausgearbeitetes Beispiel: Producer–Consumer mit wait und notifyAll

Das folgende Programm führt zwei Producer und drei Consumer gegen den obigen begrenzten Puffer aus. Die Producer legen jeweils 1000 Elemente ab; die Consumer laufen, bis sie gemeinsam 2000 entnommen haben.

java— editable, runs on the server

Was aus dem Programmlauf zu lernen ist:

  • Die Summen stimmten überein. Jedes vom Producer abgelegte Element wurde von genau einem Consumer entnommen; nichts wurde dupliziert, nichts ging verloren. Das ist die Korrektheitseigenschaft des Producer–Consumer-Musters, erreicht mit nur einem Monitor und dem wait/notifyAll-Paar.
  • Der Puffer hatte nur 4 Slots, sodass Producer ihn konsequent füllten und Consumer ihn konsequent leerten. Die while-Schleifen ließen sie parken und wieder parken, während die Queue zyklte. Ohne wait hätten die Producer auf count == capacity gespinnt und CPU verbraucht; mit wait schlafen sie, bis der Consumer signalisiert.
  • Das notifyAll wurde auf derselben Sperre aufgerufen, die sowohl Producer als auch Consumer hielten. Das ist der gesamte Koordinationsmechanismus: Ein Monitor, gegenseitiger Ausschluss und Signalisierung, mit der while-Schleife, die jedes Aufwecken auffängt, das nicht relevant war.
  • Das abschließende wait außerhalb von synchronized warf sofort eine IllegalMonitorStateException. Das ist die Durchsetzung der Regel durch die JVM: Man kann nur auf einem Monitor warten/benachrichtigen, den man gerade besitzt. Wenn diese Ausnahme auftritt, hat der Codepfad wait erreicht, ohne vorher durch synchronized gegangen zu sein.
  • Die gleiche Form – begrenzter Puffer, gegenseitiger Ausschluss, Signal bei jeder Zustandsänderung – ist das, was ArrayBlockingQueue intern macht, außer dass es zwei Conditions verwendet (eine für "nicht voll", eine für "nicht leer") anstatt eines großen notifyAll. Das ist der richtige Weg, dies in der Produktion zu schreiben; die wait/notifyAll-Version ist der zugrunde liegende Mechanismus, auf dem jede übergeordnete Klasse aufbaut.

Was als Nächstes kommt

Das nächste Kapitel, Java Deadlock, behandelt den Fehlermodus, der das Sperren von Grund auf subtil macht – zwei Threads, die jeweils das halten, was der andere braucht – und die Strategien, die ihn verhindern.

Übungsaufgaben

Übung
Warum muss `obj.wait()` immer aus einem `synchronized (obj)`-Block heraus (oder einer `synchronized`-Methode, die auf `obj` sperrt) aufgerufen werden?
Warum muss `obj.wait()` immer aus einem `synchronized (obj)`-Block heraus (oder einer `synchronized`-Methode, die auf `obj` sperrt) aufgerufen werden?
Was this page helpful?