凌晨两点,报警群又炸了。
"杨工!订单批量更新接口卡死了!" 小刘的声音带着哭腔,"用户点了提交,转圈转了五分钟还没反应!"
我冲回工位,扫了一眼监控------线程池活跃线程数已经顶到了 200,队列积压了 3000+ 个任务,但 CPU 使用率只有 5%。
"别慌。" 我拉过一把椅子,"CPU 低、线程池满、请求不返回------这是典型的死锁特征。"
小刘:"死锁?但 CPU 明明很低啊......"
我 :"这就是死锁最反直觉的地方。死锁的线程都卡在 BLOCKED 或 WAITING 状态,不消耗 CPU,只是互相等着对方释放锁。所以 CPU 越低,越要怀疑死锁。"
第一幕:抓现行------jstack 连抓三次
我:"先别重启,重启会丢现场。抓线程栈。"
jstack -l 12345 > thread_dump_1.txt
sleep 5
jstack -l 12345 > thread_dump_2.txt
sleep 5
jstack -l 12345 > thread_dump_3.txt
小刘:"为什么要连抓三次?"
我 :"单次快照可能是巧合。连续三次都卡在同一个地方,才能确认是死锁。另外 -l 参数必须带,它会输出 JUC 锁的信息,ReentrantLock 场景下不加 -l 看不到持锁关系。"
五分钟后,小刘把三份文件甩给我:"三次都卡住了!而且文件末尾有这个东西------"
Found one Java-level deadlock:
=============================
"thread-order-update-1":
waiting to lock monitor 0x00007f8a1c004e00 (object 0x000000076ab12340, a java.lang.Object),
which is held by "thread-order-update-2"
"thread-order-update-2":
waiting to lock monitor 0x00007f8a1c004d80 (object 0x000000076ab12300, a java.lang.Object),
which is held by "thread-order-update-1"
我:"找到了。线程 1 持有锁 A,等锁 B;线程 2 持有锁 B,等锁 A。典型的循环等待。"
小刘:"怎么定位到代码?"
我 :"顺着 waiting to lock 往下找,看每个线程的栈帧,找到业务代码行号。"
"thread-order-update-1":
at com.example.OrderService.updateOrder(OrderService.java:45)
- waiting to lock <0x000000076ab12340> (a java.lang.Object)
- locked <0x000000076ab12300> (a java.lang.Object)
at com.example.OrderController.batchUpdate(OrderController.java:28)
小刘:"找到了!第 45 行,先锁订单 A 再锁订单 B。但另一个线程是反过来的------先锁 B 再锁 A。"
我:"对,这就是根因。两个线程以不同顺序拿锁,形成了死锁环。"
第二幕:死锁的四个必要条件(必背)
小刘:"杨工,死锁到底是怎么形成的?"
我:"四个条件同时满足才会死锁,缺一不可:
- 互斥:锁同一时刻只能被一个线程持有。
- 占有并等待:线程持有锁 A 的同时,还在等待锁 B。
- 不可抢占:锁不能被其他线程强行夺走,只能由持有者主动释放。
- 循环等待:线程 1 等线程 2,线程 2 等线程 1,形成闭环。"
小刘:"那预防死锁就是破坏其中任意一条?"
我:"没错。写代码时破坏任意一条即可。"
第三幕:预防写法(体现工程素养)
我:"回到刚才的代码,怎么改?"
小刘:"让它们按同一个顺序拿锁?"
我 :"对,固定加锁顺序。比如批量更新订单时,先按订单 ID 排序,再依次加锁。这样所有线程都按 A→B 的顺序拿锁,就不会出现循环等待。"
我把预防清单推给他:
| 预防手段 | 破坏的条件 | 说明 |
|---|---|---|
| 固定加锁顺序 | 循环等待 | 所有路径按同一顺序拿锁,批量更新按主键排序 |
| 锁内不做慢操作 | 占有并等待时间过长 | RPC、慢 SQL、文件 IO 挪到锁外,减少持锁时间 |
tryLock(timeout) |
不可抢占 | 拿不到锁就超时释放已持有的锁,然后重试 |
| 少混用多套锁体系 | 循环等待 | synchronized 和 ReentrantLock 混用容易出问题 |
| 合并锁 | 占有并等待 | 两把总是一起出现的锁,考虑合并成一把 |
小刘:"那线上这个死锁怎么办?"
我 :"线上死锁基本无法在线解开,只能重启止血 + 修代码 。不过有个兜底方案可以提一句:JDK 的 ThreadMXBean.findDeadlockedThreads() 可以做在线检测,适合做成诊断接口,方便排查。"
第四幕:MySQL 死锁------另一种"互相等死"
刚修完 Java 死锁,小刘又跑过来:"杨工!日志里又报错了------Deadlock found when trying to get lock; try restarting transaction"
我:"这是数据库死锁,和 Java 死锁不一样。先看最近一次死锁详情:
SHOW ENGINE INNODB STATUS\G
找到 LATEST DETECTED DEADLOCK 段,里面会显示两个事务各自持有和等待的锁。"
小刘:"出来了!事务 A 持有行锁 1,等待行锁 2;事务 B 持有行锁 2,等待行锁 1。"
我:"MySQL 死锁的常见根因有三个:
- 事务内以不同顺序更新多行------和 Java 死锁一样,破坏加锁顺序即可。
- 条件列没有索引,导致锁范围扩大------本来想锁一行,结果因为没走索引,行锁变成了多行锁甚至间隙锁,锁的范围变大了就容易冲突。
- 大事务持锁时间长------事务越大,持锁越久,冲突概率越高。"
小刘:"怎么解?"
我:"四条:
- 统一更新顺序(比如按主键排序)。
- 给 WHERE 条件补索引,避免锁范围扩大。
- 拆小事务,减少持锁时间。
- 应用层捕获死锁错误后重试,但要注意幂等------重试不能产生重复数据。"
尾声
凌晨四点,代码修复上线,线程池恢复了正常,CPU 回到了 30%。
小刘:"杨工,今天学到的东西,够我吹一年面试了。"
我 :"记住,排查死锁就像破案:先看现象(CPU 低 + 线程池满)→ 抓线程栈(jstack -l 连抓三次)→ 找死锁指纹(Found one Java-level deadlock)→ 回到代码看锁顺序。套路熟了,凌晨就不慌了。"
他点点头,补了一句:"还有,以后写锁我一定先想清楚顺序。"
我笑了:"这才是今晚最大的收获。"
天亮了,监控大屏恢复绿色。但我知道,下一场"凌晨战役"迟早会来。
不过没关系,套路熟了,就不怕了。