← 返回题目列表

什么是死锁?产生条件和如何避免?

高频 中等 第 4 / 31 题 更新于 2026/07/25
死锁并发

简化版

死锁是两个或多个线程互相持有对方需要的锁、都不释放,永远僵在那里的状态。它的产生要同时满足四个条件:互斥、持有并等待、不可剥夺、循环等待。破坏其中任意一个就能避免,最实用的是破坏「循环等待」——让所有线程按同样的固定顺序加锁

详细版

一个经典死锁例子

// 线程 A:先拿 lock1,再拿 lock2
synchronized (lock1) {
    synchronized (lock2) { }
}
// 线程 B:先拿 lock2,再拿 lock1  ← 加锁顺序相反!
synchronized (lock2) {
    synchronized (lock1) { }
}

A 拿着 lock1 等 lock2,B 拿着 lock2 等 lock1,谁也不让,死锁

死锁的四个必要条件(缺一不可,同时满足才会死锁):

  1. 互斥:资源同一时刻只能被一个线程占用。
  2. 持有并等待:线程持有一个资源的同时,又去请求别的资源,且不放手上的。
  3. 不可剥夺:线程拿到的资源,不能被别人强行抢走,只能自己释放。
  4. 循环等待:存在一个线程环,每个线程都在等下一个线程持有的资源(A等B、B等A)。

完整版教学

一、破坏四个条件 = 四种避免思路

因为四个条件必须同时成立才死锁,那么破坏任意一个就能避免。逐个看哪些可行:

  • 破坏互斥:让资源可共享。但很多资源天生互斥(如写操作),一般做不到
  • 破坏持有并等待:要求线程一次性申请到所有需要的锁,要么全拿到要么都不拿。可行但降低并发度。
  • 破坏不可剥夺:用可超时/可中断的锁——拿不到就主动释放已持有的锁再重试。ReentrantLock.tryLock(timeout) 就能做到。
  • 破坏循环等待:给所有锁规定一个全局顺序,所有线程都按同样顺序加锁。这样不可能形成环。这是最常用、最实用的办法。

二、最实用的解法:固定加锁顺序

回到上面的例子,死锁的根源是 A、B 加锁顺序相反。只要让所有线程都「先 lock1 再 lock2」,环就不可能形成:

// 两个线程都按同一顺序:先 lock1,再 lock2
synchronized (lock1) {
    synchronized (lock2) { }
}

实践中如果锁对象是动态的(比如转账时锁两个账户),可以按对象的某个唯一且稳定的属性排序(如账户 id、System.identityHashCode)来决定先锁哪个,保证所有线程顺序一致。

三、用 tryLock 超时避免

ReentrantLocktryLock(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 -ljstack -l 还能报告 Java 监视器死锁。但持续阻塞也可能来自数据库锁、分布式锁或本地资源,JVM 未必自动识别。

证据能回答的问题局限
多次线程转储等待关系是否持续、锁拥有者是谁主要覆盖 JVM 可见线程与锁
JFR 锁事件哪些锁竞争频繁、等待多久需合理录制配置
数据库锁视图事务等待与阻塞链不展示 Java 对象锁
分布式锁日志租约、持有者、续期依赖业务埋点和时钟

例如每隔 5 秒抓 3 次快照,若同一批线程始终卡在相同锁关系,证据比单次“恰好阻塞”更可靠。处理前先保留现场,再根据应急预案决定隔离、重启或终止事务。

记忆钩子:死锁本质是等待图出现环;预防是让环不可能形成,诊断则是把“谁持有什么、又在等什么”还原成图。

八、常见误区与追问

  • 误区:只要程序使用两把锁就一定会死锁。 必须让互斥、占有且等待、不可剥夺和循环等待同时成立才构成经典死锁。
  • 误区:给 synchronized 增加一个业务超时就能中断等锁。 等待进入 synchronized 监视器本身不可用 tryLock 式超时控制,需要改变锁方案或结构。
  • 误区:所有长时间 BLOCKED 都是死锁。 高竞争或锁内慢操作也会持续阻塞,必须检查等待图是否成环以及状态是否推进。
  • 追问:固定加锁顺序破坏了哪个条件? 它让等待边只能沿全序方向前进,从结构上破坏循环等待。
  • 追问:tryLock 超时为什么只能降低风险? 多锁流程还必须在失败时释放已持有锁、退避并保证业务回滚,否则可能活锁或资源泄漏。
  • 追问:数据库死锁 JVM 能自动报告吗? 通常不能,需结合数据库事务锁视图和 SQL/事务日志。
  • 追问:怎样区分死锁、活锁和饥饿? 死锁互等且不推进,活锁线程不断动作却互相让步,饥饿则是某线程长期得不到调度或资源。

九、加强记忆

死锁用“互斥、占有等待、不可剥夺、循环等待”四条件定位,本质可画成资源等待图中的环。工程上优先统一锁顺序、减少嵌套持锁和缩短临界区;需要可中断或超时时再选择 ReentrantLock.tryLock,并正确释放已拿到的锁。线上用多次线程转储还原拥有者与等待者,JVM 锁、数据库锁和分布式锁要分别取证。最后把活锁和饥饿区分开:一个是不断重试却没进展,一个是特定参与者长期拿不到机会。