《Java 并发编程·性能诊断与事故》3.3 线程转储的读法

用一份同时包含 BLOCKED、WAITING (parking) 与 Java 级死锁的真实线程转储,讲清 jcmd Thread.print -l 与 jstack -l、- waiting to lock/- locked/parking to wait for 的配对方法,如何从地址找到锁持有者,以及 Found one Java-level deadlock 段的读法与修复后的验证。

承接的问题

3.1 和 3.2 给的是聚合证据:哪类锁竞争最多、哪条调用路径最宽、分配压力落在哪个站点。真正处理线上故障时,第一份拿到的往往不是 JFR 或火焰图,而是一份线程转储。它便宜、随手可得、不需要提前埋点,但也最容易读错。

典型症状:线程池的活跃线程数打满,接口 RT 从 80 ms 涨到 2 s,CPU 只有 25%。抓一份转储,满屏 BLOCKED (on object monitor),但不知道谁持有锁;或者看到一片 WAITING (parking),误以为系统死锁了;再或者 jstack 末尾写着 Found 1 deadlock,却对不上是哪两个线程。

这一节用一份真实抓取的转储回答四个问题:

  • jcmd <pid> Thread.print -l 和 jstack -l 的头部、线程段、锁行各是什么;
  • BLOCKED 与 WAITING (parking) 的区别,为什么前者能直接看到持有者、后者不能;
  • 怎么从 - waiting to lock <0x...> 或 parking to wait for <0x...> 找到同一个转储里的 - locked <0x...>;
  • Found one Java-level deadlock 段怎么读,以及修复后怎么验证。

场景是构造的:一个进程里同时放三组线程——一组抢监视器、一组制造 Java 级死锁、一组抢 ReentrantLock。转储来自本机 Temurin 25.0.5+8,load average 在 100 以上;线程状态和地址是那一次快照的真实值,换一次运行就会变。

两种入口与头部

先找到 PID:

jcmd -l
jps -l

两个入口抓的是同一份 VM 线程转储:

jcmd <pid> Thread.print -l > thread-dump.txt
jstack -l <pid> > jstack.txt

-l 是关键:它会额外打印 Locked ownable synchronizers,也就是 ReentrantLock、ReentrantReadWriteLock 这类 AQS 锁的持有情况。不加 -l,你只能看到 parking to wait for <地址>,却找不到谁持有这个地址。jcmd Thread.print 还有 -e 选项打印扩展线程信息;本节只用 -l。

本机 jcmd 抓到的头部:

62811:
2026-10-29 02:17:04
Full thread dump OpenJDK 64-Bit Server VM (25.0.5+8-LTS mixed mode, sharing)
                 JDK version: OpenJDK Runtime Environment Temurin-25.0.5+8 (25.0.5+8) (build 25.0.5+8-LTS)

