☰
Java线程转储分析:TDA定位死锁、BLOCKED与线程池卡顿
2026/10/27 8:59:57 网站建设 项目流程

简介:TDA-Thread Dump Analyzer 的 tda-bin-2.3.3 发行包,面向需要排查 Java 多线程问题的开发与运维人员。当应用出现响应迟缓、线程阻塞或死锁迹象时,可借助它解析 jstack 等工具导出的线程快照,把繁杂的堆栈信息转为可视化视图,定位长期等待、锁竞争与高 CPU 消耗线程。压缩包共 3 个文件,体积约 1.3MB,以 jar 主程序为核心,配合 bat 与 sh 两类启动脚本,分别适配 Windows 与 Linux/Unix 环境,解压后无需复杂安装即可运行。目前已有 1334 人学习下载,说明它在线上故障排查场景中有一定认可度。除基本的线程状态分布与堆栈深度展示外,工具还提供死锁链路标记、锁争用统计、线程活动度观察、堆栈对比以及过滤搜索与报告导出,便于把一次分析结论沉淀为可分享的文档,适合作为日常性能诊断的常备工具。

1. 线上接口每隔几小时就抖一次,top看 CPU 没满,日志里也没有异常堆栈,能抓在手里的只剩一串jstack出来的文本——几千行,满屏java.lang.Thread.State,肉眼找 BLOCKED 找到眼花。这种时候我一般先把转储丢进 Thread Dump Analyzer,也就是 TDA;它就是一个专门处理线程转储文本的离线分析器,2.3.3 这个版本以 tda-bin-2.3.3.zip 分发,解压后是能直接跑起来的 Java 程序,不用往生产机装 agent。它做的事很集中:把线程、锁、堆栈解析成可点开的视图,标出死锁,支持按状态和线程名过滤。适合被卡顿和线程池问题折腾过的后端、要做压测复盘的运维,以及分不清waiting on condition和BLOCKED的新人。下面按先跑起来、再采对数据、最后读懂图的顺序拆开说。

2. 把 TDA 跑起来:JDK 版本、解压布局与启动脚本

TDA 的落地门槛不高,但对运行时版本和输入格式比较挑剔。这一章把「能不能启动」和「能不能读进去」两件事一次说清,省得后面分析时发现解析结果全是空。

2.1 先确认 JDK 版本和接受的转储格式

TDA 本身是 Java 写的,跑起来的前提是机器上有一套够新的 JRE。别急着解压,先在终端敲一句:

java -version

输出里能看到openjdk version "17.0.x"这类信息就够用。我一般建议用 11 或 17 两个 LTS 版本之一,避免踩到「类文件版本不匹配」这种一看就懵的报错。万一启动脚本一跑就抛:

Exception in thread "main" java.lang.UnsupportedClassVersionError: ... has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version ... recognizes ... up to 55.0

不用去网上查半天。报错里class file version后面那个数字就是换算依据:55 对应 Java 11,61 对应 Java 17,65 对应 Java 21。对照着换一版 JDK 就行,比瞎猜快得多。反过来,如果你机器上是 Java 8,遇到 61.0 的类文件就只能升级,降维是没戏的。

另一件事是「转储格式」。TDA 主要处理 HotSpot 格式,也就是 OpenJDK 和 Oracle JDK 默认jstack出来的那种文本。如果生产环境用的是 J9 系虚拟机,jstack命令根本不存在,得改用jcmd或kill -3触发;格式不匹配时 TDA 打开后线程数会是 0,这个坑在第五章单独展开。

提示:同一台分析机上装多个 JDK 是常态。启动前用which java确认当前 shell 指向哪个版本,必要时在启动脚本里写死JAVA_HOME,别依赖环境变量碰运气。

2.2 解压 tda-bin-2.3.3.zip,先看清楚目录再动手

拿到 zip 之后,第一件事不是双击,而是解到一个干净目录,避免文件散落在下载文件夹里。命令很简单:

unzip tda-bin-2.3.3.zip -d tda-2.3.3 cd tda-2.3.3 ls -la find . -maxdepth 2 -type f | head -40

-d tda-2.3.3的作用是把解压结果收进一个固定目录,后面要删要移都方便。find那条不是必须的,但能让你一眼看出这个发布包的结构。典型的tda-bin包会包含三类东西:bin/里放启动脚本(Linux/macOS 是tda.sh,Windows 是tda.bat),lib/里放主 jar 和依赖,另外可能有一份说明或配置目录。

