ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Thread Dump Analyzer(TDA)实战:用jstack线程转储定位死锁与CPU热点

Thread Dump Analyzer(TDA)实战:用jstack线程转储定位死锁与CPU热点

简介:TDA(Thread Dump Analyzer)是一款面向Java开发与运维人员的开源线程Dump分析工具,用于解析jstack等命令生成的线程快照,快速定位线程阻塞、死锁、锁竞争及CPU占用过高等性能问题,适合线上故障排查和性能调优场景。其2.3.3版本压缩包共3个文件,包含Windows下的bat、Linux/macOS下的sh启动脚本以及可独立运行的jar主程序,整体大小仅1.3MB,无需复杂安装即可跨平台使用。工具提供线程状态可视化、死锁自动检测、线程耗时统计、锁竞争分析、堆栈深度比较、活动度分析及过滤搜索等能力;导入Dump后,可在总览视图查看线程状态分布,点击任意线程查看完整堆栈与锁关系,并一键导出HTML分析报告。针对复杂问题,还可结合不同时间点的Dump进行堆栈深度对比。已有1333人下载学习,适合需要系统性分析线程状态的中高级Java开发者作为日常诊断工具箱中的便携组件,可有效提升并发问题排查效率。

1. 线程卡死别急着重启:Thread Dump Analyzer 把转储变成可检索的地图

服务无响应、CPU 飙到 100% 时,Thread Dump Analyzer(TDA)是我第一个会打开的工具。重启确实能把问题掩盖过去,但下一次事故往往更难看。TDA 做的事,是把 jstack 抓下来的几千行原始文本解析成线程树、状态统计和死锁视图,让现场能被真正读出来。tda-bin-2.3.3.zip 是它的二进制发行包,解压即用,不需要编译,不依赖构建工具。适合后端开发、运维和 SRE,在一张 dump 在手的时候,快速回答三个问题:谁在等锁、谁在烧 CPU、是不是死锁。我会按自己实际排查的顺序走一遍:解压、导入、死锁与热点定位,最后给出一套采集脚本。

2. 解压与启动 tda-bin-2.3.3:目录结构、JDK 版本与启动参数

2.1 解压之后先看什么

二进制包和源码包的区别很直接:不需要编译。tda-bin-2.3.3.zip 解压后的核心就两类东西:一个打包好了依赖的 tda.jar,一个启动脚本(Windows 下对应 .bat)。有些发行包还会带一个 examples 目录,里面放几张示例 dump,用来验证安装是否正常。我建议先拿示例文件试一次,省得第一次打开正式 dump 时心里没底。

我习惯把解压放到独立目录,避免 jar 和配置文件散在 home 下面:

mkdir -p ~/tools/tda && cd ~/tools/tda unzip ~/downloads/tda-bin-2.3.3.zip ls -lh

解压完第一件事不是双击运行,而是先确认 Java 版本:

java -version

这一步值得单独讲。TDA 2.3.3 发布时间比较早,按 JDK 8 的类库结构发布,内部引用了 sun.misc、sun.awt 这类 JVM 内部包。JDK 9 引入模块化之后,这些包默认不对外导出,直接 java -jar tda.jar 大概率翻车,报的是 NoClassDefFoundError 或者 IllegalAccessError,形式五花八门,根因都是同一个。换个 JDK 版本就能省掉后面一大半折腾,所以我每次拿到新机器,第一步就是把java -version的输出看清楚。

2.2 JDK 版本选型与启动参数

Java 版本启动方式稳定性
JDK 8java -jar tda.jar最省事,推荐
JDK 11加 --add-opens,见下面命令能跑,个别对话框可能异常
JDK 17同上,异常多就换 JDK 8不推荐

JDK 8 是我的兜底选择。如果机器上只有 JDK 11 以上,又不方便切版本,用这条命令可以正常启动:

java -Xmx4g \ --add-opens java.base/java.lang=ALL-UNNAMED \ --add-opens java.desktop/sun.awt=ALL-UNNAMED \ --add-opens java.desktop/java.awt=ALL-UNNAMED \ -jar tda.jar

