Java / JVM 排查 列表全部文章
Java / JVM 排查

应用“假死”:CPU 2%,请求全挂,jstack 一眼看出两个线程在互相“谦让”

服务进程在、端口通、健康检查也过,但所有业务请求都卡住,CPU 只有 2%。jstack 直接报出 Found one Java-level deadlock。本文含最小可复现代码、输出逐行解读,以及锁顺序统一、tryLock 超时、缩小锁粒度三种修法。

更多

周三下午,客服群里突然一片“转圈圈”:“用户说提交订单一直转,我这边后台也点不动。”我打开监控:进程存活,端口在监听,健康检查探针返回 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(取消订单回补库存)。一个是“先订单后库存”,一个是“先库存后订单”。

二、排查路线图

排查路线 ① top -Hp:无线程占 CPU(排除死循环) ② jstack:大量 BLOCKED(on object monitor) ③ 看输出末尾:Found one Java-level deadlock ④ 对照两条线程的栈,找出加锁顺序冲突 这次的死锁:交叉加锁 线程 12:下单持有【库存锁】 线程 31:取消持有【订单锁】 订单锁 库存锁 想要 想要 被对方持有 互相等待,谁也不放手 → 永久阻塞

三、动手:写一个最小复现,把 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,事务提交在锁释放之后,会带来另一类并发问题(这个留给以后的“事务”篇)。

五、止血与修复

止血

  1. 保存现场:两份 jstack(相隔 10 秒,确认死锁线程 id 没变化,说明不是“慢”而是“死”);
  2. 重启是唯一办法:synchronized 死锁无法从外部解开——没有 kill 某个线程的安全 API,Thread.stop 早已被废弃且危险。滚动重启,一次一台;
  3. 让健康检查“更诚实”:/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 deadlockJVM 自带死锁检测,一条命令就出结论,比猜快得多
用 jstack -l 或 jcmd Thread.print -lReentrantLock 等可拥有同步器的死锁,需要 -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 不知道哪个线程更委屈。”——它俩当时的状态大概是:“你先请。”“不,你先请。”“我真的不急。”“我也不急。”然后,就是一下午。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人