Java 进程还在,CPU 也不高,但部分接口一直不返回。此时需要先确认工作线程在等什么:普通空闲等待、下游调用变慢、线程池任务互相等待,都可能表现为“卡住”,不能看到 WAITING 就认定死锁。

本文主要面向 HotSpot JDK 17/21 的平台线程、对象监视器和常见同步锁。Java 21 虚拟线程的诊断边界在后文单独说明。线程转储命令需要有权访问目标进程;示例程序只在独立测试 JVM 中运行。

先保存多份线程转储

在应用所在主机或容器中确认实际 Java PID、运行用户与 JDK。Linux Bash 示例中的 12345 必须替换成目标进程号,目录应有写入权限和足够空间:

umask 077
mkdir -p /tmp/thread-check
jcmd 12345 Thread.print -l > /tmp/thread-check/threads-1.txt
sleep 5
jcmd 12345 Thread.print -l > /tmp/thread-check/threads-2.txt
sleep 5
jcmd 12345 Thread.print -l > /tmp/thread-check/threads-3.txt

五秒只是示例采样间隔,按故障持续时间调整。Thread.print -l 输出线程栈和同步信息,转储会消耗一定资源,不要在高负载服务上无节制高频采集。附加目标 JVM 还受用户权限和进程命名空间等条件影响;找不到进程时先查这些条件,不把权限放宽当作第一步。jcmd 文档说明了命令与使用前提。

多份转储的用途是判断等待是否持续、持锁者有没有推进。线程正在短暂争抢同一个锁,与两个线程永久互相等待,是不同问题。线程栈也可能包含业务参数或内部路径,保存和分享时按内部诊断资料管理。

重建“持有什么、等待什么”

先找与失败请求对应的线程,再记录线程名、调用位置、当前持有的锁和正在等待的锁。下面是关系示意,不是一次生产线程转储:

线程 worker-a:持有锁 L1,等待锁 L2
线程 worker-b:持有锁 L2,等待锁 L1

两条关系形成环,才有了判断锁死锁的直接依据。实际转储中要对照对象标识、锁拥有者和源码位置,不能只凭两行相似的业务方法名拼出结论。HotSpot 可能在末尾直接报告检测到的 Java 级死锁,仍要回到对应线程与锁信息确认原因。

BLOCKED 常见于等待进入 synchronized 监视器;WAITING、TIMED_WAITING 还可能来自 park、条件队列、join 等正常操作。RUNNABLE 也不等于正在消耗大量 CPU,某些本地 I/O 调用会呈现这个状态。状态需要与栈、锁和请求时间线一起解释。Thread.State给出了各状态的含义。

在独立 JVM 中观察一个确定的死锁

下面程序让两个平台线程分别拿到一把锁,再等待对方的锁。它只用于本地教学验证,不能放进服务启动路径。线程设为 daemon,主线程检测并输出后,整个独立 JVM 就会退出,不需要在测试后留下卡住的进程。

保存为 ThreadDeadlockProbe.java:

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.concurrent.CountDownLatch;

public class ThreadDeadlockProbe {
    private static final Object LEFT = new Object();
    private static final Object RIGHT = new Object();
    private static final CountDownLatch READY = new CountDownLatch(2);

    private static Thread worker(String name, Object first, Object second) {
        Thread thread = new Thread(() -> {
            synchronized (first) {
                READY.countDown();
                try {
                    READY.await();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
                synchronized (second) {
                    throw new AssertionError("Unexpected lock acquisition");
                }
            }
        }, name);
        thread.setDaemon(true);
        return thread;
    }

    public static void main(String[] args) throws Exception {
        worker("worker-a", LEFT, RIGHT).start();
        worker("worker-b", RIGHT, LEFT).start();
        ThreadMXBean bean = ManagementFactory.getThreadMXBean();
        long deadline = System.nanoTime() + 5_000_000_000L;
        long[] ids;
        do {
            ids = bean.findDeadlockedThreads();
            if (ids != null) {
                System.out.println("deadlocked=" + ids.length);
                for (ThreadInfo info : bean.getThreadInfo(ids, true, true)) {
                    System.out.println(info.getThreadName()
                            + " waits for " + info.getLockName()
                            + "; owner=" + info.getLockOwnerName());
                }
                return;
            }
            Thread.sleep(20);
        } while (System.nanoTime() < deadline);
        throw new IllegalStateException("Deadlock not detected in time");
    }
}

在安装完整 JDK 的本地目录执行:

javac ThreadDeadlockProbe.java
java ThreadDeadlockProbe

本机 Windows / Oracle JDK 8u361 已编译运行这份示例,检测到两个死锁线程,owner 分别指向对方;这不作为 JDK 17/21 或生产环境实测结论。预期在相同平台线程条件下得到同类结果。对象标识与输出顺序可以变化,不应硬编码。这个小例子只证明监视器锁环的检测方式,不代表某个真实接口卡顿已经被诊断。

findDeadlockedThreads() 针对它支持的线程和同步关系工作;空结果不能证明整个服务没有等待环。ThreadMXBean 文档说明了检测能力与限制。

没检测到锁环,继续查任务与下游

一种常见情况是固定大小的线程池:父任务占着池中的线程,再向同一线程池提交子任务并同步等待,子任务却没有空闲线程执行。这种任务依赖造成的停滞,不一定表现为 JVM 能报告的监视器死锁。

这时要对照线程栈里的 Future 等待、线程池活跃数、任务队列和任务提交关系。不要看到 get 或 park 就删掉等待,也不要只扩大线程池后结束排查;需要确认任务依赖与容量边界。

如果线程在等数据库或 HTTP 连接,还要去对应系统继续找证据。Java 锁死锁与MySQL 事务死锁有不同的拥有者和等待资源,JVM 转储不能代替数据库的锁信息。

Java 21 虚拟线程要补另一份视图

ThreadMXBean 不监控虚拟线程,不能据它返回空结果就排除虚拟线程相关的问题。Java 21 可使用针对虚拟线程的线程转储,例如在目标 JVM 支持该命令时:

jcmd 12345 Thread.dump_to_file -format=json /tmp/thread-check/threads.json

文件路径由目标进程访问,已有文件、权限和工具版本都要检查。该转储与传统 Thread.print 的信息结构不同,不能机械套用同一套锁字段解析;要结合任务结构、调用栈与目标 JDK 的虚拟线程工具说明。Oracle 虚拟线程诊断文档介绍了相应能力。

按锁关系修改,再复现原来的并发条件

真正的锁顺序环,可以统一获取顺序、缩小持锁范围,或将外部调用移出锁内。使用可超时锁时,还要设计获取失败后如何释放已持有资源、回滚和重试;加一个超时并不自动保证业务正确。

修改后用原来容易卡住的并发入口复测,同时检查多份线程转储和请求完成情况。保留能构成等待环的输入和任务关系,避免只用单线程调用证明修复。重启可以暂时恢复服务,但恢复前后的诊断证据仍要保留。

参考资料

更新记录

  • 2026-10-29:初稿;排查命令按 JDK 21 官方资料核对,教学示例使用兼容 JDK 8 的平台线程 API,生产命令未在本站服务执行。