--add-opens 的含义是允许反射访问指定包的私有成员。TDA 是 Swing 老应用,启动时需要反射 java.awt 和 sun.awt 的窗口实现;不加参数,模块系统会直接拦截。其中 -Xmx4g 也关键:TDA 要把整个 dump 解析成对象树存进堆,默认堆大小对几百 MB 的转储文件根本不够,导入到一半就会卡死或 OOM,这不是工具 bug,是内存预算没给够。不同环境可能还缺别的包,报什么错就补什么 --add-opens,一般逃不出 java.base 和 java.desktop 两个模块。

提示:如果把命令存成 tda.sh,记得 chmod +x tda.sh。脚本里用 dirname "$0" 定位 jar,这样不管从哪个目录执行都不会找不到文件。

2.3 无显示器的服务器怎么启动

线上服务器一般没有图形界面,直接跑会抛 java.awt.HeadlessException。常见做法是用 Xvfb 在服务器上搭一个虚拟帧缓冲:

Xvfb :99 -screen 0 1280x1024x24 & DISPLAY=:99 java -Xmx4g -jar tda.jar

DISPLAY=:99 把 GUI 画到虚拟显示上,程序不再报 headless 错误。但说实话,在服务器上把窗口画出来没人看得见,这个方案只适合验证包没坏、能正常解析。另一种做法是 ssh -X 做 X11 转发,把远端窗口拉到本地,但延迟高、经常断线,故障时候别依赖它。我实际工作中更常用的路径是:把 dump 文件 scp 回本地桌面,在本地打开 TDA 慢慢看,操作顺手,图表也清楚。抓 dump 在线上做,分析 dump 在本地做,两边职责分开,效率最高。

3. 把 jstack 文本变成线程地图:格式识别、导入与状态分类

3.1 先认识原料:一份标准线程转储

jstack 或者 jcmd Thread.print 的输出,典型段落长这样:

2025-01-10 10:00:01 Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.392-b08): "http-nio-8080-exec-10" #44 daemon prio=5 os_prio=0 tid=0x00007f8c480100 nid=0x2a3f runnable [0x00007f8c5fe000] java.lang.Thread.State: RUNNABLE at java.util.HashMap.getNode(HashMap.java:573) at java.util.HashMap.get(HashMap.java:556) "pool-3-thread-7" #37 prio=5 os_prio=0 tid=0x00007f8c485200 nid=0x2b1c waiting for monitor entry [0x00007f8c514000] java.lang.Thread.State: BLOCKED (on object monitor) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) - waiting to lock <0x00000007d5e4f328> (a java.util.concurrent.ThreadPoolExecutor$Worker)

每段第一行是线程名和一堆属性,第二行以下的 java.lang.Thread.State 是官方状态,再往下是栈帧。TDA 的解析器能识别 HotSpot 的 jstack、jcmd Thread.print,以及 WebLogic、WebSphere 等容器的变种格式;判断依据是文件头部的 "Full thread dump" 和 VM 型号字样,不需要手动指定格式。这也是我推荐 dump 尽量用标准 jstack 或 jcmd 抓的原因——格式越标准,解析越稳。

另外值得注意一行里的两个 id:tid 是 JVM 内部线程 id,nid 是 OS 原生线程 id。后面第 4 章做 CPU 热点对照时,用的是 nid。这两个概念容易混,但排查时不能认错。

3.2 导入与线程树视图

打开 TDA 后,菜单 File → Open… 选择 dump 文件即可。多个 dump 可以先后打开,每个文件一个标签页,互不干扰。导入完成,主窗口分成上下两块:上面是线程列表或线程树,下面是选中线程的完整栈帧。

线程树从顶层的线程组往下展开,再往下一层才是具体线程对象,显示线程名、优先级、daemon 标志和 nid。栈帧列表从栈顶开始,第一帧就是线程当前正在执行的位置——排查时先看栈顶,再看整个调用链。

线程名是第一抓手。业务线程通常带语义,比如 http-nio-8080-exec-10、RpcServerHandler-20880-thread-3,一看就知道是 Web 容器还是 RPC 工作线程。像 pool-3-thread-7 这种没名字的,就靠状态和栈帧内容判断。我的习惯是先把所有线程名过一遍,名字里带关键业务词的标出来,再往下钻。TDA 界面一般也会提供搜索或过滤输入框,按线程名关键字过滤一组线程,比在文本编辑器里翻页快得多。

3.3 状态统计:先回答「多少线程卡住了」

