面试考察点

  • 能否说出死锁四个必要条件。
  • 是否会通过线程转储识别锁等待环。
  • 能否给出可落地的预防策略。

核心答案

死锁需要互斥、占有且等待、不可抢占和循环等待同时成立。线上先保存多次线程转储,确认线程长期停在同一锁关系,并查看 JVM 是否报告 Java-level deadlock;修复通常通过统一锁顺序、缩小锁范围或超时获取锁破坏条件。

如果涉及数据库、分布式锁和 JVM 锁,还要把各层等待关系放在一起分析,单份 Java 堆栈可能看不到完整等待环。

定位步骤

先确认请求停滞和线程池占用,再使用 jstackjcmd 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」、配置实验和事故数据,比复述固定模板更有说服力。