什么是死锁?产生条件和如何避免?
简化版
死锁是两个或多个线程互相持有对方需要的锁、都不释放,永远僵在那里的状态。它的产生要同时满足四个条件:互斥、持有并等待、不可剥夺、循环等待。破坏其中任意一个就能避免,最实用的是破坏「循环等待」——让所有线程按同样的固定顺序加锁。
详细版
一个经典死锁例子:
// 线程 A:先拿 lock1,再拿 lock2
synchronized (lock1) {
synchronized (lock2) { }
}
// 线程 B:先拿 lock2,再拿 lock1 ← 加锁顺序相反!
synchronized (lock2) {
synchronized (lock1) { }
}
A 拿着 lock1 等 lock2,B 拿着 lock2 等 lock1,谁也不让,死锁。
死锁的四个必要条件(缺一不可,同时满足才会死锁):
- 互斥:资源同一时刻只能被一个线程占用。
- 持有并等待:线程持有一个资源的同时,又去请求别的资源,且不放手上的。
- 不可剥夺:线程拿到的资源,不能被别人强行抢走,只能自己释放。
- 循环等待:存在一个线程环,每个线程都在等下一个线程持有的资源(A等B、B等A)。
完整版教学
一、破坏四个条件 = 四种避免思路
因为四个条件必须同时成立才死锁,那么破坏任意一个就能避免。逐个看哪些可行:
- 破坏互斥:让资源可共享。但很多资源天生互斥(如写操作),一般做不到。
- 破坏持有并等待:要求线程一次性申请到所有需要的锁,要么全拿到要么都不拿。可行但降低并发度。
- 破坏不可剥夺:用可超时/可中断的锁——拿不到就主动释放已持有的锁再重试。
ReentrantLock.tryLock(timeout)就能做到。 - 破坏循环等待:给所有锁规定一个全局顺序,所有线程都按同样顺序加锁。这样不可能形成环。这是最常用、最实用的办法。
二、最实用的解法:固定加锁顺序
回到上面的例子,死锁的根源是 A、B 加锁顺序相反。只要让所有线程都「先 lock1 再 lock2」,环就不可能形成:
// 两个线程都按同一顺序:先 lock1,再 lock2
synchronized (lock1) {
synchronized (lock2) { }
}
实践中如果锁对象是动态的(比如转账时锁两个账户),可以按对象的某个唯一且稳定的属性排序(如账户 id、System.identityHashCode)来决定先锁哪个,保证所有线程顺序一致。
三、用 tryLock 超时避免
ReentrantLock 的 tryLock(timeout) 提供了另一条路——破坏「不可剥夺 + 持有并等待」:
if (lock1.tryLock(1, SECONDS)) {
try {
if (lock2.tryLock(1, SECONDS)) {
try { /* 业务 */ }
finally { lock2.unlock(); }
} else {
// 拿不到 lock2,释放 lock1,退避一会儿重试,避免僵持
}
} finally { lock1.unlock(); }
}
拿不到第二把锁时,主动释放第一把、退避重试,就不会一直僵持。synchronized 做不到这点(它会死等),这正是 ReentrantLock 相比 synchronized 的一个优势(详见「synchronized 和 ReentrantLock 的区别」那道题)。
四、怎么排查死锁
线上怀疑死锁时:
- jstack
<pid>:线程 dump 会直接标出Found one Java-level deadlock,列出互相等待的线程和锁; - jconsole / VisualVM:有「检测死锁」按钮;
- 死锁的线程状态是 BLOCKED(在等锁,详见「线程状态」那道题)。
排查后通常回到「统一加锁顺序」或「改用 tryLock」来修。
五、死锁 vs 活锁 vs 饥饿
顺带区分三个易混概念:
- 死锁:互相等待,都不动(线程 BLOCKED)。
- 活锁:线程都在动,但一直在「谦让/重试」中反复冲突、无法推进(像两人过道互相让路又同时挪同一边)。
- 饥饿:某线程一直抢不到资源(如优先级太低、或非公平锁下一直被插队),长期得不到执行。
六、把锁顺序变成可验证的工程规则
“固定顺序”只有在顺序可全局比较时才可靠。转账场景可按账户 ID 从小到大加锁;如果 A.id=17、B.id=42,无论转账方向如何都先锁 17 再锁 42,这样等待边只能从小 ID 指向大 ID,不可能形成环。
规则:lock(min(idA,idB)) → lock(max(idA,idB))
17 → 42 → 88 可以继续延伸,但不可能出现 88 → 17
当两个对象 ID 恰好相等或缺少稳定顺序时,可使用额外的 tie lock 处理极少数冲突。更好的设计往往是缩小锁作用域、减少同时持有多把锁,或通过消息串行化避免嵌套锁。
七、线上排查要保留多次线程快照
一次线程转储中看到两个线程互相等待已能强烈指向死锁,jcmd <pid> Thread.print -l 或 jstack -l 还能报告 Java 监视器死锁。但持续阻塞也可能来自数据库锁、分布式锁或本地资源,JVM 未必自动识别。
| 证据 | 能回答的问题 | 局限 |
|---|---|---|
| 多次线程转储 | 等待关系是否持续、锁拥有者是谁 | 主要覆盖 JVM 可见线程与锁 |
| JFR 锁事件 | 哪些锁竞争频繁、等待多久 | 需合理录制配置 |
| 数据库锁视图 | 事务等待与阻塞链 | 不展示 Java 对象锁 |
| 分布式锁日志 | 租约、持有者、续期 | 依赖业务埋点和时钟 |
例如每隔 5 秒抓 3 次快照,若同一批线程始终卡在相同锁关系,证据比单次“恰好阻塞”更可靠。处理前先保留现场,再根据应急预案决定隔离、重启或终止事务。
记忆钩子:死锁本质是等待图出现环;预防是让环不可能形成,诊断则是把“谁持有什么、又在等什么”还原成图。
八、常见误区与追问
- 误区:只要程序使用两把锁就一定会死锁。 必须让互斥、占有且等待、不可剥夺和循环等待同时成立才构成经典死锁。
- 误区:给
synchronized增加一个业务超时就能中断等锁。 等待进入 synchronized 监视器本身不可用tryLock式超时控制,需要改变锁方案或结构。 - 误区:所有长时间 BLOCKED 都是死锁。 高竞争或锁内慢操作也会持续阻塞,必须检查等待图是否成环以及状态是否推进。
- 追问:固定加锁顺序破坏了哪个条件? 它让等待边只能沿全序方向前进,从结构上破坏循环等待。
- 追问:
tryLock超时为什么只能降低风险? 多锁流程还必须在失败时释放已持有锁、退避并保证业务回滚,否则可能活锁或资源泄漏。 - 追问:数据库死锁 JVM 能自动报告吗? 通常不能,需结合数据库事务锁视图和 SQL/事务日志。
- 追问:怎样区分死锁、活锁和饥饿? 死锁互等且不推进,活锁线程不断动作却互相让步,饥饿则是某线程长期得不到调度或资源。
九、加强记忆
死锁用“互斥、占有等待、不可剥夺、循环等待”四条件定位,本质可画成资源等待图中的环。工程上优先统一锁顺序、减少嵌套持锁和缩短临界区;需要可中断或超时时再选择 ReentrantLock.tryLock,并正确释放已拿到的锁。线上用多次线程转储还原拥有者与等待者,JVM 锁、数据库锁和分布式锁要分别取证。最后把活锁和饥饿区分开:一个是不断重试却没进展,一个是特定参与者长期拿不到机会。