凌晨两点的死锁:当线程“互相等死“

凌晨两点,报警群又炸了。

"杨工!订单批量更新接口卡死了!" 小刘的声音带着哭腔,"用户点了提交,转圈转了五分钟还没反应!"

我冲回工位,扫了一眼监控------线程池活跃线程数已经顶到了 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。"

我:"对,这就是根因。两个线程以不同顺序拿锁,形成了死锁环。"


第二幕:死锁的四个必要条件(必背)

小刘:"杨工,死锁到底是怎么形成的?"

我:"四个条件同时满足才会死锁,缺一不可:

  1. 互斥:锁同一时刻只能被一个线程持有。
  2. 占有并等待:线程持有锁 A 的同时,还在等待锁 B。
  3. 不可抢占:锁不能被其他线程强行夺走,只能由持有者主动释放。
  4. 循环等待:线程 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 死锁的常见根因有三个:

  1. 事务内以不同顺序更新多行------和 Java 死锁一样,破坏加锁顺序即可。
  2. 条件列没有索引,导致锁范围扩大------本来想锁一行,结果因为没走索引,行锁变成了多行锁甚至间隙锁,锁的范围变大了就容易冲突。
  3. 大事务持锁时间长------事务越大,持锁越久,冲突概率越高。"

小刘:"怎么解?"

我:"四条:

  1. 统一更新顺序(比如按主键排序)。
  2. 给 WHERE 条件补索引,避免锁范围扩大。
  3. 拆小事务,减少持锁时间。
  4. 应用层捕获死锁错误后重试,但要注意幂等------重试不能产生重复数据。"

尾声

凌晨四点,代码修复上线,线程池恢复了正常,CPU 回到了 30%。

小刘:"杨工,今天学到的东西,够我吹一年面试了。"

我 :"记住,排查死锁就像破案:先看现象(CPU 低 + 线程池满)→ 抓线程栈(jstack -l 连抓三次)→ 找死锁指纹(Found one Java-level deadlock)→ 回到代码看锁顺序。套路熟了,凌晨就不慌了。"

他点点头,补了一句:"还有,以后写锁我一定先想清楚顺序。"

我笑了:"这才是今晚最大的收获。"


天亮了,监控大屏恢复绿色。但我知道,下一场"凌晨战役"迟早会来。

不过没关系,套路熟了,就不怕了。

相关推荐
ThinkerQAQ_1 小时前
并发编程(八):读写锁——从语言规则到 CPU
后端
lizhongxuan1 小时前
让 AI 提效转化为团队交付
后端
Είναι η κοπέλα1 小时前
llama.cpp 与 GGUF 格式:本地大模型的“裸引擎“
开发语言·人工智能·pytorch·python·conda
颜进强2 小时前
23 · NestJs ModuleRef 模块引用:容器递给你的"取货窗口",四个 API 四种语义
前端·后端·ai编程
captain3762 小时前
Maven
java·后端·idea
hsfxuebao2 小时前
Loop Engineering 已死? 一文带你了解Graph Engineering
人工智能·后端
大草原的小灰灰2 小时前
Python面向对象编程
开发语言·python
知识分享小能手2 小时前
C学习教程,从入门到精通,C语言概述 —— 知识点详解(1)
c语言·开发语言·学习
颜进强2 小时前
22 · NestJs InjectionScopes 注入作用域:默认单例不是偷懒,是最优——以及何时才该打破
前端·后端·ai编程