Java并發編程:線程活躍性問題:死鎖、活鎖與饑餓
活躍性問題意味著程序永遠無法得到運行的最終結果。與之前提到的線程安全問題導致的程序錯誤相比,活躍性問題的后果可能更嚴重。例如,若發生死鎖,程序會完全卡死無法運行。
最典型的三種活躍性問題是死鎖(Deadlock)、活鎖(Livelock)和饑餓(Starvation)。下面逐一介紹。
1. 死鎖(Deadlock)
最常見的活躍性問題是死鎖。當兩個線程互相等待對方持有的資源,且都不釋放自己已持有的資源時,就會導致永久阻塞。
代碼示例:
public class DeadLock {
static Object lock1 = new Object();
static Object lock2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
try {
synchronized (lock1) {
Thread.sleep(500);
synchronized (lock2) {
System.out.println("Thread 1 成功執行");
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
try {
synchronized (lock2) {
Thread.sleep(500);
synchronized (lock1) {
System.out.println("Thread 2 成功執行");
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
輸出結果:
Acquired lock1, trying to acquire lock2.
Acquired lock2, trying to acquire lock1.
啟動程序后會發現,程序一直在運行,但永遠無法輸出線程 1 和線程 2 的執行結果,說明兩者都被卡住了。如果不強制終止進程,它們將永遠等待。
注意:后續章節會詳細講解
synchronized
關鍵字,目前只需知道它能確保同一時刻最多一個線程執行代碼(需持有對應鎖),以控制并發安全。
死鎖的必要條件
根據上述示例,可以分析死鎖發生的四個必要條件:
- 互斥條件:資源一次只能被一個進程或線程使用。例如,鎖被某個線程持有后,其他線程無法獲取,直到釋放。
- 請求與保持條件:線程在持有第一個鎖的同時請求第二個鎖。例如,線程 1 持有鎖 A 后嘗試獲取鎖 B,且不釋放鎖 A。
- 不可剝奪條件:鎖不會被外部強制剝奪。即沒有外界干預來終止死鎖。
- 循環等待條件:多個線程形成環形等待鏈。例如,線程 A 等線程 B 釋放資源,線程 B 等線程 A 釋放資源;或多個線程形成 A→B→C→A 的循環等待鏈。
??以上四個條件缺一不可!只要破壞任意一個條件,即可避免死鎖!
如何預防死鎖
如果線程一次只能獲取一個鎖,則不會發生死鎖。雖然不太實用,但這是最徹底的解決方案。
以下是兩種常用預防方法:
- 按固定順序獲取鎖
如果必須獲取多個鎖,設計時需要確保所有線程按相同順序獲取鎖。例如修改上述代碼:
// 線程 1 和線程 2 均按 lock1 → lock2 順序獲取
Thread1--> 獲取lock1--> 獲取lock2--> 執行成功;
Thread2--> 獲取lock1--> 獲取lock2--> 執行成功;
- 超時放棄
使用synchronized
內置鎖時,線程會無限等待。而Lock
接口的tryLock(long time, TimeUnit unit)
方法允許設置等待時間。若超時未獲鎖,線程可主動釋放已持有的鎖,從而避免死鎖。
2. 活鎖(Livelock)
什么是活鎖
活鎖是第二種活躍性問題。與死鎖類似,程序無法得到最終結果,但線程并非完全阻塞,而是不斷嘗試執行卻無法推進。
例如:兩人迎面相遇,互相讓路,結果你往右我往左,再次相撞,最終誰也無法通過。
代碼示例:
public class Livelock {
private Lock lock1 = new ReentrantLock(true);
private Lock lock2 = new ReentrantLock(true);
public static void main(String[] args) {
Livelock livelock = new Livelock();
new Thread(livelock::operation1, "T1").start();
new Thread(livelock::operation2, "T2").start();
}
public void operation1() {
while (true) {
lock1.tryLock();
System.out.println("獲取 lock1,嘗試獲取 lock2");
sleep(50); // 模擬業務耗時
if (lock2.tryLock()) {
System.out.println("獲取 lock2");
} else {
System.out.println("無法獲取 lock2,釋放 lock1");
lock1.unlock();
continue;
}
System.out.println("執行 operation1");
break;
}
lock2.unlock();
lock1.unlock();
}
public void operation2() {
while (true) {
lock2.tryLock();
System.out.println("獲取 lock2,嘗試獲取 lock1");
sleep(50);
if (lock1.tryLock()) {
System.out.println("獲取 lock1");
} else {
System.out.println("無法獲取 lock1,釋放 lock2");
lock2.unlock();
continue;
}
System.out.println("執行 operation2");
break;
}
lock1.unlock();
lock2.unlock();
}
private void sleep(long sleepTime) {
try {
Thread.sleep(sleepTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
輸出結果:
獲取lock1,嘗試獲取lock2
獲取lock2,嘗試獲取lock1
無法獲取lock2,釋放lock1
獲取lock2,嘗試獲取lock1
無法獲取lock1,釋放lock2
...(循環)
從日志可見,兩個線程不斷獲取和釋放鎖,但都無法完成操作。
注意:由于線程調度,此示例可能在運行一段時間后自動解除活鎖,但不影響理解其原理。
如何預防活鎖
活鎖的根源在于線程同時釋放鎖并重試。解決方法是為鎖獲取設置隨機等待時間,打破同步釋放的節奏:
修改代碼:
// 在 sleep 方法中增加隨機等待時間
private void sleep(long sleepTime) {
try {
Thread.sleep(sleepTime + (long)(Math.random() * 100));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
修改后運行結果:
獲取lock1,嘗試獲取lock2
獲取lock2,嘗試獲取lock1
無法獲取lock1,釋放lock2
獲取lock2
執行operation1
獲取lock2,嘗試獲取lock1
獲取lock1
執行operation2
此時活鎖問題基本消失。
典型場景:消息隊列中某個錯誤消息反復重試,導致線程忙但無結果。解決方法:
- 將錯誤消息移至隊列尾部延遲處理;
- 限制重試次數,超過后丟棄或特殊處理。
3. 饑餓(Starvation)
什么是饑餓
饑餓指線程長期無法獲取資源(如 CPU 時間),導致無法運行。常見場景:
- 線程優先級過低,長期得不到調度;
- 某線程持有鎖且不釋放(如無限循環),其他線程長期等待。
饑餓的影響
導致程序響應性差。例如,瀏覽器前端線程因后臺線程占用 CPU 無法響應操作。
如何預防饑餓
- 確保邏輯正確,及時釋放鎖;
- 合理設置線程優先級(或不設置優先級)。