面试考察点
- 能否说出死锁四个必要条件。
- 是否会通过线程转储识别锁等待环。
- 能否给出可落地的预防策略。
核心答案
死锁需要互斥、占有且等待、不可抢占和循环等待同时成立。线上先保存多次线程转储,确认线程长期停在同一锁关系,并查看 JVM 是否报告 Java-level deadlock;修复通常通过统一锁顺序、缩小锁范围或超时获取锁破坏条件。
如果涉及数据库、分布式锁和 JVM 锁,还要把各层等待关系放在一起分析,单份 Java 堆栈可能看不到完整等待环。
定位步骤
先确认请求停滞和线程池占用,再使用 jstack、jcmd Thread.print 或监控平台获取线程转储。找到 BLOCKED 或等待锁的线程、它等待的锁以及锁持有者,沿关系检查是否形成环。
一个最小死锁示例
Object left = new Object();
Object right = new Object();
// 线程 1
synchronized (left) {
synchronized (right) { }
}
// 线程 2
synchronized (right) {
synchronized (left) { }
}
若两个线程各自拿到第一把锁后再等待对方,就满足循环等待。实际系统中锁可能是账户锁、缓存 Key 锁、数据库行锁、连接池许可或分布式锁,等待环跨越组件时更难发现。
如何读线程转储
线程转储中关注三部分:线程当前状态、waiting to lock 的对象标识,以及 locked 的对象标识。JVM 有时会在末尾直接报告 “Found one Java-level deadlock”,但它只能识别 JVM 监视器或可识别同步器中的环,无法看到 HTTP、数据库或消息系统中的等待。
连续采样可以区分死锁和慢操作:死锁线程的锁等待关系长期不变;慢 SQL 可能最终返回,线程栈和持锁对象随时间变化。生产排查应同时保存应用 trace、数据库事务与锁等待、线程池队列长度。
用锁顺序破坏循环等待
void transfer(Account a, Account b, long amount) {
Account first = a.id() < b.id() ? a : b;
Account second = a.id() < b.id() ? b : a;
synchronized (first) {
synchronized (second) {
doTransfer(a, b, amount);
}
}
}
全局排序可以是 ID、资源类型加 ID,必须对所有调用路径一致。相同 ID 的边界也要处理,避免两个相等资源导致排序退化。若资源动态集合很大,锁顺序规则需要作为架构契约记录下来。
tryLock 与超时
ReentrantLock 的 tryLock(timeout) 能让线程在拿不到第二把锁时退出、释放已持有锁并重试或报错。它不是自动修复:失败分支必须完整释放资源,重试需要抖动和上限,否则大量线程可能同步重试形成活锁。
不只是死锁:饥饿与活锁
线程池饥饿是任务等待同一线程池内尚未运行的任务;活锁是线程都在运行和重试,却没有任何实际进展;优先级不公平还可能导致低优先级任务长期拿不到资源。排障与治理方式不同,不能把所有无响应都归为死锁。
预防策略
- 多把锁按全局稳定顺序获取,按逆序释放。
- 锁内不做网络 I/O、回调和不可控耗时操作。
- 使用
tryLock加超时并在失败时完整回滚已持有资源。 - 能用单一所有者、消息传递或并发容器时减少显式锁。
常见误区
线程都不动不一定是死锁,也可能是下游超时、线程池饥饿或长时间 GC。tryLock 只避免无限等待,若失败后没有释放已持有锁仍可能造成问题。
高频追问与参考回答
追问:数据库死锁为什么有时会自动恢复?
数据库能检测事务等待图并选择牺牲者回滚,从而打破环;应用仍需捕获对应错误、保证幂等并有限重试,同时修正不一致的访问顺序。
追问:synchronized 死锁能设置超时吗?
不能直接设置获取监视器锁的超时。需要超时语义时可使用 ReentrantLock.tryLock,或重新设计为消息串行化、分段锁等模型。
追问:如何在线上自动检测 JVM 死锁?
可通过 ThreadMXBean 的死锁检测 API、JFR 或监控平台代理采集;自动检测后应记录转储并告警,谨慎自动重启,先评估是否会影响正在执行的写操作。
追问:固定锁顺序能解决数据库死锁吗?
对同一业务访问多行时保持一致顺序可显著降低概率,但索引范围、隔离级别、间隙锁和其他事务路径仍可能形成等待环,必须结合数据库执行计划治理。
总结
定位靠等待图证据,预防靠锁顺序、短临界区和可失败获取,不能把所有“卡住”都直接归因于死锁。
机制全景图
下面把「如何定位和避免 Java 死锁?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。
flowchart LR
A["线程持有第一把锁"]
A --> B["请求第二把锁"]
B --> C["形成等待依赖边"]
C --> D["依赖环闭合"]
D --> E["检测并中断或重启恢复"]
完整链路:从输入到结果
沿着「线程持有第一把锁 → 请求第二把锁 → 形成等待依赖边 → 依赖环闭合 → 检测并中断或重启恢复」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。
1. 线程持有第一把锁
死锁需要互斥、持有并等待、不可剥夺和循环等待四个条件同时成立。
2. 请求第二把锁
线程在持有资源时请求另一资源,会在 wait-for graph 中形成有向边。
3. 形成等待依赖边
多个锁顺序不一致最常见,数据库行锁、连接池与应用锁也可能跨层组成环。
4. 依赖环闭合
环形成后线程无法自行推进,CPU 可能不高,但请求堆积和连接占用持续上升。
5. 检测并中断或重启恢复
jstack、JFR 或 ThreadMXBean 可识别 Java monitor/ownable synchronizer,外部资源死锁还需联合其他系统证据。
源码与实现定位
| 入口 | 阅读重点 |
|---|---|
| ThreadMXBean#findDeadlockedThreads | JVM 可拥有同步器死锁检测 |
| jcmd Thread.print -l | 锁拥有者与等待者现场 |
源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。
参数配置与可复现实验
long first = Math.min(fromId, toId);
long second = Math.max(fromId, toId);
lock(first); lock(second);
用 CountDownLatch 固定两个线程反向拿锁,确认检测器识别;修复后循环 10 万次并注入 tryLock 超时。
验证步骤与预期结果
1. 固定输入和基线
先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「死锁线程数」为主基线,记录值应满足「必须 0」;同时保存 BLOCKED 线程数、锁拥有者与等待者,使后续变化能够回到同一时间轴比较。
2. 从实现入口确认路径
在「ThreadMXBean#findDeadlockedThreads」确认请求确实进入「JVM 可拥有同步器死锁检测」对应的实现,再沿「jcmd Thread.print -l」观察「锁拥有者与等待者现场」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。
3. 注入本文特有的失败模式
优先复现「锁顺序在不同代码路径不一致」,并把单一变量逐级放大,直到「死锁线程数」越过「>0」。随后再分别验证「锁内调用外部服务形成未知依赖」和「线程池与连接池互相等待形成资源死锁」,三类故障分开执行,避免多个变量同时变化而无法归因。
4. 执行止损和根因修复
第一轮只应用「全局排序加锁」,确认它能控制影响范围;第二轮应用「锁内禁止远程 I/O」,验证核心链路恢复;最后落实「超时后完整回滚并幂等重试」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。
5. 通过退出条件
实验只有同时满足三项才算通过:「死锁线程数」回到「必须 0」、「锁等待 P99」回到「低于事务预算」、「超时重试率」回到「低个位数」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。
量化基线
| 指标 | 样例基线/口径 | 风险线 | 结论 |
|---|---|---|---|
| 死锁线程数 | 必须 0 | >0 | 立即摘流量 |
| 锁等待 P99 | 低于事务预算 | 持续增长 | 锁顺序/长事务 |
| 超时重试率 | 低个位数 | 形成风暴 | 退避和限次 |
这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。
事故复盘:转账接口请求全部卡住
两个线程分别锁定账户 A、B 后再请求对方账户,构成经典循环等待。按账户 ID 全局排序获取锁可以破坏环;同时增加 tryLock 超时与事务回滚,避免未来新增资源时无限等待。
| 失败模式 | 首要证据 | 第一处置动作 |
|---|---|---|
| 锁顺序在不同代码路径不一致 | BLOCKED 线程数 | 全局排序加锁 |
| 锁内调用外部服务形成未知依赖 | 锁拥有者与等待者 | 锁内禁止远程 I/O |
| 线程池与连接池互相等待形成资源死锁 | 请求在途与连接占用 | 超时后完整回滚并幂等重试 |
发布与回滚检查点
- 发布前:确认「ThreadMXBean#findDeadlockedThreads」对应实现和上述配置在目标版本仍然有效,并保存「死锁线程数」基线。
- 灰度中:同时观察 BLOCKED 线程数、锁拥有者与等待者、请求在途与连接占用;任一指标越过表中风险线,就停止继续扩量。
- 回滚时:先执行「全局排序加锁」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
- 发布后:至少覆盖一个完整峰值周期,确认「锁顺序在不同代码路径不一致」没有再次出现,才关闭变更观察窗口。
方案对比与选型
| 方案 | 更适合的场景 | 主要收益 | 代价与边界 |
|---|---|---|---|
| 全局锁顺序 | 同类多资源加锁 | 从设计上消除循环等待 | 所有调用方必须遵守 |
| tryLock 超时 | 无法完全控制获取顺序 | 故障可恢复、避免无限卡住 | 需要回滚和重试幂等 |
| 单线程所有者/消息串行化 | 按键操作可分区 | 不需要多锁组合 | 吞吐取决于分区且有排队 |
选型至少带上 并发线程数、临界区长度、阻塞比例和任务到达速率,并用上面的量化基线验证;未知数据应明确为待测假设。
设计边界与工程取舍
超时只能让系统从等待中退出,不证明业务状态完整;恢复路径必须释放已持有资源,并确保重试不会重复产生副作用。
工程落地遵循:先建立 happens-before 与所有权边界,再谈吞吐和无锁优化。回答时直接引用「ThreadMXBean#findDeadlockedThreads」、配置实验和事故数据,比复述固定模板更有说服力。