TDA 的统计视图按 java.lang.Thread.State 分组计数。加载完一张 dump,第一眼该看三个数:RUNNABLE、BLOCKED、WAITING/TIMED_WAITING。正常服务的 BLOCKED 数量应该很小而且瞬时;如果持续两位数,基本可以断定有人在公共锁上排队。

状态线程正在干嘛常见栈顶
RUNNABLE正在执行,或等待 OS 调度业务代码、native 调用
BLOCKED等待 monitor 锁- waiting to lock
WAITING无条件等待LockSupport.park、wait()
TIMED_WAITING带超时等待sleep、parkNanos

读法上先统计后明细:统计视图确认人数规模,再切回线程树逐个看 BLOCKED 线程的栈顶。栈顶清一色停在同一个类上,说明是单点热点锁;栈顶分散,则是多个锁各自竞争,分析方向和手段都不一样。

提示:大量线程 TIMED_WAITING 不一定是故障。线程池空闲时线程都停在 LinkedBlockingQueue.take() 上,状态就是 WAITING,这属于正常 idle,别误判成事故。

4. 死锁与热点线程定位:TDA 的三个核心分析姿势

4.1 死锁检测:问题视图的 Deadlock 条目

TDA 内置了死锁分析遍历,会把所有线程的「持有锁 → 等待锁」关系建成一张图,再找环。典型场景:A 拿住锁 L1 等 L2,B 拿住锁 L2 等 L1,环闭合,问题视图里出现 Deadlock 条目,展开能看到参与线程和各自卡住的栈帧行号。环内会标出从哪把锁起始、又回到哪把锁,顺着这个环读,定位持锁点最快。

有一个容易忽略的边界:死锁能否检测到,取决于 dump 那一刻。环必须两个线程同时 BLOCKED 才闭合;如果抓 dump 的瞬间有某个线程恰好被调度走,那一刻就不成环。所以单张 dump 显示「没有死锁」,不能证明没死锁,只会证明这一刻不成环。结论是连续抓三张、间隔 5 秒,三张里有两张成环,基本可以定性。

4.2 CPU 热点:nid 与 top 的十六进制对照

jstack 不直接给 CPU 占用率,但每行末尾的 nid=0x... 是个可靠口子——它就是 OS 层线程 ID 的十六进制。操作路径:

top -H -p <jvm进程pid> # 按 CPU 排序,找到最吃 CPU 的线程 tid printf '%x\n' <tid> # 转十六进制,得到 nid grep "nid=0x2a3f" dump.txt # 在 dump 里定位到对应线程

把 grep 出来的线程名拿回 TDA 搜索框一搜,直接落到栈帧。线上排障这个动作我几乎每周都做,比在 GUI 里翻列表快一个量级。TDA 在某些带扩展信息的 dump 里也能显示 CPU 时间列,但通用方案始终是 top 对照 nid,不依赖格式里有没有附加字段。

需要注意:top -H 看到的 tid 是十进制 OS 线程号,dump 里是十六进制 nid,中间必须过一道 printf '%x'。我踩过一次直接用十进制 grep,半天没结果,以为是解析问题,其实是进制没转。

4.3 锁竞争分析:按锁地址归拢

BLOCKED 线程的栈帧里有一行 "waiting to lock <0x00000007d5e4f328>",尖括号里就是这个 monitor 的对象地址。同一把锁在多个线程的栈里会反复出现这个地址,TDA 里把同一锁地址的线程归拢在一起,排序后能看到谁在等、谁在持有。

我判断热点锁的顺序:

  1. 统计视图看 BLOCKED 数量是否异常;
  2. 线程树里连续点开几个 BLOCKED 线程,看栈顶等待的锁地址是否同一个;
  3. 把地址一致的多条线程放在一起数人头,超过 5 条就是强竞争;
  4. 反查持有者的栈帧,看它为什么迟迟不释放——可能是在等 IO,也可能在等另一个锁,顺着链继续追。

如果界面版本的锁视图不明显,也可以直接把地址复制出来,回原始 dump 文本里 grep --count 一遍,数字就是等在它上面的人数。这套流程十几分钟走完,比在文本编辑器里来回复制锁地址高好几个档次。

5. 避坑指南:TDA 常见问题与排查记录