参数上,-maxdepth 2是为了不递归得太深;head -40只是截断输出,避免一次刷屏几百行。这个习惯我在任何陌生发布包上都用,能少走很多「脚本在哪」的弯路。动手前确认一下bin/下的可执行脚本名字,如果跟你预期的不一样,以实际文件名为准,别硬套。

2.3 启动、看端口、把界面打开

Linux 或 macOS 下,启动脚本通常要补一次执行权限:

chmod +x bin/tda.sh ./bin/tda.sh

Windows 就在命令行里直接跑bin\tda.bat。启动过程中终端会打印一些 JVM 和服务的日志,最后会给出一个本地访问地址。多数情况下浏览器会自动弹出;没弹就手动复制那个地址到浏览器。

这里有一个比较常见的翻车点:端口被占。终端里如果出现Address already in use或绑定失败,用lsof -i :端口号看一眼是谁占着。要改端口,一般直接在bin/下的启动脚本里找带port字样的行改掉即可,改完重启。注意别在运行中直接编辑脚本,先把进程停干净再改,否则会出现「脚本已经改了、进程还是旧参数」的玄学现象。

启动日志里还有两个信息值得留意:一是 Java 版本,确认跟java -version一致;二是工作目录或数据目录,后面导入的转储、临时文件一般落在那。如果要换机器分析,把目录结构一起带走,别只拷一个脚本。

2.4 导入第一份转储,确认解析链路是通的

界面起来之后,找到「打开/导入转储」的入口,把准备好的.txt或.log线程转储拖进去或选择上传。第一份样本建议用带-l参数采出来的 HotSpot 转储,因为只有它才包含Locked ownable synchronizers这类锁归属信息,解析结果最完整。

导入成功后,先核对三个字段:转储时间、JVM 版本、线程总数。这三个对上了,说明头部解析没问题;再看线程树里有没有正常的线程名和状态。如果线程数显示为 0,或者只解析出寥寥几行,基本可以判定格式不匹配或文件损坏,先别急着分析,回到第三章重新采一份。

我第一次导入时图省事,用了从聊天记录里复制出来、中间被截断的一段转储,结果状态全是「未知」。转储文本本身是有头有尾的,头部记录采样时间和 JVM 信息,尾部是每个线程的完整堆栈,复制粘贴时截掉任何一段都会让解析器犯难。这一点后面还会反复强调。

3. 线程转储怎么来才算「可分析」:三种导出方式与采集时机

TDA 只是分析端,真正决定结论质量的是采样端。转储采得不全、采得不是时候,工具再强也只能给出一堆看似专业的空结论。这一章把采样的三种方式、采几次、怎么核对完整性讲透。

3.1 jstack、jcmd、kill -3 三选一,关键在-l

大多数场景用jstack就够了,但有一个参数必须带上:

# 最常用的方式,-l 让转储带上锁归属信息 jstack -l 12345 > td-$(date +%H%M%S).txt # 容器环境或 jstack 不可用时,用 jcmd jcmd 12345 Thread.print -l > td-jcmd.txt # 应急:不依赖任何工具,直接给进程发信号 kill -3 12345

-l是 long listing 的意思,效果是把java.util.concurrent锁的持有者信息一起写进转储。没有它,TDA 里「锁被谁占着」这一类视图基本是空的,只能看到状态,看不到因果,分析价值大打折扣。这条属于血泪经验,后文避坑章还会再提。

jcmd的好处是对容器和较新 JDK 更友好,参数名虽然不同,但-l的语义一样。kill -3是最后的保险,它不会把结果写到某个文件,而是打进目标进程自己的标准输出。在容器里通常意味着去docker logs或者应用日志里捞,位置不固定,一定要提前确认日志落在哪,别一发送信号就找不到转储了。

参数选择上,如果你不确定生产机是三是十一还是十七,先jcmd <pid> help看有没有Thread.print,有就走 jcmd 路线,比 jstack 更稳定。

3.2 采几次、隔多久:单份转储说明不了「一直」还是「恰好」

