资讯动态

Java线程转储分析:TDA定位死锁、BLOCKED与线程池卡顿

发布时间:2026/10/27 9:00:01 来源:尧图企业网站定制
简介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 1161 对应 Java 1765 对应 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.shWindows 是tda.batlib/里放主 jar 和依赖另外可能有一份说明或配置目录。参数上-maxdepth 2是为了不递归得太深head -40只是截断输出避免一次刷屏几百行。这个习惯我在任何陌生发布包上都用能少走很多「脚本在哪」的弯路。动手前确认一下bin/下的可执行脚本名字如果跟你预期的不一样以实际文件名为准别硬套。2.3 启动、看端口、把界面打开Linux 或 macOS 下启动脚本通常要补一次执行权限chmod x bin/tda.sh ./bin/tda.shWindows 就在命令行里直接跑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 秒PID12345 for i in 1 2 3 4 5; do jstack -l $PID td-$i-$(date %H%M%S).txt sleep 5 donePID换成实际进程号sleep 5控制间隔。间隔太短几份转储几乎是同一时刻区分度不够间隔太长问题可能已经过去了。5 到 10 秒对绝大多数「持续卡顿」类问题是个合理的窗口。采多份的意义在 TDA 里体现得很明显同一个线程名在五份里状态反复横跳说明它在正常干活如果五份里都在BLOCKED或都在等同一把锁那它就是一个高度可疑的受害者。这个判断单看一份是做不出来的。压测复盘时我会在压测开始后、稳定期和收尾前各采一组覆盖不同负载阶段比只在「出问题那一刻」采一份有用得多。3.3 采完先核对完整性别把半截转储喂给分析器转储文本有固定结构拿到文件后花十秒检查三处能避免后面大量无效分析。第一看头部。文件开头通常有采样时间、JVM 版本和Full thread dump字样没有这些说明文件是被截断的或者根本不是线程转储。第二看锁信息。用 grep 扫一下有没有Locked ownable synchronizersgrep -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.encodingUTF-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线程上结果排查方向偏了两天。从那以后每次拿到转储我都强制走一遍「多份对比 脚本复核」的流程哪怕问题看起来很明显也照做。希望这套习惯能帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