Java 中什么情况会导致死锁?如何避免?
- 标签:Java并发、Java
🏛 业务架构师
死锁的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。业务上最容易制造死锁的是两个锁的加锁顺序不一致:
经典场景——转账:事务 A(转账:先锁账户 1 再锁账户 2)与事务 B(退款:先锁 2 再锁 1)并发,互相持有对方要的锁,永久等待。数据库里的批量更新顺序不一致(A 按 ID 升序、B 按降序)同理。
我的预防与治理体系:
事前(设计期,成本最低)
- 统一加锁顺序协议:所有业务路径按同一规则排序(账户 ID 升序、订单号排序)再逐个加锁——这是业务协议,要写进设计文档与代码规约,靠约定消灭循环等待;
- 锁的持有时间最小化:锁内禁止远程调用、大计算、IO;事务里的 RPC 是头号杀手;
- 一次拿全所需锁,避免"拿着 A 去申请 B"的渐进式获取。
事中(运行期)
- 数据库靠 InnoDB 死锁检测自动回滚一方,业务捕获死锁异常并有限重试(指数退避)——这是必须写的韧性代码;
- JVM 锁用 tryLock(timeout) 替代无限 lock,拿不到就放弃并释放已持有的锁,破坏"持有并等待";
- Java 排查:jstack 看 "Found one Java-level deadlock",数据库看
SHOW ENGINE INNODB STATUS的 LATEST DETECTED DEADLOCK。
事后(治理)
- 死锁率监控告警——突增通常对应某次业务变更(新查询路径、批任务改时段),把死锁当业务信号而不只是技术事件。
一句话:死锁的解药一半在协议(顺序),一半在韧性(超时+重试),两者都要制度化。
🏗 系统架构师
死锁产生的四个必要条件:互斥、持有并等待、不可剥夺、循环等待——四个同时成立才死锁,工程上打破任意一个即可。
Java 里典型场景:嵌套获取多把锁顺序不一致(A 线程先锁 X 再锁 Y,B 线程先 Y 再 X);线程池场景的相互等待任务(父任务提交子任务并 get(),子任务排在队列里永远轮不到执行);两把数据库行锁交叉更新。
我的预防与治理:
- 统一加锁顺序(打破循环等待,最常用):所有代码路径按全局顺序拿锁,批量操作先排序再加锁;
- tryLock 带超时(打破不可剥夺):
lock.tryLock(1, TimeUnit.SECONDS)失败释放已持有的锁退避重试,死锁退化为重试; - 缩小锁范围、锁内不做耗时操作(RPC、IO),减少交叉窗口;
- 线程池隔离 + 拒绝策略,杜绝任务互相等待;Java 21 虚拟线程下同步块内再阻塞的 pinning 问题也要注意;
- 开放调用(alien method 移出锁)、用不可变对象减少共享。
排查工具:jstack 直接标注 "Found one Java-level deadlock";ThreadMXBean.findDeadlockedThreads() 可做运行时检测自动告警。数据库侧则靠 InnoDB 死锁检测自动回滚 + 应用重试。原则:用超时把死锁从故障降级为可重试错误,是架构韧性的一部分。
💻 全栈开发专家
死锁四条件:互斥、持有并等待、不可剥夺、循环等待——同时成立才死锁。代码层面就是两个线程互相持有对方需要的锁且都不放手。
java
// 经典互相加锁
Thread1: lock(A) → 请求 lock(B)
Thread2: lock(B) → 请求 lock(A)避免手段(按工程频率):
- 统一加锁顺序:所有线程都按 A→B 顺序拿锁,循环等待被打破。批量操作先按主键排序再逐条锁;
- tryLock 带超时:拿不到就放弃已持有的锁并重试,破坏"持有并等待":
java
if (lockA.tryLock(1, TimeUnit.SECONDS)) {
try {
if (lockB.tryLock(1, TimeUnit.SECONDS)) { try { doWork(); } finally { lockB.unlock(); } }
} finally { lockA.unlock(); }
}- 缩小锁范围、锁内不做 IO/RPC/sleep;
- 减少锁粒度:分段锁、
ConcurrentHashMap、LongAdder代替同步计数; - 用无锁结构(CAS、阻塞队列)重构。
排查(实操命令):
bash
jstack <pid> | grep -A 20 "Found one Java-level deadlock" # 直接给出死锁对
# 或用 arthas:thread -b 一键找阻塞源头线上预防:压测覆盖并发路径;对外部调用(DB 行锁死锁)catch 死锁异常(SQLState 40001)做自动重试;监控线程 BLOCKED 数量突增。