只采一份转储,最容易得出错误结论。原因很直接:一份转储是某个瞬间的快照,一个线程此刻在RUNNABLE,可能是真的在跑业务,也可能只是恰好轮到它被采样。要区分「一直占着」和「刚好路过」,只能靠多份对比。

我的习惯是采 3 到 5 份,间隔 5 到 10 秒:

PID=12345 for i in 1 2 3 4 5; do jstack -l $PID > td-$i-$(date +%H%M%S).txt sleep 5 done

PID换成实际进程号,sleep 5控制间隔。间隔太短,几份转储几乎是同一时刻,区分度不够;间隔太长,问题可能已经过去了。5 到 10 秒对绝大多数「持续卡顿」类问题是个合理的窗口。

采多份的意义在 TDA 里体现得很明显:同一个线程名在五份里状态反复横跳,说明它在正常干活;如果五份里都在BLOCKED或都在等同一把锁,那它就是一个高度可疑的受害者。这个判断单看一份是做不出来的。压测复盘时,我会在压测开始后、稳定期和收尾前各采一组,覆盖不同负载阶段,比只在「出问题那一刻」采一份有用得多。

3.3 采完先核对完整性,别把半截转储喂给分析器

转储文本有固定结构,拿到文件后花十秒检查三处,能避免后面大量无效分析。

第一看头部。文件开头通常有采样时间、JVM 版本和Full thread dump字样,没有这些说明文件是被截断的,或者根本不是线程转储。

第二看锁信息。用 grep 扫一下有没有Locked ownable synchronizers:

grep -c "Locked ownable synchronizers" td-1-*.txt # 输出 0 说明没带 -l,锁视图会空

第三看有没有明显的编码问题。如果线程名里出现大段乱码,多半是转储文件编码与 TDA 默认不一致,通常是 UTF-8 与非 UTF-8 的差异。这不会让解析失败,但会让「按线程名过滤」和「按业务线程定位」这类操作变得不可靠。遇到这种情况,先确认采样机的file.encoding,必要时在启动参数里统一成 UTF-8 再重采。

注意:kill -3打到标准输出的转储经常被日志框架加时间戳前缀,或者把一行拆成多行。喂给 TDA 之前最好先看一眼原文,必要时把日志前缀去掉再另存为纯文本,否则解析器可能把线程行识别错。

4. 在 TDA 里读一份转储:死锁、BLOCKED 链与线程归属

转储能正常解析之后,真正的活才开始。TDA 的界面把内容分成了线程列表、锁关系、死锁检测几块,关键不是逐个点开,而是先建立「哪些线程是一伙的、谁在等谁」的整体印象。这一章从视图讲到判断,拿最常见的三种现象做例子。

4.1 线程列表先看状态分布,再看堆栈

打开一份转储,第一眼别盯着某个线程的堆栈看,先扫一遍状态分布。四个状态的含义和常见诱因可以对照这张表:

状态含义常见诱因
RUNNABLE正在执行,或阻塞在原生 IOCPU 密集计算、同步 IO 调用
BLOCKED等待进入 synchronized 临界区monitor 锁竞争
WAITING无限期等待被唤醒Object.wait()、LockSupport.park()
TIMED_WAITING带超时的等待Thread.sleep、带超时的wait

绝大多数「线上卡住」的场景,状态分布会给出很强的信号:大量线程挤在BLOCKED,说明有热点锁;大量线程停在WAITING,再看堆栈是不是集中在同一个条件变量上,往往指向线程池耗尽或外部依赖无响应。状态表只是起点,别停在「有多少个 BLOCKED」上,要顺着堆栈看它到底卡在哪个类。

TDA 的线程列表支持按名字和状态过滤。压测复盘时我一般会先过一遍线程名,把业务线程、框架线程、JVM 内部线程手动分堆,再逐堆看。混在一起看,那些 GC 线程和编译线程很容易干扰判断。

4.2 死锁检测:只有「互相持有」才算真正的 Java 死锁

TDA 对 Java 级死锁的处理是最省心的一块:转储里如果存在「线程 A 持有锁 1 等锁 2、线程 B 持有锁 2 等锁 1」这类循环等待,工具会把相关线程标出来,并列出持有关系和等待关系。

判断是不是真死锁,标准其实很明确:循环等待,而且涉及的是对象监视器或ReentrantLock这类可被 JVM 追踪的锁。synchronized和显式 lock 都会被记录,LockSupport.park那种更底层的等待通常不构成可检测的死锁,只能看到WAITING。

