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(); // IllegalMonitorStateExceptionDiese 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:
- Gibt den Monitor des Objekts frei, auf dem es aufgerufen wurde.
- Parkt den aktuellen Thread in der Warteschlange dieses Monitors.
- 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 einerwhile-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:
- Spurious wakeups. Die JVM darf einen
waitohne jeglichen Grund aufwecken. Die Schleife fängt diese ab. notifyAllweckt 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 zuwait.- Anderer Zustand kann sich ändern. Zwischen
notifyund 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. notifyAllauf 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
waitgibt 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:
| Bedarf | Verwenden |
|---|---|
| Begrenzter Producer–Consumer | ArrayBlockingQueue, LinkedBlockingQueue |
| Auf N Abschlüsse warten | CountDownLatch |
| Auf das Treffen aller N Parteien warten | CyclicBarrier, Phaser |
| Mehrere Bedingungsvariablen auf einer Sperre | Condition (von ReentrantLock.newCondition()) |
| Ressourcenerlaubnisse | Semaphore |
| Einmaliges Future-Ergebnis | CompletableFuture |
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.
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. Ohnewaithätten die Producer aufcount == capacitygespinnt und CPU verbraucht; mitwaitschlafen sie, bis der Consumer signalisiert. - Das
notifyAllwurde auf derselben Sperre aufgerufen, die sowohl Producer als auch Consumer hielten. Das ist der gesamte Koordinationsmechanismus: Ein Monitor, gegenseitiger Ausschluss und Signalisierung, mit derwhile-Schleife, die jedes Aufwecken auffängt, das nicht relevant war. - Das abschließende
waitaußerhalb vonsynchronizedwarf sofort eineIllegalMonitorStateException. 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 Codepfadwaiterreicht, ohne vorher durchsynchronizedgegangen zu sein. - Die gleiche Form – begrenzter Puffer, gegenseitiger Ausschluss, Signal bei jeder Zustandsänderung – ist das, was
ArrayBlockingQueueintern macht, außer dass es zweiConditions verwendet (eine für "nicht voll", eine für "nicht leer") anstatt eines großennotifyAll. Das ist der richtige Weg, dies in der Produktion zu schreiben; diewait/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.