一句话先说结论:死锁不是异常,是几个线程各自握着对方要的锁、互相永久等待。用
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)
"Thread-2":
at DeadlockDemo.lambda$main$1(DeadlockDemo.java:20)
- waiting to lock <0x000000001e0c8800> (a java.lang.Object)
- locked <0x000000001e0c7800> (a java.lang.Object)
这段输出就是定位死锁的金标准:谁在等谁、锁被谁占,一目了然。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,框架内部和并发集合同样会死锁。