实际排查时有两种情况要分开看。第一种是检测到死锁,那就顺着持有链找到两个线程的业务代码入口,基本能定位到加锁顺序不一致的代码位置。第二种是没检测到死锁但业务确实卡了,这类更常见,占了我遇到的八成以上,原因往往不是「互相等待」,而是「大家排队等同一个东西」,比如线程池满、数据库连接耗尽、下游接口超时。这时 TDA 的死锁视图是空的,但线程列表里会有一大片线程齐刷刷地停在等资源上,这时候应该去看那个资源的池子配置,而不是继续翻堆栈。

4.3 多份转储对比:找「状态不变」的线程

把同一时间段的几份转储一起导入,逐个线程看它在这几份里的状态组合,是 TDA 相比纯文本 grep 最有价值的地方。重点找三类线程:

一类是五份里状态始终为RUNNABLE、堆栈顶始终停在同一个方法上的。这类线程高度可疑,要么在死循环,要么在处理一个超大对象或超长查询,是 CPU 打满的首选嫌疑。

第二类是状态始终BLOCKED、且等待的锁归属始终是同一个线程的。这就是典型的「热点锁 + 慢持有者」,问题不在被阻塞的线程,而在那个一直不释放锁的持有者身上。

第三类是状态在RUNNABLE和WAITING之间规律切换、堆栈在等待资源附近反复出现的。这类线程可能只是正常的线程池工作线程,不要误伤。

对比时有个小陷阱:不同转储里线程的nid(原生线程 id)可能复用,靠线程名和tid交叉确认更稳。线程名在不同转储之间一般保持不变,是天然的稳定标识。

4.4 认出框架线程名,能省一半定位时间

转储里线程名往往带前缀,这些前缀就是「谁家的线程」的标签。常用对照:

线程名前缀来源
http-nio-8080-exec-*Tomcat 请求处理线程
DubboServerHandler-*Dubbo 服务端线程
HikariPool-1-*HikariCP 连接池
GC task thread*JVM GC 线程
Common-Cleaner/Reference HandlerJVM 内部线程

看到大量http-nio线程卡住,先怀疑应用层慢;看到HikariPool线程在等,直接去查连接池配置和慢 SQL;看到GC task thread占满RUNNABLE,那问题已经不在业务线程这一层了,得先去处理 GC。认名字这件事,本质上是用线程名反推「这是谁的责任田」,能少翻很多代码。

提示:业务代码里自定义线程池时,给ThreadFactory起一个能看出业务域的名字,比如order-export-pool-*。这一点不影响功能,但出事时能让你在 TDA 里一眼把问题线程从茫茫线程海里捞出来,是性价比最高的一件小事。

5. 避坑清单:解析失败、锁信息缺失与「假死锁」

这一章把最常见的几类翻车场景集中列出来,每条都按「现象 — 原因 — 解决」来写。遇到卡住的地方先在这里对一遍,能省下不少来回试错的时间。

现象一:导入后线程数为 0,或界面只显示零星几行。原因通常是三类:文件不是 HotSpot 格式,比如是 J9 或 JRockit 的转储;文件在复制或传输过程中被截断;文件被错误地改了编码,头部信息识别不出来。 解决:先head看一下文件开头有没有Full thread dump和 JVM 版本,再看结尾是否完整;确认采样命令是jstack或jcmd Thread.print产出的;如果是 J9,换用对应虚拟机自带的转储命令,TDA 对非 HotSpot 格式的支持有限,别硬喂。

现象二:线程列表正常,但锁归属一栏全是空的。原因几乎可以确定是采样时没带-l。jstack的默认输出只包含堆栈和状态,不包含「谁持有了哪个锁」,所以 TDA 只能显示在等,显示不出被谁挡着。 解决:重新采一份jstack -l的转储。已经采过的历史文件没法补救,别在那上面继续分析锁关系,越分析越偏。这个坑我在第一次用时踩得很结实,花了半小时盯着空白的锁视图找原因。