5.1 启动与环境类问题

现象:双击启动脚本闪一下就没了;命令行下报 java.lang.NoClassDefFoundError: sun/misc/Unsafe 或 IllegalAccessError。 原因:JDK 9 模块化后,sun.misc 这类内部包默认不导出;TDA 2.3.3 按 JDK 8 的类库结构发布,直接拿不到。 解决:换 JDK 8 执行,或按第 2 章给 JVM 加 --add-opens。我把启动命令固化成了 tda.sh,以后只跑脚本。

现象:服务器上启动报 java.awt.HeadlessException。 原因:Swing 界面需要 X 协议,服务器没有显示器。 解决:用 Xvfb 虚拟显示,或者把 dump 拉回本地机器分析。ssh -X 转发偶尔能用但延迟高,故障时不推荐依赖。

5.2 转储解析与分析类问题

现象:导入 100MB 以上的 dump,界面假死,CPU 长时间 100%。 原因:堆内存不够,TDA 解析把所有线程对象都装进堆。 解决:-Xmx4g 起步;文件实在大的话先在服务器上按线程段拆分再逐个导入。拆分可以用 awk 按空行分组,比手工截文件可控。

现象:业务线程名是中文,导入后全变成乱码,搜索功能失效。 原因:dump 文件是 GBK 编码,TDA 按默认编码读。 解决:导入前转码,转完最好先 head 看几行确认中文正常再打开:

iconv -f GBK -t UTF-8 dump.txt > dump-utf8.txt

现象:jstack 输出有两百个线程,TDA 只解析出一百五十个。 原因:dump 文件不完整,最常见是 jstack -F 强制模式抓出的文件被截断,或者 JVM 在 OOM 边缘输出中断。 解决:改用 jcmd Thread.print 重新抓;导入前扫一眼文件末尾,确认完整落到线程段结束、Heap 段之前。

现象:死锁检测列表为空,服务却已经卡死。 原因:死锁环必须两个线程同时 BLOCKED 才能被观察到;CPU 自旋、单锁高竞争都不会出现在死锁结果里。 解决:连续抓三张 dump 间隔 5 秒;同一线程三张都停在同一个栈帧,说明是稳定卡点,再用锁竞争视图数人头。

5.3 三板斧排查顺序

故障现场时间紧,我给团队定的固定顺序是:

  1. top -H -p 找 CPU 最高的线程,记下 tid;
  2. jcmd Thread.print 抓 dump,连续三张间隔 5 秒;
  3. TDA 打开三张 dump:先看统计,再看死锁视图,最后按 nid 对照查热点线程。

这个顺序把「CPU 热点」和「锁等待」两条线分开处理,不会在排障时来回切换工具。用熟了之后,十分钟能给出一份定性结论。如果三张 dump 显示同一线程永远停在同一个栈帧,那就不用怀疑是偶发抖动,直接按稳定卡点处理。

6. 进阶技巧:把 jstack 采集与 TDA 分析串成流水线

线上故障的时候,手速决定拼图完不完整。我以前是手忙脚乱敲 jstack,抓完一张又不知道下一张该什么时候抓。现在我把采集固化成一个脚本,故障时一条命令拿到三张带间隔的 dump:

#!/bin/bash pid=$1 out=./dumps mkdir -p "$out" for i in 1 2 3; do jcmd "$pid" Thread.print > "$out/dump-$i.txt" sleep 5 done

三张 dump 分别导入 TDA,用状态统计横向对比。某个线程三张都停在同一个栈帧,说明它不是在抖动而是真卡住;第一张 RUNNABLE、第三张 BLOCKED,说明锁竞争在恶化——两个方向不同,后面的处置也完全不同。

我又加了一个习惯:抓完顺手跑一遍 top 对照 nid,把 CPU 最高的两三个线程名写进文件名,比如 dump-1_top-nio-8080.txt,分析时直接开对应文件,省去对着十六进制换算的时间。从那以后我每次遇到线上卡顿,都强制先抓齐全三张 dump 再碰重启按钮,死锁和热点锁的位置基本两轮分析内就能定位。下载 tda-bin-2.3.3.zip 后,记得先用第 2 章的 JDK 匹配和 -Xmx 参数把环境搭稳,再谈分析。数据在手,重启也不慌了。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进