周三下午,客服群里突然一片“转圈圈”:“用户说提交订单一直转,我这边后台也点不动。”我打开监控:进程存活,端口在监听,健康检查探针返回 200(因为它只访问一个不加锁的 /ping),CPU 2%,内存正常,GC 无异常。整套仪表盘一片祥和,就像一个人躺在床上眼睛睁着,但叫他不答应。
这种“看起来一切正常,实际上什么都干不了”的状态,我们通常叫它假死。它有几个常见嫌疑人:线程池被打满(上一篇讲过)、连接池耗尽、外部依赖卡住,还有一个古老而经典的:死锁。好消息是,死锁是少数“JVM 会直接告诉你答案”的问题之一——只要你想起来问它。
一、现象:进程活着,但业务线程都不动了
先把“在等下游”和“死锁”区分开。判断办法很朴素:看 CPU 和线程栈。
$ top -Hp 4187 -n 1 | head -12
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
4187 app 20 0 6231204 1.9g 25412 S 2.0 12.1 18:04.31 java
4203 app 20 0 6231204 1.9g 25412 S 1.7 12.1 0:31.02 VM Periodic Tas
4211 app 20 0 6231204 1.9g 25412 S 0.3 12.1 0:04.87 C2 CompilerThre
...(没有任何业务线程在吃 CPU)
没有线程在烧 CPU,说明不是“死循环”或“正则回溯”那类问题;线程都在等。再看 Tomcat 线程状态,如果大量线程都是 BLOCKED,那就要警惕锁问题了:
$ jstack 4187 > /data/dumps/jstack-1.txt
$ grep 'java.lang.Thread.State' /data/dumps/jstack-1.txt | sort | uniq -c | sort -rn
96 java.lang.Thread.State: BLOCKED (on object monitor)
41 java.lang.Thread.State: WAITING (parking)
12 java.lang.Thread.State: TIMED_WAITING (parking)
9 java.lang.Thread.State: RUNNABLE
96 个线程 BLOCKED (on object monitor)——它们在排队等 synchronized 锁。如果只是“锁竞争激烈”,持有锁的线程应该在 RUNNABLE 地干活,CPU 会有波动;现在持有锁的线程自己也在等别的锁,那就是死锁的嫌疑了。而 jstack 有一个非常贴心的功能:在输出末尾,它会自动做死锁检测。
$ jstack 4187 | grep -A 40 'Found one Java-level deadlock'
Found one Java-level deadlock:
=============================
"http-nio-8080-exec-12":
waiting to lock monitor 0x00007f3c1c004e00 (object 0x00000006c1a2b340, a java.lang.String),
which is held by "http-nio-8080-exec-31"
"http-nio-8080-exec-31":
waiting to lock monitor 0x00007f3c1c007750 (object 0x00000006c1a2b380, a java.lang.String),
which is held by "http-nio-8080-exec-12"
Java stack information for the threads listed above:
===================================================
"http-nio-8080-exec-12":
at com.demo.trade.service.StockService.deduct(StockService.java:52)
- waiting to lock <0x00000006c1a2b340> (a java.lang.String)
- locked <0x00000006c1a2b380> (a java.lang.String)
at com.demo.trade.service.OrderService.create(OrderService.java:88)
at com.demo.trade.web.OrderController.submit(OrderController.java:34)
"http-nio-8080-exec-31":
at com.demo.trade.service.OrderService.cancelAndRestock(OrderService.java:141)
- waiting to lock <0x00000006c1a2b380> (a java.lang.String)
- locked <0x00000006c1a2b340> (a java.lang.String)
at com.demo.trade.web.OrderController.cancel(OrderController.java:61)
Found 1 deadlock.
这就是本文的标题所说的“一眼看出”:JVM 直接给出了两个线程的循环等待。逐段读一遍:
"exec-12": waiting to lock monitor ...b340, which is held by "exec-31":线程 12 想拿b340这把锁,它现在被线程 31 持有;"exec-31": waiting to lock monitor ...b380, which is held by "exec-12":线程 31 想拿b380,它现在被线程 12 持有——闭环;- 下面的“Java stack information”给出了每个线程的栈:
- locked <...b380>表示“我已经持有了这把”,- waiting to lock <...b340>表示“我正在等这把”。注意读法:同一个线程里,先出现的(更靠上)通常是最近发生的动作,所以waiting to lock在上、locked在下,表示它先拿到了下面那把,再去够上面那把; - 业务栈帧告诉你冲突发生在哪里:线程 12 在
OrderService.create → StockService.deduct(下单扣库存),线程 31 在OrderService.cancelAndRestock(取消订单回补库存)。一个是“先订单后库存”,一个是“先库存后订单”。
二、排查路线图
三、动手:写一个最小复现,把 jstack 输出练熟
线上不能随便拿来练手。下面这段代码 30 行以内、能稳定复现死锁,很适合作为团队分享的例子。它和线上问题同构:两个线程以相反的顺序获取两把锁。
public class DeadlockDemo {
private static final Object ORDER_LOCK = new Object();
private static final Object STOCK_LOCK = new Object();
public static void main(String[] args) {
Thread create = new Thread(() -> {
synchronized (STOCK_LOCK) { // 下单:先库存
sleep(100); // 放大竞争窗口,让另一个线程有机会拿到第二把锁
synchronized (ORDER_LOCK) { // 再订单
System.out.println("create done");
}
}
}, "create-order");
Thread cancel = new Thread(() -> {
synchronized (ORDER_LOCK) { // 取消:先订单
sleep(100);
synchronized (STOCK_LOCK) { // 再库存
System.out.println("cancel done");
}
}
}, "cancel-order");
create.start();
cancel.start();
}
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}编译运行后,程序不会退出(两个线程都不是守护线程)。另开一个终端,用 jps 找到 pid,再 jstack:
$ javac DeadlockDemo.java && java DeadlockDemo &
$ jps -l
15320 DeadlockDemo
$ jstack 15320
...
Found one Java-level deadlock:
=============================
"cancel-order":
waiting to lock monitor 0x000001d7e9b2d2d8 (object 0x00000000d5f7a3c0, a java.lang.Object),
which is held by "create-order"
"create-order":
waiting to lock monitor 0x000001d7e9b2f4a8 (object 0x00000000d5f7a3d0, a java.lang.Object),
which is held by "cancel-order"
Java stack information for the threads listed above:
===================================================
"cancel-order":
at DeadlockDemo.lambda$main$1(DeadlockDemo.java:21)
- waiting to lock <0x00000000d5f7a3c0> (a java.lang.Object)
- locked <0x00000000d5f7a3d0> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:750)
"create-order":
at DeadlockDemo.lambda$main$0(DeadlockDemo.java:12)
- waiting to lock <0x00000000d5f7a3d0> (a java.lang.Object)
- locked <0x00000000d5f7a3c0> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:750)
Found 1 deadlock.官方 Java 教程里的经典死锁例子 Alphonse 与 Gaston(两个人鞠躬,各自等对方先“回礼”)讲的是同一件事:每个线程都持有一把锁,同时等待对方手里的那把,谁都不松手。这里的输出格式和线上那份完全一样。
ReentrantLock 引起的死锁,jstack 一样能看
如果用的是 java.util.concurrent.locks.ReentrantLock 而不是 synchronized,线程状态会是 WAITING (parking),而不是 BLOCKED。这时必须加 -l(long listing)参数,jstack 才会打印“Locked ownable synchronizers”并把这类死锁一并检测出来:
$ jstack -l 15320
"cancel-order" #13 prio=5 os_prio=0 tid=0x000001d7e9a8b000 nid=0x2f0c waiting on condition [0x000000de3c6ff000]
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000000d6a3c2e8> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
...
Locked ownable synchronizers:
- <0x00000000d6a3c318> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
Found one Java-level deadlock:
=============================
"cancel-order":
waiting for ownable synchronizer 0x00000000d6a3c2e8, (a java.util.concurrent.locks.ReentrantLock$NonfairSync),
which is held by "create-order"
...编程里对应的 API 是 ThreadMXBean:官方文档明确区分了两个方法——findMonitorDeadlockedThreads() 只检测 对象监视器(synchronized)造成的死锁;findDeadlockedThreads() 同时检测监视器和可拥有同步器(如 ReentrantLock),应当优先使用后者。两者在没有死锁时都返回 null,检测代价较高,别高频调用。
// 一个简单的“死锁哨兵”:每分钟检测一次,发现就打印并告警
ScheduledExecutorService ses = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "deadlock-watchdog"); t.setDaemon(true); return t; });
ThreadMXBean mx = ManagementFactory.getThreadMXBean();
ses.scheduleAtFixedRate(() -> {
long[] ids = mx.findDeadlockedThreads();
if (ids != null) {
for (ThreadInfo ti : mx.getThreadInfo(ids, true, true)) {
log.error("DEADLOCK detected: {}", ti); // 含锁持有信息与栈
}
alarm("deadlock detected, ids=" + Arrays.toString(ids));
}
}, 1, 1, TimeUnit.MINUTES);
四、根因:两条业务路径,相反的加锁顺序
回到线上代码,把两条路径拉出来对比(已脱敏):
// 下单:为了防止同一 SKU 超卖,先锁 SKU(库存),再锁订单号防重复提交
public void create(OrderDTO dto) {
synchronized (("SKU:" + dto.getSkuId()).intern()) { // 锁 1:库存
synchronized (("ORDER:" + dto.getOrderNo()).intern()) { // 锁 2:订单
stockService.deduct(dto.getSkuId(), dto.getQty());
orderMapper.insert(dto);
}
}
}
// 取消:为了防止并发取消,先锁订单,再回补库存要锁 SKU
public void cancelAndRestock(String orderNo) {
synchronized (("ORDER:" + orderNo).intern()) { // 锁 1:订单
Order o = orderMapper.selectByNo(orderNo);
synchronized (("SKU:" + o.getSkuId()).intern()) { // 锁 2:库存
stockService.restock(o.getSkuId(), o.getQty());
orderMapper.markCancelled(orderNo);
}
}
}
死锁需要同时满足四个条件(教科书里的“Coffman 条件”):互斥(锁一次只能一个线程持有)、持有并等待(拿着一把再去要另一把)、不可剥夺(synchronized 不能被外部强制释放)、循环等待(A 等 B,B 等 A)。这里四条全中。破坏其中任何一条,死锁就不可能发生,下面的三种修法各自对应一条:统一顺序破坏“循环等待”,tryLock 超时破坏“持有并等待/不可剥夺”,缩小锁粒度则是让“持有多把锁”这件事根本不出现。
顺带吐槽三个反模式:一是用 String.intern() 当锁对象,全局常量池里的字符串是共享的,别的代码块用同样的字符串字面量加锁就会和你冲突;二是同一个锁保护“查库 + 改库”的整个事务,锁持有时间被 IO 拉长,死锁窗口变宽;三是锁与事务的嵌套,方法里既有 synchronized 又有 @Transactional,事务提交在锁释放之后,会带来另一类并发问题(这个留给以后的“事务”篇)。
五、止血与修复
止血
- 保存现场:两份 jstack(相隔 10 秒,确认死锁线程 id 没变化,说明不是“慢”而是“死”);
- 重启是唯一办法:synchronized 死锁无法从外部解开——没有
kill某个线程的安全 API,Thread.stop早已被废弃且危险。滚动重启,一次一台; - 让健康检查“更诚实”:
/ping不加锁,所以死锁时依然返回 200,流量还在往死实例里灌。给健康检查加一个“业务线程池可用度”指标,或直接接入上面的死锁哨兵,检测到就返回 503,让负载均衡摘除实例(注意先在灰度验证,避免误杀)。
根治 1:统一锁的获取顺序(首选)
所有需要同时拿多把锁的路径,必须按同一个全局顺序加锁,例如约定“永远先订单、后库存”,或按锁对象的 id/字符串自然序排序:
// 需要同时锁 A、B 时,先排序再加锁,保证所有线程顺序一致
static void lockBoth(String a, String b, Runnable action) {
String first = a.compareTo(b) <= 0 ? a : b;
String second = first == a ? b : a;
synchronized (LOCKS.computeIfAbsent(first, k -> new Object())) { // LOCKS 是 ConcurrentHashMap<String, Object>,避免 intern
synchronized (LOCKS.computeIfAbsent(second, k -> new Object())) {
action.run();
}
}
}
根治 2:用 tryLock 加超时,拿不到就放弃并回滚
当锁顺序无法统一(比如锁对象来自不同模块、不同团队)时,用 ReentrantLock.tryLock(timeout, unit) 把“无限等待”变成“有限等待”。官方文档:超时前拿到锁返回 true,超时返回 false,等待期间被中断则抛 InterruptedException;带超时的版本遵守公平性设置,想“插队”可写成 tryLock() || tryLock(timeout, unit)。
boolean tryBoth(ReentrantLock l1, ReentrantLock l2, long timeoutMs, Runnable action) throws InterruptedException {
if (!l1.tryLock(timeoutMs, TimeUnit.MILLISECONDS)) return false; // 拿不到第一把:直接失败
try {
if (!l2.tryLock(timeoutMs, TimeUnit.MILLISECONDS)) return false; // 拿不到第二把:放弃,finally 会释放第一把
try {
action.run();
return true;
} finally {
l2.unlock();
}
} finally {
l1.unlock();
}
}
// 调用方:返回 false 时,对用户提示“系统繁忙,请重试”,或加随机退避后重试 1~2 次(避免活锁)
重试要注意“活锁”:两个线程都超时、都放弃、都立刻重试、又互相卡住。加一点随机退避(如 10~50ms)即可缓解。
根治 3:减少锁粒度,甚至不要在应用里锁
我们最终真正采用的方案,是把“锁”从 JVM 里拿掉。库存扣减本质上是数据库的一个原子更新,用条件更新就能保证不超卖,取消订单靠订单状态机的 CAS 保证幂等,根本不需要跨对象持有多把 JVM 锁:
-- 扣库存:一条语句完成检查 + 扣减,行锁由数据库持有,事务结束即释放
UPDATE t_stock SET available = available - #{qty}
WHERE sku_id = #{skuId} AND available >= #{qty};
-- affected rows = 0 表示库存不足
-- 取消订单:状态机 CAS,重复取消只会有一次生效
UPDATE t_order SET status = 'CANCELLED'
WHERE order_no = #{orderNo} AND status = 'CREATED';
数据库同样可能死锁(两个事务交叉更新不同行),但 MySQL InnoDB 会自动检测并回滚其中一个事务,抛出 Deadlock found when trying to get lock,业务重试即可——这比 JVM 里的死锁“温柔”得多,因为有人替你兜底。而且更新语句里保持“固定的更新顺序”(比如总是按主键升序更新多行),同样能显著降低死锁概率。
三种修法对照:
| 方案 | 破坏的条件 | 优点 | 代价 / 适用 |
|---|---|---|---|
| 统一加锁顺序 | 循环等待 | 零运行时开销,最彻底 | 需要全局约定与 Code Review;跨模块难统一 |
tryLock + 超时 | 持有并等待、不可剥夺 | 永不无限等待,可降级 | 失败需回滚/重试;要防活锁;只适用于 Lock 而不是 synchronized |
| 缩小粒度 / 把互斥交给 DB 原子操作 | 持有多把锁 | 模型最简单,天然支持多实例 | 需要业务能表达成单条原子更新或状态机 |
还有一个多实例时的现实提醒:JVM 里的 synchronized 只在单个进程内有效。我们有三个实例,靠 synchronized 防超卖本来就是自欺欺人,真正的互斥必须放到数据库或分布式锁上。这也是为什么我们最后选了方案 3。
验证
修复后,用 200 并发对“下单”和“取消”两条路径混合压测 10 分钟(脚本随机交错),跑完立刻 jstack 检查输出里没有 Found one Java-level deadlock,BLOCKED 线程数为个位;死锁哨兵一个月内零告警。
六、复盘:这类坑怎么提前躲开
| 做法 | 为什么 |
|---|---|
“CPU 低 + 请求卡死”的第一反应:jstack,直接看末尾有没有 Found one Java-level deadlock | JVM 自带死锁检测,一条命令就出结论,比猜快得多 |
用 jstack -l 或 jcmd Thread.print -l | ReentrantLock 等可拥有同步器的死锁,需要 -l 才能完整展示 |
多把锁必须约定全局顺序;代码里只要出现嵌套 synchronized,Review 时重点问“别处顺序一样吗” | 相反顺序加锁是最常见的死锁来源 |
能用 tryLock(timeout) 就别用无限等待的加锁 | 把“永久卡死”降级为“可观测的失败”,配合告警更好排查 |
不要用 String.intern() 或公共对象做锁 | 全局共享,容易被无关代码误用而制造冲突 |
| 集群场景把互斥下沉到 DB 原子更新 / 状态机 / 分布式锁 | JVM 锁只管单进程,多实例下既不安全又容易死锁 |
接入 ThreadMXBean.findDeadlockedThreads() 哨兵,健康检查纳入业务线程存活度 | 让死锁在第一分钟被发现,而不是在客服群里被发现 |
一句话备忘:CPU 低、请求卡死、大量 BLOCKED →
jstack(ReentrantLock 用-l)看末尾Found one Java-level deadlock→ 对照两个线程的locked/waiting to lock找出相反的加锁顺序 → 统一顺序、tryLock超时、缩小锁粒度。
复盘会上,实习生小声问:“死锁为什么不能像数据库那样自动选个牺牲者?”我说:“因为 JVM 不知道哪个线程更委屈。”——它俩当时的状态大概是:“你先请。”“不,你先请。”“我真的不急。”“我也不急。”然后,就是一下午。
参考资料
- The Java Tutorials:Deadlock——死锁的定义,以及 Alphonse 与 Gaston 互相鞠躬的经典示例。
- JDK 17 API:ReentrantLock——
tryLock(long, TimeUnit)的超时与公平性语义、lockInterruptibly的行为。 - JDK 17 API:ThreadMXBean——
findDeadlockedThreads与findMonitorDeadlockedThreads的区别(是否包含可拥有同步器)。 - JDK 17 jstack 手册——jstack 的用法、
-l选项,以及推荐的jcmd Thread.print替代。
一起聊聊