Java Deadlock
Was Deadlocks in Java sind, wie sie entstehen und welche Muster sie verhindern.
Ein Deadlock ist der Fehlermodus beim Sperren. Zwei oder mehr Threads halten jeweils eine Sperre, die der andere benötigt; keiner kann fortfahren; es wird keine Ausnahme ausgelöst; nichts im Log besagt „wir stecken fest." Von außen betrachtet scheint das Programm nichts zu tun — genau dasselbe äußere Symptom wie eine Busy-Loop oder ein langer Netzwerkaufruf.
Deadlocks entstehen in jedem Programm, das mehr als eine Sperre gleichzeitig akquiriert. Sie sind erschreckend leicht zu schreiben und erschreckend schwer zu reproduzieren — der Ablaufplan, der einen auslöst, kann einmal pro Woche in der Produktion auftreten und niemals in Tests. Die richtige Strategie ist nicht „debugge sie, wenn sie auftreten", sondern „strukturiere den Code so, dass sie nicht auftreten können".
Die vier Bedingungen (Coffmans Bedingungen)
Ein Deadlock erfordert, dass alle vier dieser Bedingungen gleichzeitig wahr sind:
- Gegenseitiger Ausschluss. Eine Ressource (eine Sperre) kann jeweils nur von einem Thread gehalten werden.
- Halten und Warten. Ein Thread hält mindestens eine Ressource, während er auf eine weitere wartet.
- Kein Vorrang. Ressourcen können dem Thread, der sie hält, nicht entzogen werden; der Thread muss sie freiwillig freigeben.
- Zirkuläres Warten. Es gibt einen Zyklus im Wartegraph — A wartet auf die Sperre von B, B wartet auf die Sperre von C, ..., Z wartet auf die Sperre von A.
Bricht man eine davon, werden Deadlocks unmöglich. Die standardmäßigen Präventionsverfahren brechen jeweils eine der vier:
- Sperrreihenfolge (am häufigsten): bricht das zirkuläre Warten, indem Sperren immer in einer global vereinbarten Reihenfolge akquiriert werden.
tryLockmit Timeout: bricht das Halten-und-Warten, indem aufgegeben wird, wenn die zweite Sperre nicht schnell genug erlangt werden kann.- Eine große Sperre: bricht die Mehr-Sperren-Struktur vollständig. Grob, aber wirksam bei geringer Konkurrenz.
- Lock-freie / unveränderliche Daten: bricht den gegenseitigen Ausschluss, indem die Ressource entfernt wird. Die Atomics und nebenläufigen Collections, die später in diesem Teil des Buches behandelt werden, verfolgen diesen Ansatz.
Das Zwei-Konten-Beispiel
Die klassische Demonstration:
void transfer(Account from, Account to, int amount) {
synchronized (from) {
synchronized (to) {
from.debit(amount);
to.credit(amount);
}
}
}
// Thread A: transfer(accountX, accountY, 100)
// Thread B: transfer(accountY, accountX, 100)Ablaufplan:
- Thread A akquiriert den Monitor von
accountX. - Thread B akquiriert den Monitor von
accountY. - Thread A versucht
accountYzu akquirieren — blockiert, gehalten von B. - Thread B versucht
accountXzu akquirieren — blockiert, gehalten von A.
Keiner der Threads wird jemals freigeben. Beide sind für immer BLOCKED. Die Lösung:
void transfer(Account from, Account to, int amount) {
Account first = from.id() < to.id() ? from : to;
Account second = from.id() < to.id() ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(amount);
to.credit(amount);
}
}
}Beide Threads akquirieren nun accountX dann accountY, unabhängig davon, in welcher Richtung die Überweisung erfolgt. Das zirkuläre Warten kann sich nicht bilden.
Der Sortierschlüssel muss keine id sein — System.identityHashCode(obj) funktioniert als stabiler Tiebreaker für beliebige Objekte, aber Kollisionen sind möglich, sodass Produktionscode typischerweise einen echten Schlüssel (die Datenbank-ID, die Benutzer-ID usw.) verwendet und bei übereinstimmenden Schlüsseln auf eine Tiebreaker-Sperre zurückgreift.
Sperrreihenfolge im gesamten Programm
Die Sperrreihenfolge funktioniert nur, wenn jeder Codepfad, der zwei Sperren desselben Typs nimmt, sie in derselben Reihenfolge nimmt. Eine abweichende Methode, die synchronized (b) { synchronized (a) { ... } } ausführt, reicht aus, um den Deadlock zurückzubringen.
So lässt sich das in einer größeren Codebasis konsequent durchsetzen:
- Reihenfolge dokumentieren. „Immer
parentvorchildakquirieren." Als Kommentar in die Klasse schreiben. - Über einen einzigen Helfer kanalisieren. Alle „transfer"-Aufrufe laufen durch eine Methode, die die Reihenfolge vorgibt — damit kann eine einzelne Aufrufstelle es nicht falsch machen.
-XX:+PrintConcurrentLocksin einem Thread-Dump ist eine Möglichkeit, die tatsächlichen Sperr-Akquisitionsgraphen in der Produktion zu untersuchen.
Die Disziplin ist genauso wichtig wie die Regel.
tryLock mit Timeout
Wenn die Reihenfolge nicht garantiert werden kann — unterschiedliche Bibliotheken, unterschiedliche Teams, komplexe Objektgraphen — bietet ReentrantLock.tryLock(timeout, unit) einen Ausweg:
boolean done = false;
while (!done) {
if (firstLock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (secondLock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
doWork();
done = true;
} finally { secondLock.unlock(); }
}
} finally { firstLock.unlock(); }
}
// back off briefly, retry — eventually we'll get both
}Wenn die zweite Sperre nicht innerhalb von 100 ms erlangt werden kann, gibt der Thread die erste Sperre frei und versucht es später erneut. Die Halten-und-Warten-Bedingung ist gebrochen — kein Thread blockiert für immer, selbst wenn beide dieselben Sperren in entgegengesetzter Reihenfolge versuchen.
Der Preis sind Busy-Retries und der umgebende Back-off-Code. Verwende Sperrreihenfolge, wenn möglich; greife auf tryLock zurück, wenn nicht.
Deadlocks zur Laufzeit erkennen
Zwei Hauptwerkzeuge.
Thread-Dump. jstack <pid> oder kill -3 <pid> gibt den Zustand und Stack jedes Threads aus. Ein Deadlock ist klar erkennbar: zwei Threads mit Status BLOCKED, jeder - waiting to lock <0x...> auf einem Objekt, das der andere - locked <0x...> zeigt. Die JVM ist sogar so freundlich, offensichtliche Zyklen am Ende des Dumps zu markieren:
Found one Java-level deadlock:
=============================
"thread-2":
waiting to lock monitor 0x00007fcd0e..., which is held by "thread-1"
"thread-1":
waiting to lock monitor 0x00007fcd0e..., which is held by "thread-2"ThreadMXBean.findDeadlockedThreads(). Eine programmatische Version — nützlich für die Einbindung in einen Health-Check-Endpunkt:
ThreadMXBean mx = ManagementFactory.getThreadMXBean();
long[] deadlocked = mx.findDeadlockedThreads();
if (deadlocked != null) log.error("deadlock detected: {} threads", deadlocked.length);Dies findet nur Deadlocks bei intrinsischen Monitoren und ReentrantLock. Es findet keine Livelocks oder allgemeine „Thread ist einfach langsam"-Fälle.
Livelock und Starvation — Deadlocks Verwandte
Zwei Fehlermodi, die wie Deadlocks aussehen, es aber nicht sind:
- Livelock. Threads ändern ständig ihren Zustand, machen aber keinen Fortschritt. Der klassische Fall: zwei
tryLock-Aufrufer versuchen es beide für immer erneut, weil keiner zuerst nachgibt. Die CPU ist beschäftigt; die Arbeit wird nicht erledigt. - Starvation. Ein Thread ist technisch gesehen
RUNNABLEoder aufweckbar, aber der Scheduler / die Sperr-Policy lässt ihn nie tatsächlich laufen. Unfaire Sperren unter hoher Konkurrenz können einen Schreiber aushungern, während Leser hindurchströmen.
Beide haben dasselbe äußere Symptom wie ein Deadlock („nichts scheint Fortschritte zu machen"), aber die Diagnose ist anders — der Thread-Dump zeigt kein BLOCKED in einem gegenseitigen Zyklus; er zeigt Threads, die sich drehen, oder nur einen, der ständig wartet.
Ein ausgearbeitetes Beispiel: Deadlock erzeugt und dann verhindert
Das Programm unten führt das Transfer-Muster in beide Richtungen aus — zuerst mit der fehlerhaften verschachtelten Sperr-Version (die unter Konkurrenz zu einem Deadlock führt), und dann mit der Sperrreihenfolge-Korrektur, die ihn verhindert. Die fehlerhafte Version ist in ein Watchdog-Timeout eingebettet, damit die Demo nicht ewig hängt.
Was aus dem Lauf zu entnehmen ist:
- Die
BROKEN-Variante hat nicht alle 100 Überweisungen abgeschlossen. Unter Konkurrenz hieltt1aund wartete aufb, währendt2bhielt und aufawartete. Der Watchdog erreichte seine 3-Sekunden-Frist;findDeadlockedThreads()bestätigte den Zyklus. Das ist Deadlock — keine Ausnahme, kein Log, nichts falsch an einer einzelnen Zeile Code. - Die
FIXED-Variante lief sauber durch. Die Sortierregel (first = id-min, second = id-max) bedeutet, dass beide Threads zuerstaund dannbakquirieren, unabhängig von der Richtung der Überweisung. Der Zyklus kann sich nicht bilden, weil beide Threads den Sperrgraphen in dieselbe Richtung durchlaufen. Thread.sleep(1)innerhalb des erstensynchronizedder fehlerhaften Version macht den Deadlock hochgradig reproduzierbar. In echtem Code sieht man diese Art von explizitem Sleep fast nie — aber I/O, GC oder ein Context-Switch können dasselbe Zeitfenster erzeugen. Deshalb reproduzieren sich Deadlocks sporadisch in der Produktion und nie in Tests.ThreadMXBean.findDeadlockedThreads()gab für die fehlerhafte Variante ein nicht-null-Array zurück und bestätigte die Anzahl der zyklischen Threads. Dieser Aufruf ist das Sicherheitsnetz für die prozessinterne Erkennung — binde ihn in einen Health-Endpunkt ein und du wirst über den Deadlock informiert, bevor der Benutzer es merkt.- Nachdem der Watchdog die fehlerhafte Variante als feststeckend deklarierte, unterbrach das Programm beide Threads.
interrupt()weckt keinen Thread auf, der auf einemsynchronized-Monitor blockiert ist — es weckt nur Threads insleep,wait,joinoderLockSupport.park. Deshalb löst das Unterbrechen eines Deadlocks ihn nicht auf; man müsste die JVM beenden (oderReentrantLock.lockInterruptiblyverwenden).
Was kommt als Nächstes
Das nächste Kapitel, Java volatile, widmet sich der Sichtbarkeits-Hälfte der Sicherheitsgeschichte — dem Schlüsselwort, das „ein Thread schreibt, ein anderer liest für immer den alten Wert" behebt, ohne Sperren zu benötigen.