现象三:工具没有报死锁,但业务确实是卡住的。这大概率不是检测逻辑有问题,而是问题本身不属于 Java 级死锁。常见情况是请求全部堆在某个线程池的队列里、数据库连接被占满、或者下游 HTTP 调用超时等待。这些在转储里的表现是大量线程停在WAITING或TIMED_WAITING,等待的既不是对方的锁,也不是对象监视器。 解决:不盯死锁视图,转去看线程列表的堆栈底部和状态分布,配合连接池监控和下游接口耗时数据交叉验证。这时候 TDA 给的信息是「谁在等」,具体等的是什么,得从堆栈顶层的方法调用链和业务日志里拼出来。

现象四:线程名里出现乱码,按名字过滤失效。原因是转储文件编码和 TDA 读取时使用的编码不一致。中文线程名最容易暴露这个问题。 解决:先确认采样机上的默认编码,在启动参数里显式指定-Dfile.encoding=UTF-8后重启应用并重新采样;如果只是分析历史文件,可以在另一个终端把文件转成 UTF-8 再导入。不要在 TDA 里反复切换视图试图「刷新」出正常显示,编码错乱不是界面问题。

现象五:几十兆的大文件导入后界面卡死。单次采样一份转储一般只有几百 KB 到几 MB,但如果把多份转储拼在一个文件里,或者采样的是线程数极多的应用,文件会迅速涨到几十 MB。TDA 加载和渲染这种文件会明显吃力。 解决:保持「一份转储一个文件」,需要对比就在工具里分别导入,别在文本层面合并。分析机内存紧张时,可以先把转储里重复的无关线程块删掉再导入,但删除前务必保留一份原始文件,删错了还能重来,这是分析时的后悔药。

6. 进阶:把三次转储的结论钉死

TDA 把单份转储读得很透,但真正能落成结论的,是把多份放在一起看「同一个线程在时间轴上的行为」。我常用的做法是做一张「线程指纹」:选三到五份间隔均匀的转储,针对每一类可疑线程记录「状态 + 堆栈顶方法」的组合,组合在多次采样里保持不变,才算证据。

拿一个最常见的场景说。某模拟项目在线压测时响应时间阶梯式上升,接口偶发超时。top看 CPU 没有跑满,日志里也没有异常。按第三章的方式采了五份jstack -l,间隔 8 秒。导入 TDA 后先看状态分布,发现每次采样都有超过两百个http-nio线程处于BLOCKED。接着在锁视图里顺着等待链往上追,发现这些线程等的都是同一把由某个定时任务持有的锁。再切回线程列表,找到那个定时任务线程,五份里它全都是RUNNABLE,堆栈顶始终停在同一个批量处理方法上。到这里结论就清楚了:不是死锁,是一个长时间持有锁的批处理任务把请求线程全部堵在门外。

为了让这个结论「可复核」,我会在分析记录里附一段统计脚本的输出,把每份转储里关键线程的状态计数列出来,方便第二个人按同样的文件复现:

# 每份转储里,统计处于 BLOCKED 的 http-nio 线程数 for f in td-*.txt; do n=$(grep -A1 '"http-nio' "$f" | grep -c "State: BLOCKED") echo "$f blocked_http_nio=$n" done

这段脚本不追求多优雅,只解决一个问题:把「我看到的」变成「谁都能重算的」。grep -A1取线程名行的下一行,也就是状态行;grep -c数数量。要注意线程名行和状态行不一定永远相邻,如果某次统计结果跟 TDA 界面里的数字对不上,先检查堆栈结构里有没有插入多余的行,别直接怀疑 TDA。

还有一个用法是「基线对比」。平时在健康状态采一份转储,出问题时再采一份,两段用同一套过滤条件看。健康时WAITING线程的数量和出问题时差了好几倍,这个倍数本身就是很有说服力的证据,比单看一份「有多少个线程在等」更能说明问题。

流程上我自己的习惯是固定的四步:先采三到五份,再在 TDA 里确认状态分布,然后锁定「状态不变」的线程,最后用脚本把关键计数重算一遍,和工具里的结论互相印证。工具和脚本互为校验,任何一边的输出单独拿出来都不算结论。

有一次我图快,只采了一份转储就下判断,把问题归到了一个偶发的RUNNABLE线程上,结果排查方向偏了两天。从那以后,每次拿到转储我都强制走一遍「多份对比 + 脚本复核」的流程,哪怕问题看起来很明显也照做。希望这套习惯能帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询