DevFix
Java已验证

Java 死锁排查:jstack 报 Found one Java-level deadlock 的定位与解决

@debug_master更新于 3 天前阅读 6 min0

一句话先说结论:死锁不是异常,是几个线程各自握着对方要的锁、互相永久等待。用 jstack <pid>,JVM 会直接打出 Found one Java-level deadlock: 段,标出环上每个线程。本质是四个必要条件同时成立,打破任意一个即可,最常用两招——锁顺序一致、tryLock 加超时。

背景

synchronized 保证同一时刻只有一个线程进入临界区。多把锁嵌套使用时,如果两个线程按相反顺序拿锁,就会互相卡死,谁也没法继续。

教科书式的现场是两个对象 lockAlockB

public class DeadlockDemo {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (lockA) {
                sleep(100);
                synchronized (lockB) { /* ... */ }
            }
        }, "Thread-1").start();

        new Thread(() -> {
            synchronized (lockB) {
                sleep(100);
                synchronized (lockA) { /* ... */ }
            }
        }, "Thread-2").start();
    }
}

Thread-1 先拿 lockA 再拿 lockBThread-2 反过来,正好构成一个环。

现象

死锁最阴险的地方:程序不报错、不抛异常、不打印日志,就那么在原地僵住。控制台最后几行通常是:

Thread 1: acquired lockA
Thread 2: acquired lockB

然后就没有然后了。

有经验的老手会先 jps 拿到进程号,再 jstack <pid> 抓线程快照。死锁时 jstack 末尾会直接给出结论,用一段输出把环画清楚:

Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x000000001e0c7800 (object 0x000000001e0c7800, a java.lang.Object),
  which is held by "Thread-2"
"Thread-2":
  waiting to lock monitor 0x000000001e0c8800 (object 0x000000001e0c8800, a java.lang.Object),
  which is held by "Thread-1"

Java stack information for the threads listed above:
===================================================
"Thread-1":
        at DeadlockDemo.lambda$main$0(DeadlockDemo.java:12)
        - waiting to lock <0x000000001e0c7800> (a java.lang.Object)
        - locked <0x000000001e0c8800> (a java.lang.Object)
"Thread-2":
        at DeadlockDemo.lambda$main$1(DeadlockDemo.java:20)
        - waiting to lock <0x000000001e0c8800> (a java.lang.Object)
        - locked <0x000000001e0c7800> (a java.lang.Object)

这段输出就是定位死锁的金标准:谁在等谁、锁被谁占,一目了然。jcmd <pid> Thread.print 也能拿到同样的信息。

根因分析

死锁能成立,四个条件必须同时满足:

  1. 互斥:同一把锁同一时刻只能一个线程持有。
  2. 持有并等待:线程握着一把锁,还想拿下一把。
  3. 不可抢占:没法从外面把锁抢过来,只能线程自己释放。
  4. 循环等待:线程之间形成「A 等 B、B 等 A」的环。

前面例子里,Thread-1 握着 lockAlockBThread-2 握着 lockBlockA,四个条件全中。根子是加锁顺序不一致。

死锁不一定非得是显式的 synchronized。JDK-8209007 报告过一个更隐蔽的:外层并行流里调用 ConcurrentHashMap.computecompute 内部又嵌套了 Stream.parallel,两个工作线程在两个 ReservationNode 上互相等待,同样打出 Found one Java-level deadlock。这说明框架内部的自旋和递归并行,一样会制造循环等待。

解决方案

首选:加锁顺序一致

给所有需要同时拿多把锁的路径定死一个顺序,全局遵守。先 lockAlockB,任何地方都不颠倒:

public void transfer() {
    synchronized (lockA) {
        synchronized (lockB) {
            // 固定顺序,环就断了
        }
    }
}

顺序一致后,「循环等待」被破坏,死锁从根上不可能发生。代价是锁多了之后,维护顺序的成本变高。

tryLock 加超时:拿不到就退

当加锁顺序不好统一,改用 ReentrantLock.tryLock,拿不到就超时放弃,别无限等:

ReentrantLock a = new ReentrantLock();
ReentrantLock b = new ReentrantLock();

public boolean tryWork() throws InterruptedException {
    if (a.tryLock(100, TimeUnit.MILLISECONDS)) {
        try {
            if (b.tryLock(100, TimeUnit.MILLISECONDS)) {
                try {
                    // 干活
                    return true;
                } finally {
                    b.unlock();
                }
            }
        } finally {
            a.unlock();
        }
    }
    return false; // 拿不到就放弃或重试
}

思路是破坏「持有并等待」:要么一次拿齐,要么暂时全放掉。比裸 synchronized 多了退路,但代码更繁琐,还可能引入活锁。

缩小临界区、少嵌套

能不同时拿两把锁就别拿。把真正需要加锁的部分缩到最小,代码里少出现「锁套锁」。

思考过程

jstack 那段 Found one Java-level deadlock 不是每个死锁都一定打印。更复杂的锁(如 ReentrantReadWriteLockCountDownLatchSemaphore)卡住时,线程状态是 WAITING 而非 BLOCKED,JVM 未必一眼认出是死锁。这时要靠多次抓线程快照,观察线程是否长期停在同一个 WAITING 点。

JDK-8209007 最终被标记为 Not an Issue,不是因为没有死锁,而是官方认为「在 ConcurrentHashMap.compute 里跑嵌套并行流」超出了预期用法,属于使用方式问题。这个案例说明,排查死锁不能只盯着自己写的锁,框架内部的锁同样会中招。

定位方法论就一句话:先 jstack 拿完整线程快照,别只盯 BLOCKED,长期不动的 WAITING 往往就是环上的另一头。

小结

  • 死锁的特征是「假死、无异常、无日志」,光看现象很难第一时间想到。
  • 一抓一个准的工具是 jstack <pid>,认准 Found one Java-level deadlock 那段。
  • 打破四个必要条件之一能根治,最实用的是锁顺序一致加 tryLock 超时。
  • 别只怀疑 synchronized,框架内部和并发集合同样会死锁。

来源

最后更新于 2026-08-23

这篇帮到你了吗?

刚解决了一个棘手的报错?花两分钟记录下来,帮助下一个遇到同样问题的开发者。

贡献一条解法