简介:这是一款面向Java开发者和系统运维人员的线程转储(Thread Dump)分析工具,提供Web界面用于远程诊断死锁、线程阻塞等并发性能问题。项目采用前后端分离架构,既可用于生产环境问题排查,也适合作为学习JVM线程机制与并发编程的实践案例。资源共81个文件、压缩包约1.49MB,其中包含25个TypeScript、9个Java、9个JSON、8个CSS及8个HTML等文件,前端基于Angular实现界面交互,后端基于Maven构建并提供线程转储解析接口,目录划分清晰,便于按模块阅读。已有116人学习,适合需要掌握线程状态分析、死锁检测及Web化运维工具的Java开发者。包内除完整前后端源码外,还保留了pom.xml、mvnw、angular.json等构建配置与说明文档,可快速搭建运行环境,深入理解Java并发API、JVM线程管理及日志监控在真实场景中的应用。
1. Thread_Dump_Analyzing_Tool 到底在解决什么问题:从原始快照到故障结论
线上接口突然从 20ms 涨到 2 秒,或者进程深夜悄悄假死,Java 工程师的第一反应基本都是抓一份线程 dump。但 dump 到手那一刻问题才真正开始——几百个线程、几千行栈帧,眼睛根本看不过来。Thread_Dump_Analyzing_Tool 这类工具的价值就是把原始快照变成结论:统计线程状态分布、检测死锁环、定位热点线程,让排障从「人肉翻栈」变成「看报告」。
下面按我自己做这类工具的流程来讲。先讲清 dump 里每个字段意味着什么,因为工具的价值上限由你对输入数据的理解决定;再给出一套可照抄的最小 Python 实现,覆盖解析、死锁检测和状态统计;最后列出线上环境真踩过的坑,多数误报不是算法不行,而是挂在输入数据的小细节上。
适合谁读?被线上 dump 折磨过的后端开发和运维,以及想写内部诊断工具但不想从零试错的人。
2. 抓对原始线程 dump:jstack 与 jcmd 的最小用法和字段解读
线程 dump 分析工具的第一原则:垃圾进,垃圾出。我一开始就吃过亏——用不带-l的 jstack 抓数据,解析器跑完一片正常,死锁检测却永远返回空。后来才明白,锁信息根本没被抓下来。所以这一章先把「喂给工具的数据」讲透:怎么抓、抓成什么样、哪些字段是关键。
2.1 抓 dump 的三个命令:jstack、jcmd、kill -3 怎么选
最常见的抓取命令是jstack -l <pid>,但不同 JDK 版本、不同权限环境下,可用命令不一样。我一般按下面的顺序判断:
# 找到目标 Java 进程,jps 比 ps 更精准 jps -l # 本机 attach 权限正常时,首选 jcmd,JDK 8 起内置 jcmd <pid> Thread.print -l > dump_1.txt # 工具链不完整时退回 jstack,注意也要带 -l jstack -l <pid> > dump_1.txt # 容器/生产环境没有 attach 权限时,用 kill -3,输出进 stdout 日志 kill -3 <pid>-l是关键参数,它把每个线程持有的锁(locked <...>)和正在等待的锁(waiting to lock <...>)打出来。没有这些行,后面的死锁检测全是空谈,工具再漂亮也白搭。kill -3不是杀进程,而是向 JVM 发 SIGQUIT 信号,JVM 收到后把线程 dump 写到标准输出;生产环境通常配合 nohup 或日志重定向用。
注意:
kill -3的 dump 输出到进程标准输出,不一定落在当前终端。生产环境里 stdout 通常被日志框架重定向,抓完去对应日志文件里搜Full thread dump确认落盘位置。
另外,jcmd的输出格式和jstack在某些版本有细微差异,锁行的缩进、空行数量不完全一致。解析器最好写成容错的正则匹配,而不是严格对齐空格,否则换个 JDK 版本就翻车。
2.2 一份 dump 里的字段地图:线程名、nid、状态与锁行
拿到 dump 先别急着丢给工具,花两分钟看一段原始输出,后面写解析器会顺很多。下面是一段有代表性的线程记录:
"http-nio-8080-exec-7" #25 daemon prio=5 os_prio=0 cpu=8.75ms elapsed=96.2s tid=0x00007f3cc012d800 nid=0x1f3b runnable [0x00007f3cc05e7000] java.lang.Thread.State: RUNNABLE at java.net.SocketInputStream.socketRead0(Native Method) at java.net.SocketInputStream.socketRead(SocketInputStream.java:110) at com.example.web.OrderController.getOrder(OrderController.java:42) - waiting to lock <0x000000074e8038d0> (a java.lang.Object)第一行是线程头:双引号里是线程名,nid=0x1f3b是操作系统原生线程 ID 的十六进制表示,tid是 JVM 内部线程 ID,中括号里是线程栈指针。第二行java.lang.Thread.State: RUNNABLE是 JVM 线程状态,这是后面所有统计的基准。- waiting to lock <0x...>表示这个线程正在阻塞等待某把锁;如果看到- locked <0x...>,表示它已经持有这把锁。
容易被忽略的是cpu=8.75ms elapsed=96.2s,部分较新版本 JDK 的 dump 第一行会带这两个数字:线程累计 CPU 消耗和存活时长,对定位热点非常有用。没有这行也没关系,后面 3.3 给降级方案。
2.3 四种核心状态怎么解读:RUNNABLE、BLOCKED、WAITING、TIMED_WAITING
如果把 dump 比作体检报告,线程状态就是生命体征。下面是四种高频状态在分析中的含义,我一般把它们映射成三类结论:干活、等待、卡死。
| 状态 | 含义 | 分析结论倾向 |
|---|---|---|
| RUNNABLE | 线程正在执行指令,可能在业务代码也可能在 native 调用 | 结合栈顶帧判断是正常执行还是 CPU 热点 |
| BLOCKED | 线程在等一把被其它线程持有的锁(进入 synchronized 块失败) | 锁竞争信号,必须找持锁线程 |
| WAITING | 线程主动等待 notify 或 LockSupport 唤醒 | 可能是池化空闲,也可能是业务死等 |
| TIMED_WAITING | 带超时的等待(sleep、wait(timeout)、parkNanos) | 多数正常,关注超时时间是否异常 |
最容易翻车的判断是「WAITING 等于出问题」。一个健康的 Spring Boot 服务,线程池里有几十个线程长期停在 WAITING / TIMED_WAITING,栈顶是ThreadPoolExecutor.getTask或LockSupport.park,这是正常池化空闲。真正要警惕的是栈顶停在业务代码Object.wait()上、长时间不返回——那是黑匣子故障,notify 一旦丢失就是真死等。
3. 从零写一个线程 dump 分析工具:解析器、死锁检测与报告三件套
这一章直接给可抄的作业。实现语言选 Python 3,原因有三个:正则处理和文本遍历快,dataclass让记录结构清晰,输出报表不需要任何重量级依赖。整个工具拆三个模块:解析器把文本变结构,分析器找死锁算热点,报告器打印结论。把三段代码拼成一个analyzer.py就能跑。
3.1 解析器:用正则把线程块拆成结构化记录
解析的核心是识别「一个线程从哪里开始、到哪里结束」。线程头行以"开头,包含nid=0x...,结尾是[...]栈指针;之后所有缩进行都属于这个线程,直到下一个线程头。基于这个规律写一个按行扫描的状态机解析器:
import re from dataclasses import dataclass, field @dataclass class ThreadRecord: name: str nid: str # 原生线程 ID(十六进制字符串) state: str = "UNKNOWN" stack: list = field(default_factory=list) locks_held: list = field(default_factory=list) # - locked <...> locks_waiting: list = field(default_factory=list) # - waiting to lock <...> HEAD_RE = re.compile(r'^"(.+?)".*?nid=0x([0-9a-f]+).*?\[.*?\]$') STATE_RE = re.compile(r'^\s+java\.lang\.Thread\.State:\s+(\w+)') STACK_RE = re.compile(r'^\s+at\s+(.+)$') LOCKED_RE = re.compile(r'^\s+- locked <(0x[0-9a-f]+)>') WAITING_RE = re.compile(r'^\s+- waiting to lock <(0x[0-9a-f]+)>') def parse_dump(path): records = [] cur = None with open(path, encoding="utf-8", errors="replace") as f: for line in f: head = HEAD_RE.match(line) if head: cur = ThreadRecord(name=head.group(1), nid=head.group(2)) records.append(cur) continue if cur is None: continue m_stack = STACK_RE.match(line) if m_stack: cur.stack.append(m_stack.group(1)) continue m_state = STATE_RE.match(line) if m_state: cur.state = m_state.group(1) continue m_lock = LOCKED_RE.match(line) if m_lock: cur.locks_held.append(m_lock.group(1)) continue m_wait = WAITING_RE.match(line) if m_wait: cur.locks_waiting.append(m_wait.group(1)) return records几个参数说明:HEAD_RE末尾的\[.*?\]$保证只匹配真正的线程头,避免把栈帧里的at行误当成新线程;nid保留十六进制原始字符串,千万别在解析阶段转成十进制,后面要对账时再按需转换。锁行正则只取地址0x...,因为死锁检测只依赖地址相等性,不关心锁的类名。errors="replace"处理 dump 里可能出现的非法 UTF-8 字节——中文日志混入时常见,不加这一项解析到一半就抛异常了。
3.2 死锁检测:把锁等待关系画成图,再找环
死锁的数学本质是「锁等待图里有环」:线程 A 持有锁 1 等待锁 2,线程 B 持有锁 2 等待锁 1,A、B 互相卡死。实现分两步:先建立「锁地址 → 持有线程」的映射,再根据waiting to lock建线程间的等待边,最后 DFS 找环。
def find_deadlock_cycles(records): # 锁地址 -> 持有该锁的线程,这里用 nid 做唯一标识 holder = {} for r in records: for lock in r.locks_held: holder[lock] = r # 等待图节点用 nid,线程池同名线程不会互相误并 graph = {} name_of = {} for r in records: name_of[r.nid] = r.name for lock in r.locks_waiting: h = holder.get(lock) if h is not None and h.nid != r.nid: graph.setdefault(r.nid, []).append(h.nid) cycles = [] visited, path = set(), [] def dfs(nid): visited.add(nid) path.append(nid) for nxt in graph.get(nid, []): if nxt in path: i = path.index(nxt) cycle = path[i:] + [nxt] cycles.append([name_of[n] for n in cycle]) elif nxt not in visited: dfs(nxt) path.pop() for nid in graph: if nid not in visited: dfs(nid) return cycles这段代码就是三色 DFS:path是当前深搜路径上的线程 nid,如果下一步要去的 nid 已出现在path里,说明找到了环;visited防止重复遍历,path.pop()保证回溯到上一个分支点。两个注意点:第一,holder映射依赖锁地址完整,0x前缀一旦丢或地址被截断,死锁检测必然漏报;第二,同线程重入同一把锁会在locks_held里出现重复地址,所以分析前应该去重——下面 4.4 会细说。
3.3 状态统计与热点线程:把 dump 变成一张一眼能看懂的表
死锁检测解决「卡死」,热点分析解决「慢」。没有cpu=字段时,热点判断靠多份 dump 交叉验证:同一线程名如果多次出现在 RUNNABLE、且前几个栈帧落在业务代码包名里,大概率在烧 CPU。代码先按状态聚合成占比表,再对多次 dump 统计 RUNNABLE 线程名频次:
from collections import Counter def summarize(records): cnt = Counter(r.state for r in records) total = len(records) or 1 print(f"{'state':<18}{'count':>6}{'percent':>9}") for state, n in cnt.most_common(): print(f"{state:<18}{n:>6}{n * 100 // total:>8}%") def hot_threads(all_records, top_n=5): # all_records 是多次 dump 解析结果的列表,定位反复 RUNNABLE 的线程 runnable_names = Counter() for records in all_records: for r in records: if r.state == "RUNNABLE": runnable_names[r.name] += 1 return runnable_names.most_common(top_n)hot_threads需要你手动把多次解析结果传进来,这呼应了上一章说的原则:单份 dump 是瞬时快照,不能当结论。最后在主函数里串起来:解析、找环、统计、打印。工具本身很小,但解析规则和检测逻辑跟商业级线程分析工具是同一条思路——先结构化,再找关系,最后呈现。
4. 线程 dump 分析工具的避坑实录:五个让结果失真的常见问题
工具写完只是开始。我跑了半年,发现大多数误报漏报不是算法问题,而是输入数据理解和抓取姿势不对。这五个坑按出现频率排序,每条按「现象 → 原因 → 解决」讲,方便对照排查。
4.1 现象:nid 对不上操作系统线程 PID,热点定位落空
抓完 dump,用top -H -p <pid>看到 CPU 最高的线程号,回 dump 里找对应线程,怎么都对不上。原因很简单:内核和 top 用十进制 PID,dump 里的nid是十六进制,两套进制没换算。解决方法是先转进制再搜:
# 把 top -H 里最高的线程号(假设是 8003)转成十六进制 printf '%x\n' 8003输出1f43,去 dump 里搜nid=0x1f43就是它。反过来从 dump 拿 nid 想确认内核线程,用echo $((16#1f43))。这个坑几乎每个新手必踩一次,解决成本却极低,建议直接写进工具的手册里。
4.2 现象:WAITING 线程一大堆,工具告警「线程池异常」
第一版工具跑完,状态统计里 WAITING 占 40%,半夜把运维叫起来。后来一查,凌晨低峰期 Tomcat 线程池和业务异步线程都在正常池化等待。原因是我把「WAITING」等同「出问题」,没看栈顶帧。解决方法是给分析器加规则:当线程处于 WAITING/TIMED_WAITING 且栈顶是ThreadPoolExecutor.getTask、LockSupport.park、ForkJoinPool.awaitWork这类基础设施帧时,归为「正常空闲」;只有栈顶是业务代码才算「可疑阻塞」。这行判断逻辑虽简单,能把误报率从 40% 打到接近零。
4.3 现象:同一个死锁环时有时无,检测结果不稳定
连续抓五份 dump,死锁检测只报出两次同一个环。开始我以为是算法随机性,后来想明白:dump 是瞬时快照,线程在「等锁」和「抢到锁被调度」之间切换,某个瞬间锁被放下,环就断裂了。解决方法是把判定条件从「一份 dump 里有环」改成「N 份里至少 M 份检出同一个环」,通常取 N=5、M=3,抓取间隔拉到 3 秒以上,避免连续快照采样到同一状态造成假阳性。
4.4 现象:线程「持有锁 A 等锁 B」时,工具在它自己身上报了个环
这是最隐蔽的坑。一个线程栈里同时出现locked <A>和waiting to lock <B>,正好是死锁环的典型形态;但 synchronized 可重入,同一线程对同一把锁 A 可能有多条locked记录,如果不做处理,「自己持有 A、自己又等待 A」会被误判成自环。原因就是没区分「持有」和「等待」两个集合,也没对锁地址去重。解决方法是:locks_held按线程内去重(同一地址只保留一条),建等待图时强制h.nid != r.nid排除自环。另外提醒一句,锁地址解析必须取完整 12 位以上十六进制,截断会导致不同锁碰撞,holder 映射全错。
4.5 现象:老版本 JDK 的 dump 没有 cpu 字段,热点线程全部落空
JDK 8 的 jstack 输出里没有cpu=和elapsed=,我写的热点分析依赖这个字段,结果老版本环境跑出来一片空白。解决方法是降级:检测到 dump 里没有 cpu 字段时,自动切到「多份 dump 交叉统计」模式——抓 5 份间隔 3 秒的 dump,统计线程名出现在 RUNNABLE 状态的次数,按频次排序。虽然不如 cpu 精确,但定位「某个线程池忙个不停」已经足够。还要记得,jcmd 在部分版本里Thread.print默认输出也不带 cpu,先确认目标环境的 JDK 行为再决定抓取命令。
5. 让分析工具在线上站住脚:五连抓验证法与下一步扩展
工具跑通只是第一步,难的是确认它没在骗你。我的习惯是造一份「自杀式」测试:写一个 Java 程序,两个线程互相持有对方需要的锁,第三个线程跑死循环烧 CPU,跑起来后抓五份 dump 喂给工具。这个验证比单元测试实在,因为它用的是 JVM 真实产出的 dump,格式兼容性和分析逻辑一起验了。
# 测试机抓 5 份间隔 3 秒的 dump for i in 1 2 3 4 5; do jcmd "$PID" Thread.print -l > "dump_$i.txt" sleep 3 done # 跑工具,核对死锁环与热点线程 python3 analyzer.py dump_*.txt --states --deadlock验证要点有两个:死锁环应该连续三份以上出现,且线程名、锁地址能对上测试代码;热点线程里必然包含烧 CPU 的那个线程。如果你加了自动告警,先在测试机验证它不误报正常环境。
验证通过后,把抓取脚本交给定时任务,每 10 分钟抓一次,把状态占比、死锁结果推到告警平台。线上抓 dump 本身有开销(STW 变长、GC 暂停被拉长),频率别太低;我的血泪经验是 10 分钟一次以下,并且用jcmd而不是jstack,减少进程外 attach 的隐患。后续扩展方向也清晰:把多份 dump 的状态占比做成时序曲线,能看到「线程池慢慢被占满」的渐变故障;再做线程池名归并,把线程名只差数字的池合并统计,误报又能降一档。每接入一个新环境,我先拿一份历史 dump 对一遍输出,确认格式符合预期再放开。希望帮到你。
本文还有配套的精品资源,点击获取