简介:这是一款面向 Java 开发者和运维人员的线程转储分析工具,完整代码包包含前端 Angular 工程与后端 Java 服务,通过 Web 界面上传 dump 文件后,可辅助排查死锁、线程阻塞及高 CPU 等并发问题。压缩包约 1.49MB,共 81 个文件,其中 25 个 TypeScript、9 个 Java 源码承担核心逻辑,8 个 HTML/CSS 等文件用于界面展示,另有 Maven/Gradle 构建脚本、配置文件与 README 说明,结构清晰便于二次开发。已有 116 人学习下载。工具覆盖 Thread 状态解析、锁竞争检测、堆栈聚合等功能,既适合生产环境快速定位异常线程,也能帮助初学者通过源码理解 Java 并发、JVM 线程模型及 Web 前后端协作方式。
1. Thread_Dump_Analyzing_Tool:线程卡死现场里,把线程转储变成证据的工具
线上接口突然不返回,CPU、内存、GC 指标全部正常,Java 进程像一潭死水。你唯一能拿到的硬证据,就是一份几百行、全是堆栈地址的线程转储文件。Thread_Dump_Analyzing_Tool 这类工具做的事情很简单:解析线程头、抽离线程状态、还原锁的持有与等待关系,再把堆栈做聚类,输出一份能对着源码定位问题的报告。它不玄学,每一条输出都能回查原始 dump 文件。这篇文章面向正在排查线程卡死、死锁、高 CPU 问题的 Java 后端工程师,也适合想把 dump 分析沉淀成巡检脚本的 SRE。下面我按自己做过的方式,从文件格式讲到解析器,再到避坑和进阶验证,完整过一遍。
2. 从 jstack 到线程状态:读懂 Thread Dump 里的锁与栈,工具才能跑对
想写出能落地的分析工具,先得弄清楚输入文件长什么样。线程转储不是自由文本,而是有固定结构的半格式化输出。解析规则稍有偏差,后面所有统计都会失真。
2.1 收集线程转储:jstack -l 与 jcmd Thread.print 的差异
分析工具的第一步是拿到合格的 dump 文件。常见做法是直接在服务器上用 JDK 自带的命令抓取,两个命令都行:
# 方式一:jstack,注意必须带 -l,否则没有锁信息 jstack -l 12345 > /tmp/thread_dump_$(date +%Y%m%d_%H%M%S).txt # 方式二:jcmd,新一代 JDK 推荐,信息更完整 jcmd 12345 Thread.print -l > /tmp/thread_dump_jcmd.txt参数说明:-l代表 long mode,会额外输出- locked <0x...>和waiting to lock <0x...>这类锁详情。没有这个参数,死锁检测和锁等待分析完全无法进行,这是最容易忽略的前提。12345是 Java 进程 PID,先用jps -l查。如果 Java 跑在容器里,先拿到宿主机映射后的 PID,再执行命令。
我一般会连续采样 5 次,间隔 30 秒到 1 分钟:
for i in $(seq 1 5); do jcmd 12345 Thread.print -l > /tmp/thread_dump_$(date +%H%M%S).txt sleep 30 done连续采样的必要性在第 5 章会详细说。这里先记住一个结论:单次 dump 只能说明某一瞬间线程状态,无法区分“持续卡死”和“瞬时抖动”。
2.2 线程头、状态、锁信息、堆栈:一次解析要抓的 4 个字段
打开一份 dump 文件,你会看到大量以"开头的线程块。每个线程块由若干行组成,但真正对分析工具有用的字段只有四个。
| 字段 | 所在位置 | 作用 | 解析方式 |
|---|---|---|---|
| 线程头行 | 以"开头的一整行 | 线程名、线程ID、原生线程ID、CPU 时间、线程地址 | 正则提取线程名与tid=0x... |
| 线程状态行 | java.lang.Thread.State:开头 | 线程当前状态,只能是 RUNNABLE、BLOCKED、WAITING、TIMED_WAITING 等枚举值 | 冒号后直接取字符串 |
| 锁信息 | 堆栈中的- locked、- waiting to lock、- parking to wait for | 判断线程持有哪把锁、在等哪把锁 | 正则提取<0x...>十六进制地址 |
| 线程堆栈 | at ...开头的一系列行 | 定位具体代码位置 | 按行保存,后续做栈帧聚类 |
线程头行里还有一个容易被忽略的信息:cpu=字段。它表示线程累计消耗的 CPU 时间。同样是 RUNNABLE 状态,cpu=3125.00ms和cpu=2.00ms的含义完全不同,前者更像在跑 CPU 密集逻辑,后者可能刚被唤醒。好的分析工具应该把这个字段透传出来,而不是只显示状态。
2.3 一个真实线程块的拆解:RUNNABLE / BLOCKED / WAITING 怎么判定
拿一段典型输出做拆解:
"pool-1-thread-3" #23 prio=5 os_prio=0 cpu=12.00ms elapsed=600.22s tid=0x00007f8b3c0b7000 nid=0x4b12 waiting on condition [0x00007f8b2f7f4000] java.lang.Thread.State: TIMED_WAITING (parking) at jdk.internal.misc.Unsafe.park(Native Method) - parking to wait for <0x00000000f1234567> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:252) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(...) at java.util.concurrent.LinkedBlockingQueue.poll(...) at java.util.concurrent.ThreadPoolExecutor.getTask(...) at java.util.concurrent.ThreadPoolExecutor.runWorker(...)第一行的waiting on condition只是提示性文字,真正的状态判定要看java.lang.Thread.State:这一行。这里的状态是TIMED_WAITING (parking),括号里的parking表示是通过LockSupport.park进入等待。常见误判是把所有 WAITING 都当成异常。实际上,这个线程块是一个线程池工作线程在LinkedBlockingQueue.poll上等任务,属于完全正常的空闲状态。
锁信息- parking to wait for <0x...>解析出来以后,不能直接把它等同于死锁等待。它是 ConditionObject 上的等待,和synchronized的waiting to lock语义不同。第 4 章的死锁检测必须区分这两类锁,否则会把线程池的空闲等待误报成死锁。
3. 搭建最小可用的 Thread_Dump_Analyzing_Tool:解析规则与统计输出
这里我用 Python 写一个最小可用的解析器,选 Python 是因为处理文本和正则方便,部署到服务器上也简单,不需要编译。整个工具只有三个核心部分:解析器、统计模块、报告输出。
3.1 输入设计:一份 dump 文件的切块规则
解析器的输入是一份完整的 dump 文本,不需要预处理。线程块的边界规则很简单:
- 以
"开头的行是一个新线程块的开始。 - 线程块内部所有行都属于当前线程,直到遇到下一个
"开头的行为止。 - 文件末尾最后一个线程块也要收尾。
- 空行跳过,不参与解析。
这个规则在 Linux 上没问题,但 Windows 上抓的 dump 如果通过 FTP 传下来,行尾可能带\r。Python 的splitlines()已经处理了换行符,但如果你用split("\n"),就要手动strip()每一行。我在第 5 章会把这个坑展开。
3.2 用 Python 写解析器:线程块、状态与锁信息一次抓全
import re from collections import Counter LOCK_RE = re.compile(r'<0x[0-9a-fA-F]+>') class ThreadInfo: def __init__(self, header_line: str): self.header = header_line self.name = "" self.tid = "" self.state = "" self.stack = [] self.locked = set() self.waiting_on = None self.cpu_ms = 0.0 self._parse_header(header_line) def _parse_header(self, line: str): """从线程头行提取线程名、tid 和 cpu 时间。""" m = re.match(r'"(.*?)"', line) if m: self.name = m.group(1) m = re.search(r'tid=(0x[0-9a-fA-F]+)', line) if m: self.tid = m.group(1) m = re.search(r'cpu=([\d.]+)ms', line) if m: self.cpu_ms = float(m.group(1)) def add_line(self, line: str): """记录堆栈,并识别锁相关信息。""" self.stack.append(line) hit = LOCK_RE.search(line) if not hit: return lock_addr = hit.group(0) if "- locked" in line: self.locked.add(lock_addr) elif ("waiting to lock" in line) or ("parking to wait for" in line): self.waiting_on = lock_addr def parse_dump(text: str) -> list[ThreadInfo]: """按线程块切分整个 dump 文件。""" threads = [] current = None for raw_line in text.splitlines(): line = raw_line.strip() if not line: continue if line.startswith('"'): if current: threads.append(current) current = ThreadInfo(line) elif current is not None: if line.startswith("java.lang.Thread.State:"): current.state = line.split(":", 1)[1].strip() current.add_line(line) if current: threads.append(current) return threads逻辑说明:ThreadInfo在收到线程头的瞬间完成基础字段解析;add_line负责累积堆栈并识别锁关系,- locked表示持有锁,waiting to lock和parking to wait for都视为等待锁。注意locked用set保存,因为一个线程可能持有多个锁,而且同一把锁在堆栈中可能出现多次,用集合天然去重。
参数说明:LOCK_RE专门匹配<0x...>形式的锁地址。不同 JVM 版本的 dump 里,锁地址格式完全一致,这个正则通用。cpu_ms用float保存,便于后续做排序对比。
3.3 第一版报告:状态分布、锁等待与堆栈 Top N
解析器跑通后,接着写统计报告。先输出最直观的状态分布和锁等待关系:
def build_report(threads: list[ThreadInfo], top_n: int = 10): state_counter = Counter(t.state for t in threads) print("== 线程状态分布 ==") for state, count in state_counter.most_common(): print(f" {state}: {count}") print("\n== 等待锁的线程 ==") lock_waiters = {} for t in threads: if t.waiting_on: lock_waiters.setdefault(t.waiting_on, []).append(t.name) for lock_addr, names in lock_waiters.items(): print(f" {lock_addr}: {len(names)} 个线程等待 -> {names[:5]}")锁等待统计的意义在于快速找出“被多线程争抢的同一把锁”。如果一份 dump 里 20 个线程都waiting to lock同一个地址,那这把锁几乎必然是瓶颈。但注意,这只是初步线索,要配合下面的堆栈聚类才能定位到代码。
热点堆栈聚类是把原始堆栈转成“可读结论”的关键一步。直接打印完整堆栈会刷屏,而且线程池里 50 个线程的堆栈基本相同,人眼看不出来。做法是取每个线程堆栈中at开头的前 5 帧,拼成签名,再按签名计数:
def hot_stack_report(threads: list[ThreadInfo], depth: int = 5, top_n: int = 10): sig_counter = Counter() samples = {} for t in threads: frames = [line.strip() for line in t.stack if line.startswith("at ")] sig = " <- ".join(frames[:depth]) sig_counter[sig] += 1 samples.setdefault(sig, t) print("\n== 热点堆栈 Top %d ==" % top_n) for sig, cnt in sig_counter.most_common(top_n): sample = samples[sig] print(f" [{cnt} 次] state={sample.state}") print(f" {sig}")参数说明:depth=5表示取栈顶 5 帧。取太少区分度不够,取太多会导致每个签名都独一无二,聚类失效。5 到 8 帧是一个比较合理的区间。执行一次python3 dump_analyzer.py dump_20250601_120000.txt,输出就会包含状态分布、锁等待清单和热点堆栈。到这里,最小可用版本已经成型,后面要补的是判定规则,而不是再堆功能。
4. 让分析器会判断:死锁检测、阻塞识别与热点堆栈聚类
解析和统计只是把文件读懂了,真正的核心价值在于自动判定。死锁、阻塞、热点堆栈聚类是三个最常见的分析目标,也是能直接写进巡检脚本的规则。
4.1 锁依赖环与死锁的自动检测
jstack 输出里如果检测到死锁,会在文件末尾打印Found one Java-level deadlock并列出线程。但实际生产中,dump 文件可能是被截断的,或者多个 JVM 的 dump 合并分析,不能只依赖这一行文本。
更通用的做法是构建锁依赖图,找环。规则是:线程持有锁 A 并且等待锁 B,就建立一条边 A → B。如果 A → B 和 B → A 都存在,就说明有循环等待。
from collections import defaultdict def find_deadlock_cycle(threads: list[ThreadInfo]): graph = defaultdict(set) for t in threads: if t.waiting_on is None or not t.locked: continue for lock_held in t.locked: graph[lock_held].add(t.waiting_on) visiting = set() visited = set() path = [] def dfs(node: str): if node in visiting: idx = path.index(node) return path[idx:] + [node] if node in visited: return None visiting.add(node) path.append(node) for nxt in graph.get(node, []): result = dfs(nxt) if result: return result path.pop() visiting.discard(node) visited.add(node) return None for lock in list(graph.keys()): cycle = dfs(lock) if cycle: print("检测到锁依赖环: " + " -> ".join(cycle)) # 打印参与环的线程,方便回溯 for t in threads: if t.waiting_on in cycle and t.locked & cycle: print(f" 参与线程: {t.name} state={t.state}") return逻辑说明:DFS 遍历锁依赖图,发现某个锁节点已经出现在当前递归路径上,就说明存在环。注意graph的 key 是锁地址,不是线程名,这样才能表达“锁 A 被等待,而等待者又持有锁 B”的传递关系。
参数与边界说明:这段代码默认检测第一个死锁环就返回。如果一次 dump 中可能存在多个死锁环,可以把return改成继续遍历,但实际场景中第一个环就足够引发告警了。另外,这个算法必须依赖上一个章节里对locked和waiting_on的准确提取,任何一行锁信息解析失败都会让环检测失效。
4.2 阻塞线程识别:waiting to lock 与 parking to wait
死锁是少数情况,多数线程卡顿是“没死锁但被阻塞”。识别阻塞不能只看线程状态,要细化锁信息:
def blocked_by_monitor(threads: list[ThreadInfo]): """找真正阻塞在 synchronized 锁上的线程。""" blocked = [] for t in threads: if "- waiting to lock" not in t.header and not any( "- waiting to lock" in line for line in t.stack ): continue blocked.append(t) return blocked这段代码的判断依据是- waiting to lock,这是synchronized进入临界区失败时的标志。与之相对的- parking to wait for是LockSupport.park或锁条件等待,这类等待通常是业务上用ReentrantLock、LinkedBlockingQueue等实现的有意识等待,不能直接定性为问题。
区分这两者之后,报告要更明确。一个线程状态显示 BLOCKED,但堆栈在等waiting to lock,基本可以断定它被另一线程持锁阻塞。此时把锁地址对应的持有线程列出来,才是排查方向。持有者信息怎么找?遍历所有线程的locked集合,谁包含这个地址,谁就是持锁线程。这一点属于工具输出的必要字段,建议在报告里直接列出。
4.3 热点堆栈聚类:把 200 个线程归并成 5 类问题
生产环境的 dump 动辄几百个线程,逐个看完全没有可操作性。聚类的基本思路不是直接打印堆栈,而是去掉线程名、锁地址、行号这些噪音,保留方法调用序列,再按序列出现次数排序。
我常用的聚类维度有两档。第一档是栈顶 3 帧,适合快速分类线程在干什么;第二档是栈顶 10 帧,适合定位到具体业务方法。以下是基于第 3 章hot_stack_report的增强版,增加了按状态过滤:
def cluster_hot_stacks(threads: list[ThreadInfo], state_filter: str = "", depth: int = 8): counter = Counter() sample_ref = {} for t in threads: if state_filter and t.state != state_filter: continue frames = [line.strip().replace("~", "").replace("[0x...]", "") for line in t.stack if line.startswith("at ")] sig = " <- ".join(frames[:depth]) counter[sig] += 1 sample_ref[sig] = t for sig, count in counter.most_common(8): t = sample_ref[sig] print(f"[{count}] state={t.state} cpu={t.cpu_ms}ms") print(sig) print()参数说明:state_filter传BLOCKED就能只聚被阻塞的线程;传RUNNABLE只聚活跃线程。frames列表里的replace("~", "")是为了忽略 JIT 编译标记,这些标记不影响方法签名。当同一个业务接口出问题时,你会看到一条栈帧几乎一致的签名重复出现几十次,这时候问题结论基本就出来了。
5. 避坑清单:线程转储分析工具误判与空转的 5 个常见原因
工具不是写完就能用的。解析 dump 的过程中总有各种意外,我在实际落地时踩过不少坑,下面按“现象 → 原因 → 解决”整理出来。
5.1 换行符与多余空行:Windows 上解析不到的隐形坑
现象:工具在本地测试正常,部署到 Windows 服务器或拿到 Windows 导出的 dump,解析出的线程数变得很少,甚至解析出 0 个线程。 原因:Windows 下保存的文件行尾是\r\n,有些抓取脚本还带了 UTF-8 BOM。用split("\n")切分后,每行末尾残留\r,导致line.startswith('"')判断失败。 解决:所有行读取统一走strip(),或者直接用splitlines()。文件打开时用encoding="utf-8-sig"处理 BOM。检查文件头第一行是否包含Full thread dump字样,没有的话先人工确认文件来源是不是 jstack。
5.2 采样间隔太短与单次转储:连续 dump 才能区分瞬时与持续
现象:只抓一次 dump,发现 10 个线程 WAITING,但重启后问题不再复现,工具白跑一趟。 原因:单次 dump 是瞬时快照。一个偶发的慢 SQL 或一次 GC 停顿都可能让线程短暂 WAITING,这不代表持续故障。 解决:不要只传一份 dump,至少间隔 30 秒连续抓 5 份。工具增加一个最小输入约束:少于 3 份 dump 时,输出结果只做参考不触发告警。分析锁等待时,只有在多份 dump 中同一锁地址反复出现,才具备告警说服力。
5.3 线程名不是唯一标识:线程池同名线程统计被覆盖
现象:工具输出的热点堆栈计数偏低,线程总数对不上,明明有 200 个线程,按名字统计只有 30 个。 原因:线程池默认命名规则是pool-1-thread-1到pool-1-thread-N,多份 dump 合并后线程名大量重复。如果代码里用name做字典 key,相同名字的线程会互相覆盖。 解决:用tid做线程唯一标识,name只做展示。ThreadInfo里我已经把tid单独提出来,后续所有统计和去重都以它为准。
5.4 RUNNABLE 占比高不是结论,还要看栈
现象:工具输出 RUNNABLE 线程占 80%,直觉判断 CPU 忙,但实际 CPU 利用率只有 15%,问题定位完全偏离。 原因:RUNNABLE 状态只表示线程“可运行”,不表示它正在占用 CPU。在 JVM 的线程转储中,网络 I/O、锁竞争短暂自旋都可能让线程停留在 RUNNABLE。 解决:RUNNABLE 分组之后必须下钻堆栈。如果栈顶是SocketInputStream.read或EPollArrayWrapper.epollWait,这其实是 I/O 等待;如果栈顶是Unsafe.park,那是锁等待;只有栈顶频繁出现execute、invoke这类纯计算帧时,才能判断是 CPU 密集。工具报告里要在 RUNNABLE 分组内再做一次热点堆栈聚类,不要直接给结论。
5.5 死锁误报:瞬时锁竞争与真死锁的分辨
现象:工具检测到锁依赖环,告警死锁,但业务方反馈系统没有完全卡死,接口只是偶发超时。 原因:锁依赖环只是必要条件,不是充分条件。一次 dump 中,线程 A 正持有锁 X 等待锁 Y,线程 B 正持有锁 Y 等待锁 X,但 B 的锁 Y 可能马上释放,A 就能继续跑。这是瞬时锁竞争,不是死锁。 解决:死锁判定加上“多份 dump 复现”约束。3 份采样中同一锁环出现至少 2 次,才输出死锁告警。另外,工具展示锁环时不能只给锁地址,还要同时展示两个参与线程的完整堆栈,让工程师能人工确认循环等待是否真实持续。
6. 进阶玩法:批量分析、基线对比与用已知故障验证你的工具
工具能跑通一条命令后,再往深走一步,把它嵌进日常巡检和故障复盘流程。
6.1 批量分析:一次跑完整个目录的 dump
故障现场往往不止一份 dump。用 Shell 循环包一下,输出每个文件的摘要,比一条条执行更快:
#!/usr/bin/env bash for f in /tmp/thread_dump_*.txt; do echo "== $f ==" python3 dump_analyzer.py "$f" | head -20 done这层包装的价值是让整个目录的状态一眼看完。哪个文件出现 BLOCKED 线程多、哪个文件存在锁依赖环,直接对比输出即可。
6.2 基线对比:把故障时刻与正常时刻 diff 出来
比单文件分析更有效的是基线对比。挑一个业务低峰期的 dump 存为baseline.txt,故障时再抓一份,用解析器把两次的线程状态和锁等待关系分别写入 JSON,再做差值。重点看三个维度:
- 新增的 WAITING/BLOCKED 线程数。
- 新增的锁等待关系,尤其是指向同一锁地址的新等待者。
- 热点堆栈排名变化。
python3 dump_analyzer.py baseline.txt --json > baseline.json python3 dump_analyzer.py failure.txt --json > failure.json python3 dump_diff.py baseline.json failure.jsondump_diff.py里直接比对两个 JSON 中的lock_waiters字段,输出只在故障文件中出现的锁地址,就能快速锁定嫌疑锁。
6.3 验证方法:拿一个已知故障的 dump 来校验规则
最后说一个我保持了很久的习惯,也是最重要的进阶技巧:工具必须先用自己的历史故障数据验证,再投入使用。我手头会保留几个典型的测试样本,其中一个模拟死锁的片段长这样:
"demo-thread-1" #10 prio=5 tid=0x... nid=0x... waiting for monitor entry java.lang.Thread.State: BLOCKED (on object monitor) at com.demo.ServiceA.run(ServiceA.java:35) - waiting to lock <0x00000000dead0001> (a java.lang.Object) - locked <0x00000000dead0002> (a java.lang.Object) "demo-thread-2" #11 prio=5 tid=0x... nid=0x... waiting for monitor entry java.lang.Thread.State: BLOCKED (on object monitor) at com.demo.ServiceB.run(ServiceB.java:22) - waiting to lock <0x00000000dead0002> (a java.lang.Object) - locked <0x00000000dead0001> (a java.lang.Object)用这个片段跑工具,应该输出dead0001 -> dead0002 -> dead0001的锁依赖环,并列出两个参与线程。任何一次修改解析逻辑后,先把这段样例跑一遍,确认它还能正确识别,再拿到真实环境用。我早期犯过的一个错误是:手工 grep 锁信息时漏了一个parking to wait for,导致误把线程池空闲当成阻塞。从那以后,我坚持所有判定必须有样例 dump 兜底,先让工具说人话,再让它去做判断。
希望帮到你。
本文还有配套的精品资源,点击获取