1. 深入探討 Deadlock
Deadlock(「互相封鎖」)是指兩個或多個執行緒彼此無限期等待:每個執行緒都持有某個資源,並嘗試取得另一個已被其他執行緒佔用的資源。結果誰都無法繼續工作,程式就「卡住」了。就像兩輛車在狹窄的橋上迎面相遇:若沒有一方退後,誰也過不去。
在 Java 中,deadlock 不是例外(Exception),而是程式的「卡死」。執行緒不會結束或丟出錯誤,而是彼此無限期等待。因此這類問題很陰險:往往只在「特定情況」下才會浮現。
Deadlock 發生的四個條件
- 1. 互斥(Mutual Exclusion)
某資源在同一時間只能被一個執行緒持有(例如物件監視器、檔案)。 - 2. 保持並等待(Hold and Wait)
執行緒持有一個資源,同時在不釋放既有資源的情況下再去請求另一個資源。 - 3. 不可搶占(No Preemption)
外部無法「奪走」資源:只能由持有該資源的執行緒自行釋放。 - 4. 循環等待(Circular Wait)
存在一條閉合的等待鏈,每個執行緒都在等待下一個執行緒所持有的資源。
只有同時滿足以上所有條件,才可能發生 deadlock。只要打破其中任一條件,互相封鎖就不會發生。
2. Deadlock 的程式碼範例
示意
- 有兩個資源:lock1 與 lock2(一般物件)。
- 執行緒 A 先取得 lock1,再嘗試取得 lock2。
- 執行緒 B 先取得 lock2,再嘗試取得 lock1。
範例(請勿在家嘗試!)
public class DeadlockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
// 執行緒 1
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("執行緒 1: 已取得 lock1");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("執行緒 1: 嘗試取得 lock2");
synchronized (lock2) {
System.out.println("執行緒 1: 已取得 lock2");
}
}
});
// 執行緒 2
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("執行緒 2: 已取得 lock2");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("執行緒 2: 嘗試取得 lock1");
synchronized (lock1) {
System.out.println("執行緒 2: 已取得 lock1");
}
}
});
thread1.start();
thread2.start();
}
}
執行時會發生什麼?
- 執行緒 1 先取得 lock1,執行緒 2 先取得 lock2。
- 兩者都在 100 毫秒內暫停,足以先拿到第一把鎖。
- 接著各自嘗試取得第二把鎖,但那把鎖已被對方持有。
- 兩個執行緒無限期地彼此等待——出現 deadlock。
主控台會看到:
執行緒 1: 已取得 lock1
執行緒 2: 已取得 lock2
執行緒 1: 嘗試取得 lock2
執行緒 2: 嘗試取得 lock1
為什麼會發生 deadlock?詳細解析
- 互斥:每把鎖同一時間只能被一個執行緒持有。
- 保持並等待:執行緒 1 持有 lock1 並等待 lock2;執行緒 2 持有 lock2 並等待 lock1。
- 不可搶占:外部無法把鎖「拔走」——只能離開 synchronized 區塊後才會釋放。
- 循環等待:兩個執行緒之間形成了循環等待。
3. 如何避免與預防 deadlock
以相同順序取得資源
首要規則:當多個執行緒需要取得多個資源時——務必在所有位置都用相同的順序。例:所有地方都先 lock1,再 lock2。
public void doSomething() {
Object firstLock = lock1;
Object secondLock = lock2; // 統一順序 "lock1 -> lock2"
synchronized (firstLock) {
synchronized (secondLock) {
// 同時操作兩個資源
}
}
}
在統一順序下不可能形成循環等待——deadlock 的條件被破壞。
使用帶逾時的 tryLock(ReentrantLock)
當事先無法確定需要哪些資源時,使用 ReentrantLock 與 tryLock 方法,以避免無限期等待。
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class TryLockDemo {
private static final ReentrantLock lock1 = new ReentrantLock();
private static final ReentrantLock lock2 = new ReentrantLock();
public void doWork() {
try {
if (lock1.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// 臨界區
} finally {
lock2.unlock();
}
} else {
System.out.println("無法取得 lock2,回退");
}
} finally {
lock1.unlock();
}
} else {
System.out.println("無法取得 lock1,回退");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
優點:可以回退操作並稍後再試。缺點:程式碼較複雜,但可以避免 deadlock。
最小化持有鎖的時間
盡可能縮短持鎖時間。在 synchronized/lock 中只做必要的事;其餘邏輯移到臨界區之外。
避免巢狀鎖
synchronized/lock 的巢狀層級越少,發生互相封鎖的風險越低。如果可行——針對相關資源使用單一把鎖。
使用現成的 thread-safe 結構
標準程式庫已解決許多問題:例如 ConcurrentHashMap 等集合與 java.util.concurrent 中的其他類別,可降低 deadlock 風險並簡化程式碼。
4. deadlock 的診斷
Thread Dump 與 jstack
Thread Dump 是 JVM 中所有執行緒狀態的快照。
在主控台取得:
jstack <pid>
其中 <pid> 是 Java 行程的識別碼(可透過 jps 取得)。
在 IDE 的除錯面板中通常有「Thread Dump」按鈕。
在 dump 中請留意:
- 狀態 BLOCKED 或 WAITING。
- 類似 waiting to lock ... 與 locked ... 的訊息。
- JVM 的訊息:"Found one Java-level deadlock:"——當偵測到互相封鎖時會出現。
Dump 片段範例:
"Thread-1":
waiting to lock monitor 0x000000001e4000, (object 0x7f8a5c00, a java.lang.Object),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x000000001e3000, (object 0x7f8a5c10, a java.lang.Object),
which is held by "Thread-1"
這就是 deadlock:執行緒彼此「互相持有」。
VisualVM 與其他工具
- VisualVM——免費的執行緒分析與 deadlock 偵測工具。
- Java Mission Control / Flight Recorder——進階的監控與剖析。
在 VisualVM 中可檢視執行緒樹、其狀態,並獲得互相封鎖的通知。
表格:「如何避免陷入 deadlock」
| deadlock 的原因 | 如何避免 |
|---|---|
| 以不同順序取得資源 | 一律遵守統一的資源取得順序 |
| 巢狀的 synchronized | 最小化巢狀層級;可行時使用單一把鎖 |
| 持鎖時間過長 | 縮短臨界區內的工作 |
| 同時使用多把鎖 | 搭配逾時的 tryLock 並具備回退邏輯 |
| 非 thread-safe 的集合 | 使用並行集合(如 ConcurrentHashMap 等) |
5. 處理 deadlock 的常見錯誤
錯誤 №1:以不同順序取得資源。最常見的原因是不同執行緒以不同順序取得鎖。即使「看似更快」,也務必遵守統一順序——不然就難免循環等待。
錯誤 №2:不必要的巢狀 synchronized。多餘的巢狀會增加互相封鎖風險並使程式碼更複雜。請簡化鎖模型。
錯誤 №3:忽略 tryLock 與逾時。使用 synchronized 可能讓執行緒無限期等待。若無把握,請使用帶逾時的 tryLock 並實作回退邏輯。
錯誤 №4:在持鎖期間執行耗時工作。網路請求、I/O 或重度計算都會在持鎖時大幅提高風險。請將其移出臨界區。
錯誤 №5:不分析 Thread Dump。Thread Dump 是追查互相封鎖的最佳利器。請使用 jstack,分析 BLOCKED/WAITING 狀態與「holding/waiting to lock」的鏈結。
GO TO FULL VERSION