一句話先說結論:死鎖不是例外,是幾個執行緒各自握著對方要的鎖、互相永久等待。用
jstack <pid>,JVM 會直接打出Found one Java-level deadlock:段,標出環上每個執行緒。本質是四個必要條件同時成立,打破任意一個即可,最常用兩招——鎖順序一致、tryLock加超時。
背景
synchronized 保證同一時刻只有一個執行緒進入臨界區。多把鎖嵌套使用時,如果兩個執行緒按相反順序拿鎖,就會互相卡死,誰也沒法繼續。
教科書式的現場是兩個物件 lockA、lockB:
public class DeadlockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lockA) {
sleep(100);
synchronized (lockB) { /* ... */ }
}
}, "Thread-1").start();
new Thread(() -> {
synchronized (lockB) {
sleep(100);
synchronized (lockA) { /* ... */ }
}
}, "Thread-2").start();
}
}
Thread-1 先拿 lockA 再拿 lockB,Thread-2 反過來,正好構成一個環。
現象
死鎖最陰險的地方:程式不報錯、不拋例外、不打印日誌,就那麼在原地僵住。控制台最後幾行通常是:
Thread 1: acquired lockA
Thread 2: acquired lockB
然後就沒有然後了。
有經驗的老手會先 jps 拿到進程號,再 jstack <pid> 抓執行緒快照。死鎖時 jstack 末尾會直接給出結論,用一段輸出把環畫清楚:
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x000000001e0c7800 (object 0x000000001e0c7800, a java.lang.Object),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x000000001e0c8800 (object 0x000000001e0c8800, a java.lang.Object),
which is held by "Thread-1"
Java stack information for the threads listed above:
===================================================
"Thread-1":
at DeadlockDemo.lambda$main$0(DeadlockDemo.java:12)
- waiting to lock <0x000000001e0c7800> (a java.lang.Object)
- locked <0x000000001e0c8800> (a java.lang.Object)
at DeadlockDemo$$Lambda$1/0x0000000800b20840.run(Unknown Source)
at java.lang.Thread.run(Thread.java:833)
"Thread-2":
at DeadlockDemo.lambda$main$1(DeadlockDemo.java:20)
- waiting to lock <0x000000001e0c8800> (a java.lang.Object)
- locked <0x000000001e0c7800> (a java.lang.Object)
at DeadlockDemo$$Lambda$2/0x0000000800b20840.run(Unknown Source)
at java.lang.Thread.run(Thread.java:833)
這段輸出就是定位死鎖的金標準:誰在等誰、鎖被誰佔,一目瞭然。jcmd <pid> Thread.print 也能拿到同樣的信息。
根因分析
死鎖能成立,四個條件必須同時滿足:
- 互斥:同一把鎖同一時刻只能一個執行緒持有。
- 持有並等待:執行緒握著一把鎖,還想拿下一把。
- 不可搶佔:沒法從外面把鎖搶過來,只能執行緒自己釋放。
- 循環等待:執行緒之間形成「A 等 B、B 等 A」的環。
前面例子裡,Thread-1 握著 lockA 等 lockB,Thread-2 握著 lockB 等 lockA,四個條件全中。根子是加鎖順序不一致。
死鎖不一定非得是顯式的 synchronized。JDK-8209007 報告過一個更隱蔽的:外層並行流裡調用 ConcurrentHashMap.compute,compute 內部又嵌套了 Stream.parallel,兩個工作執行緒在兩個 ReservationNode 上互相等待,同樣打出 Found one Java-level deadlock。這說明框架內部的自旋和遞歸併行,一樣會製造循環等待。
解決方案
首選:加鎖順序一致
給所有需要同時拿多把鎖的路徑定死一個順序,全局遵守。先 lockA 後 lockB,任何地方都不顛倒:
public void transfer() {
synchronized (lockA) {
synchronized (lockB) {
// 固定顺序,环就断了
}
}
}
順序一致後,「循環等待」被破壞,死鎖從根上不可能發生。代價是鎖多了之後,維護順序的成本變高。
tryLock 加超時:拿不到就退
當加鎖順序不好統一,改用 ReentrantLock.tryLock,拿不到就超時放棄,別無限等:
ReentrantLock a = new ReentrantLock();
ReentrantLock b = new ReentrantLock();
public boolean tryWork() throws InterruptedException {
if (a.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (b.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// 干活
return true;
} finally {
b.unlock();
}
}
} finally {
a.unlock();
}
}
return false; // 拿不到就放弃或重试
}
思路是破壞「持有並等待」:要麼一次拿齊,要麼暫時全放掉。比裸 synchronized 多了退路,但程式碼更繁瑣,還可能引入活鎖。
縮小臨界區、少嵌套
能不同時拿兩把鎖就別拿。把真正需要加鎖的部分縮到最小,程式碼裡少出現「鎖套鎖」。
思考過程
jstack 那段 Found one Java-level deadlock 不是每個死鎖都一定打印。更復雜的鎖(如 ReentrantReadWriteLock、CountDownLatch、Semaphore)卡住時,執行緒狀態是 WAITING 而非 BLOCKED,JVM 未必一眼認出是死鎖。這時要靠多次抓執行緒快照,觀察執行緒是否長期停在同一個 WAITING 點。
JDK-8209007 最終被標記為 Not an Issue,不是因為沒有死鎖,而是官方認為「在 ConcurrentHashMap.compute 裡跑嵌套並行流」超出了預期用法,屬於使用方式問題。這個案例說明,排查死鎖不能只盯著自己寫的鎖,框架內部的鎖同樣會中招。
定位方法論就一句話:先 jstack 拿完整執行緒快照,別隻盯 BLOCKED,長期不動的 WAITING 往往就是環上的另一頭。
小結
- 死鎖的特徵是「假死、無例外、無日誌」,光看現象很難第一時間想到。
- 一抓一個準的工具是
jstack <pid>,認準Found one Java-level deadlock那段。 - 打破四個必要條件之一能根治,最實用的是鎖順序一致加
tryLock超時。 - 別隻懷疑
synchronized,框架內部和併發集合同樣會死鎖。