Threads class SMR info:
_java_thread_list=0x000000074aca4660, length=18, elements={

jstack -l 的头部只差第一行:它没有 62811: 这个 PID 行,直接从时间戳开始。两边线程段的内容一致;本机对比过,两次抓取的时间戳最多相差一秒。Threads class SMR info 是 VM 内部的线程列表,读锁问题时可以跳过。

一份转储是一个瞬间快照,不是时间线。线程状态在你读的时候可能已经变了;一个 BLOCKED 持续了 10 ms 还是 10 分钟,单份转储看不出来。排查长尾问题时,至少间隔 5 秒抓三份,比较同一线程是否一直在同一个锁上。

还有一个容易忽略的边界:Thread.print 只打印平台线程。本机用 3 个虚拟线程(v-0、v-1、v-2)做对照,Thread.print 里 rg -c 'v-[0-2]' 的结果是 0;改用 Thread.dump_to_file -format=json 后同样 grep 的结果是 3。虚拟线程的转储入口是:

jcmd <pid> Thread.dump_to_file -format=json /tmp/vthread-dump.json

状态:BLOCKED 与 WAITING (parking)

线程行由几段组成:

"loan-blocked-1" #26 [33027] prio=5 os_prio=31 cpu=0.08ms elapsed=4.21s \
  tid=0x000000074b13c000 nid=33027 waiting for monitor entry [0x000000016ddd6000]

#26 是 Java 线程 ID,tid 是 JVM 内部线程指针,nid 是操作系统线程 ID,cpu 是累计 CPU 时间,elapsed 是启动至今的时长,行尾的 [0x...] 是栈地址范围,不是锁地址。waiting for monitor entry、waiting on condition、runnable 是 VM 给的粗分类,真正的状态在下一行。

状态头部提示典型栈/锁行含义
RUNNABLErunnable业务栈正在跑或在 native 中,不等于占满 CPU
BLOCKED (on object monitor)waiting for monitor entry- waiting to lock <0x...>等进入 synchronized,持有者是另一个线程
WAITING (parking)waiting on conditionparking to wait for <0x...>LockSupport.park,常见于 AQS 锁或条件等待
WAITING (on object monitor)in Object.wait()java.lang.Object.wait无超时 Object.wait,等通知
TIMED_WAITING (sleeping)sleepingThread.sleep睡眠;可能仍然持有锁
TIMED_WAITING (on object monitor)in Object.wait()Object.wait(timeout)带超时的条件等待

BLOCKED 是监视器入口的等待:线程想进 synchronized,监视器被另一个线程占着。VM 知道持有者是谁,所以栈里会写 - waiting to lock <地址>,持有者的栈里会写 - locked <同一地址>。这个配对是转储里最可靠的信息。

WAITING (parking) 是 LockSupport.park 的等待:ReentrantLock、Condition.await、Future.get 都可能走到这里。等待者的栈里只有 parking to wait for <地址>,没有 - locked;持有者不在同一线程的栈里。要找到它,必须用 -l 打印的 Locked ownable synchronizers,或者用 ThreadMXBean.getLockOwnerName()。

TIMED_WAITING (sleeping) 不是锁等待,但它可能持有锁。本节靶子里的 loan-holder 就是一边 sleep(120_000) 一边持有监视器;如果只 grep BLOCKED,会漏掉这个真正的持有者。找持有者时不要按状态过滤,要按 - locked <地址> 过滤。

实跑:一份转储里的三种等待

靶子同时制造三种情况:MONITOR 被一个睡眠线程持有,两个线程在入口阻塞;LEFT/RIGHT 被两个线程反序获取,形成死锁;HEAVY_LOCK 被一个线程持有,另一个线程在 AQS 上 park。

import java.util.concurrent.locks.ReentrantLock;

public final class DumpTarget2 {
    private static final Object MONITOR = new Object();
    private static final Object LEFT = new Object();
    private static final Object RIGHT = new Object();
    private static final ReentrantLock HEAVY_LOCK = new ReentrantLock();

    public static void main(String[] args) throws Exception {
        Thread holder = Thread.ofPlatform().name("loan-holder").start(() -> {
            synchronized (MONITOR) {
                sleep(120_000);
            }
        });
        Thread blockedOne = Thread.ofPlatform().name("loan-blocked-1").start(() -> {
            synchronized (MONITOR) {
                System.out.println("blocked-1 entered");
            }
        });
        Thread blockedTwo = Thread.ofPlatform().name("loan-blocked-2").start(() -> {
            synchronized (MONITOR) {
                System.out.println("blocked-2 entered");
            }
        });
        Thread deadA = Thread.ofPlatform().name("deadlock-a").start(() -> {
            synchronized (LEFT) {
                sleep(500);
                synchronized (RIGHT) {
                    System.out.println("a acquired both");
                }
            }
        });
        Thread deadB = Thread.ofPlatform().name("deadlock-b").start(() -> {
            synchronized (RIGHT) {
                sleep(500);
                synchronized (LEFT) {
                    System.out.println("b acquired both");
                }
            }
        });
        Thread lockHolder = Thread.ofPlatform().name("lock-holder").start(() -> {
            HEAVY_LOCK.lock();
            try {
                sleep(120_000);
            } finally {
                HEAVY_LOCK.unlock();
            }
        });
        Thread lockWaiter = Thread.ofPlatform().name("lock-waiter").start(() -> {
            HEAVY_LOCK.lock();
            try {
                System.out.println("lock-waiter entered");
            } finally {
                HEAVY_LOCK.unlock();
            }
        });
        holder.join(); blockedOne.join(); blockedTwo.join();
        deadA.join(); deadB.join(); lockHolder.join(); lockWaiter.join();
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

抓取命令:

javac --release 25 -d classes DumpTarget2.java
java -cp classes DumpTarget2 &
PID=$!
sleep 4
jcmd "$PID" Thread.print -l > out/thread-dump.txt
jstack -l "$PID" > out/jstack.txt
kill "$PID"

下面所有 text 块都是转储摘录,省略了 DumpTarget2$$Lambda、Thread.runWith、Thread.run 等公共尾部帧;地址、状态行和锁行保持原样。先看持有监视器的线程。它的状态是 TIMED_WAITING (sleeping),不是 BLOCKED,但栈里有一行 - locked:

"loan-holder" #25 [26883] prio=5 os_prio=31 cpu=0.22ms elapsed=4.21s tid=0x000000074b0af800 nid=26883 waiting on condition  [0x000000016dbca000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
	at java.lang.Thread.sleepNanos0(java.base@25.0.5/Native Method)
	at java.lang.Thread.sleepNanos(java.base@25.0.5/Thread.java:509)
	at java.lang.Thread.sleep(java.base@25.0.5/Thread.java:540)
	at DumpTarget2.sleep(DumpTarget2.java:63)
	at DumpTarget2.lambda$main$0(DumpTarget2.java:12)
	- locked <0x0000000b6ec194e0> (a java.lang.Object)
	at java.lang.Thread.runWith(java.base@25.0.5/Thread.java:1487)
	at java.lang.Thread.run(java.base@25.0.5/Thread.java:1474)

再看等在入口的两个线程,状态是 BLOCKED (on object monitor),栈里是 - waiting to lock,地址和持有者的 - locked 完全一致:

"loan-blocked-1" #26 [33027] prio=5 os_prio=31 cpu=0.08ms elapsed=4.21s tid=0x000000074b13c000 nid=33027 waiting for monitor entry  [0x000000016ddd6000]
   java.lang.Thread.State: BLOCKED (on object monitor)
	at DumpTarget2.lambda$main$1(DumpTarget2.java:17)
	- waiting to lock <0x0000000b6ec194e0> (a java.lang.Object)
	at java.lang.Thread.runWith(java.base@25.0.5/Thread.java:1487)
	at java.lang.Thread.run(java.base@25.0.5/Thread.java:1474)

loan-blocked-2 的地址也是 0x0000000b6ec194e0,说明两个线程等的是同一个监视器。配对方法只有一步:把 waiting to lock 后面的地址拿去搜 - locked <同一地址>,命中的线程就是持有者。

死锁的两个线程也是 BLOCKED,但它们的 - waiting 和 - locked 交叉:

"deadlock-a" #28 [42499] prio=5 os_prio=31 cpu=0.10ms elapsed=4.21s tid=0x000000074b13d000 nid=42499 waiting for monitor entry  [0x000000016e1ee000]
   java.lang.Thread.State: BLOCKED (on object monitor)
	at DumpTarget2.lambda$main$3(DumpTarget2.java:29)
	- waiting to lock <0x0000000b6ec19500> (a java.lang.Object)
	- locked <0x0000000b6ec194f0> (a java.lang.Object)

"deadlock-b" #29 [41987] prio=5 os_prio=31 cpu=0.16ms elapsed=4.21s tid=0x000000074b13d800 nid=41987 waiting for monitor entry  [0x000000016e3fa000]
   java.lang.Thread.State: BLOCKED (on object monitor)
	at DumpTarget2.lambda$main$4(DumpTarget2.java:37)
	- waiting to lock <0x0000000b6ec194f0> (a java.lang.Object)
	- locked <0x0000000b6ec19500> (a java.lang.Object)

deadlock-a 等 ...500、持有 ...4f0;deadlock-b 等 ...4f0、持有 ...500。两个线程各持一把、各等对方那一把,这就是环。普通 BLOCKED 只说明「有人在等」,两个 BLOCKED 互相等才构成死锁。

再看 ReentrantLock 一侧。持有者 lock-holder 同样是 TIMED_WAITING (sleeping),-l 让它在末尾多出一段 Locked ownable synchronizers:

"lock-holder" #30 [41475] prio=5 os_prio=31 cpu=0.09ms elapsed=4.21s tid=0x000000074b13e000 nid=41475 waiting on condition  [0x000000016e606000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
	at java.lang.Thread.sleepNanos0(java.base@25.0.5/Native Method)
	at java.lang.Thread.sleepNanos(java.base@25.0.5/Thread.java:509)
	at java.lang.Thread.sleep(java.base@25.0.5/Thread.java:540)
	at DumpTarget2.sleep(DumpTarget2.java:63)
	at DumpTarget2.lambda$main$5(DumpTarget2.java:44)
   Locked ownable synchronizers:
	- <0x0000000b6ec19618> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)

等待者 lock-waiter 是 WAITING (parking),栈里的 parking to wait for 地址与上面的 ownable synchronizer 地址相同:

"lock-waiter" #31 [40963] prio=5 os_prio=31 cpu=0.18ms elapsed=4.21s tid=0x000000074b13e800 nid=40963 waiting on condition  [0x000000016e812000]
   java.lang.Thread.State: WAITING (parking)
	at jdk.internal.misc.Unsafe.park(java.base@25.0.5/Native Method)
	- parking to wait for  <0x0000000b6ec19618> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
	at java.util.concurrent.locks.LockSupport.park(java.base@25.0.5/LockSupport.java:223)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(java.base@25.0.5/AbstractQueuedSynchronizer.java:790)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(java.base@25.0.5/AbstractQueuedSynchronizer.java:1030)
	at java.util.concurrent.locks.ReentrantLock$Sync.lock(java.base@25.0.5/ReentrantLock.java:154)
	at java.util.concurrent.locks.ReentrantLock.lock(java.base@25.0.5/ReentrantLock.java:323)
	at DumpTarget2.lambda$main$6(DumpTarget2.java:50)

这就是为什么 -l 不能省:没有 Locked ownable synchronizers,你只有 parking to wait for <0x...>,无法在文本里完成配对。用 ThreadMXBean 可以直接拿到持有者名字:

ThreadMXBean mx = ManagementFactory.getThreadMXBean();
for (ThreadInfo info : mx.dumpAllThreads(true, true)) {
    if (info.getThreadState() == Thread.State.BLOCKED) {
        System.out.println(info.getThreadName() + " <- " + info.getLockOwnerName());
    }
}
long[] deadlocked = mx.findDeadlockedThreads();

dumpAllThreads(true, true) 的两个布尔参数分别要求锁信息和栈;getLockOwnerName() 直接给出持有者,findDeadlockedThreads() 返回死锁线程 ID。它们和文本转储读的是同一份 VM 状态,适合做自动化摘要。

死锁段怎么读

转储末尾的 Found one Java-level deadlock 是 JVM 自己做的环检测:

Found one Java-level deadlock:
=============================
"deadlock-a":
  waiting to lock monitor 0x000000074b056680 (object 0x0000000b6ec19500, a java.lang.Object),
  which is held by "deadlock-b"

"deadlock-b":
  waiting to lock monitor 0x000000074b0565a0 (object 0x0000000b6ec194f0, a java.lang.Object),
  which is held by "deadlock-a"

Java stack information for the threads listed above:
===================================================
"deadlock-a":
	at DumpTarget2.lambda$main$3(DumpTarget2.java:29)
	- waiting to lock <0x0000000b6ec19500> (a java.lang.Object)
	- locked <0x0000000b6ec194f0> (a java.lang.Object)
"deadlock-b":
	at DumpTarget2.lambda$main$4(DumpTarget2.java:37)
	- waiting to lock <0x0000000b6ec194f0> (a java.lang.Object)
	- locked <0x0000000b6ec19500> (a java.lang.Object)

Found 1 deadlock.

读法分三层:

  1. 第一段给出环:A 等 object ...500,持有者是 B;B 等 object ...4f0,持有者是 A。先确认这是环,不是单向等待。
  2. 第二段重复线程栈,把 waiting to lock 和 locked 的地址再列一遍,方便核对。DumpTarget2.java:29 和 :37 就是两个内层 synchronized 的位置。
  3. 最后一行 Found 1 deadlock. 是计数。只报告 JVM 能识别的 Java 级环;数据库行锁、分布式锁、park 造成的非监视器环不一定在这里出现。

注意 waiting to lock monitor 0x000000074b056680 (object 0x0000000b6ec19500, ...) 里有两个地址:monitor 0x... 是 VM 内部的监视器结构地址,object 0x... 才是对象地址。配对时用 object 后面的地址,不要拿 monitor 地址去和线程栈里的 - waiting to lock <...> 比。

修复后的验证很简单:把 deadlock-b 也改成先 LEFT 后 RIGHT,重新抓一份转储,rg -c 'Found one Java-level deadlock' 从 1 变成 0。但 loan-blocked-1/2 仍然是 BLOCKED,lock-waiter 仍然是 WAITING (parking)。死锁环消失不等于所有等待消失;验证时要分别看「环没了」和「竞争还在不在」。

在 libraryd-conc 里落地

conc-ops 把转储采集和摘要固定成一条命令,避免每次手工数状态:

#!/bin/sh
set -eu
PID=$1
OUT=${2:-/tmp/conc-dump}
STAMP=$(date +%Y%m%d-%H%M%S)
FILE="$OUT/dump-$STAMP.txt"
mkdir -p "$OUT"

jcmd "$PID" Thread.print -l > "$FILE"
printf 'BLOCKED=%s PARKED=%s DEADLOCK=%s\n' \
  "$(grep -c 'java.lang.Thread.State: BLOCKED' "$FILE" || true)" \
  "$(grep -c 'WAITING (parking)' "$FILE" || true)" \
  "$(grep -c 'Found one Java-level deadlock' "$FILE" || true)"
printf 'saved=%s\n' "$FILE"

采集侧只做三件事:-l 必须带、文件名带时间戳、摘要只统计三类信号。真正的分析不要写死在脚本里:BLOCKED 多不一定有问题,PARKED 多也不一定有问题,关键是它们是否集中在同一把锁、同一个业务入口。

分析侧建议固定三步:

  1. 先搜 Found one Java-level deadlock,有环先解环;
  2. 再按 - waiting to lock 的地址分组,找每个地址对应的 - locked 持有者;
  3. 最后才看 parking to wait for,用 -l 的 ownable synchronizer 段配 owner,并对照 3.1 的 ThreadPark 事件确认等待时长。

常见错误与边界

  • 只 grep BLOCKED。 持有者可能是 TIMED_WAITING (sleeping) 或 RUNNABLE;要按 - locked <地址> 找,不要按状态找。
  • 把 WAITING (parking) 一律当死锁。 空闲线程池、条件等待、Future.get 都会 park;看 parking to wait for 的类和栈,再决定是不是锁竞争。
  • 不加 -l。 没有 Locked ownable synchronizers,ReentrantLock 的持有者在文本里配不出来。
  • 拿 monitor 地址去配对。 死锁段里 monitor 0x... 是内部结构,object 0x... 才是线程栈里的对象地址。
  • 跨转储比较地址。 地址只在同一份快照内有意义;GC、重启、对象复用都会让地址变化。
  • 一份转储下结论。 状态是瞬间快照;间隔 5 秒抓三份,确认线程一直卡在同一个地址上。
  • 在容器里 attach 失败就放弃。 jcmd/jstack 需要同一用户、正确的 PID 命名空间和可写的临时目录;先确认这些,再怀疑 JVM。
  • 以为没看到 Found ... deadlock 就等于没有死锁。 JVM 只检测它能识别的 Java 级环;数据库锁、分布式锁、跨进程等待不在其中。
  • 用 Thread.print 找虚拟线程。 本机 3 个虚拟线程在 Thread.print 里是 0 条,Thread.dump_to_file -format=json 里才是 3 条;虚拟线程要换入口。

什么时候不要用线程转储: 需要知道「阻塞了多久」「哪个调用路径最耗时」时,单份转储回答不了,要用 JFR(3.1)或 async-profiler(3.2);转储适合回答「此刻谁持有什么、谁在等谁」,不适合做时间维度的归因。

小结

读线程转储的顺序是:先看头部和快照边界,再看状态行,最后做地址配对。BLOCKED 等的是监视器,持有者在另一条线程的 - locked 行里;WAITING (parking) 等的是 AQS/LockSupport,若是 AQS 锁,持有者去 -l 的 Locked ownable synchronizers 找,若是无主 park 则没有持有者;TIMED_WAITING (sleeping) 不是锁等待,但可能正持有锁。死锁段把环、对象地址和线程栈放在一起,先确认环,再用 object 地址核对,不要拿 monitor 地址配对。

本节的所有转储都来自同一份构造靶子的真实快照:4 个 BLOCKED、1 个 WAITING (parking)、2 个持有锁的 TIMED_WAITING (sleeping),Found 1 deadlock。把锁顺序改成一致后,死锁计数归零,但监视器阻塞和 AQS park 仍在——这正是下一章 4.1 要继续拆的问题。

阅读导航:上一节 3.2 async-profiler 的锁与分配火焰图 | 下一节 4.1 死锁的四个必要条件与检测

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收