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,生产命令未在本站服务执行。
Java 线程死锁怎么定位
https://java.li/archives/java-thread-deadlock
评论