凌晨一点四十七分,监控群里被一张 top 截图刷屏:支付网关服务器的 CPU 已经顶满了。我登录上去,熟练地敲下两行命令,拿到一份线程转储。文件打开一看,我反而更紧张了——整个 JVM 里只有 36 个线程,干干净净,没有几百个卡在锁上的线程池,也没有明显挂着 Waiting 的灵异线程。按以往的经验,我本来应该从一堆可疑线索里捞凶手,但这次摆在眼前的是一份看起来毫无破绽的现场。
我盯着这份 dump 看了十分钟,心里冒出一种不太好的预感:JVM 线程诊断最难的场景,不是几百个线程里捞一个异常线程,而是当线程数少、状态正常、调用栈干净,你反而要花更多力气去证明哪个“完美嫌疑人”才是真凶。 这篇文章就借这次排查过程,聊聊我从 36 个线程里排除一个又一个疑点、最后锁定真凶的完整链路,以及我在实操中踩过的误判坑。
1. 第一时间拿到可信的快照:线程转储的采集不只是“敲一条命令”
线程转储是 JVM 诊断的基础证据,很多人上来就敲 jstack -l <pid>,把文件丢到一边开始猜。这一步看起来简单,实际上不同场景下采集方式会直接影响判断结果。
1.1 四种常见采集方式的差异
我平时常用的线程转储方式有四种,适用场景完全不同:
| 采集方式 | 命令 | 特点 | 适合场景 |
|---|---|---|---|
| jstack | jstack -l <pid> |
最通用,带锁信息 | 常规排查、现场快照 |
| jcmd | jcmd <pid> Thread.print -l |
JDK 自带,不依赖额外工具 | 容器内、受限环境推荐 |
| kill -3 | kill -3 <pid> |
输出到 JVM 标准输出 | 进程卡死、jstack 无法 attach 时 |
| JMX 工具 | jconsole / VisualVM | 可视化,可远程 | 本地开发、测试环境 |
这次线上环境是容器部署,JVM 的 PID 通常是 1,直接 kill -3 1 不一定能把输出导到你手上,因为标准输出会被容器日志层接管。我习惯先用 jcmd <pid> Thread.print -l > /tmp/dump1.txt,如果 jcmd 也拿不到线程,才考虑 kill -3 配合查看容器日志。
1.2 单次转储和多次采样的决策
很多人拿一份 dump 就开始分析,我建议至少间隔 10 到 15 秒连续抓三次。原因很直白:线程状态是瞬时快照,不是持续录像。 一个正在执行正则匹配的线程,抓它的时候恰好落入一个快速分支,栈可能只有三层;但你隔 15 秒再抓,它可能又回到同一个方法。如果三次都停在同一个栈顶,这个线索的可信度会高很多。
这次排查我抓了三次,每次间隔 12 秒。第一次看到 pay-callback-pool-1-thread-7 处于 RUNNABLE,第二次、第三次它依然在同一个方法附近。这时候我才决定深入追这条线程,而不是直接去看其他几个看起来更“异常”的 Waiting 线程。
提示:线程转储建议在 CPU 持续飙高时采集,如果 CPU 已经恢复正常,你抓到的 dump 很可能没有参考价值。先确认故障还在,再执行采集。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从36个线程中排除那些“本来就该在”的线程
拿到 dump 后,我做的第一件事不是找凶手,而是先排除所有“正常存在但不是问题源”的线程。这一步叫建基础盘,价值在于避免过度关注无害的线程而偏离主线。
2.1 先识别 JVM 内部线程,避免误伤
这份 dump 中的 36 个线程,粗略分下来大概可以分成四组。第一组是 JVM 内部基础线程,它们是 JVM 正常运行的零件,通常不需要排查:
| 线程名示例 | 角色 | 是否优先关注 |
|---|---|---|
main |
主线程,可能只是承载启动流程 | 一般 |
Reference Handler |
引用对象处理 | 不关注 |
Finalizer |
终结器线程 | 如果堆积可能有问题,但少见 |
Signal Dispatcher |
信号分发 | 不关注 |
VM Thread |
JVM 系统线程 | 不关注,除非 VM 操作持续过长 |
C2 CompilerThread0/1 |
JIT 编译线程 | 高 CPU 小心,见第 5 节 |
GC Thread |
GC 线程 | 高 CPU 需要另查 |
Common-Cleaner |
清理器 | 不关注 |
JMX server connection timeout |
JMX 连接管理 | 不关注 |
这一组大约占掉 12 个左右。如果你不了解线程名对应的角色,很容易把所有线程都当成嫌疑人,浪费大量时间。建议平时就把常见线程名单整理成一份速查表,遇到问题直接对照排除。
2.2 识别业务线程池,别把“同名分组”当成一个整体
第二组是业务线程。这次最显眼的就是 pay-callback-pool-1-thread-1 到 thread-10,一共 10 个,这是一个自定义线程池。第三组是 HTTP 容器线程,像 http-nio-8080-exec-*,大概 6 个。第四组是其他零散线程,包括 ForkJoinPool 公共池、Netty 线程等。
这里有个常见的坑:同名的线程池线程,不代表行为一致。 线程池会复用线程,同一个 pay-callback-pool-1-thread-7,这一刻可能在处理任务 A,下一秒可能被分配任务 B。如果只抓一次 dump 就断定“这个线程有问题”,很容易冤案。我这次是因为连续三次都看到同一个线程号运行非常相似的栈帧,才把它列为重点。
我一般会在进入深层分析前,先把 36 个线程按“业务线程 / 容器线程 / JVM 内部线程”三类标记好,给每个业务线程记录 CPU 占用线索。这个排布过程大约需要几分钟,但它决定了后续每一步的证据是否可信。
3. 完美嫌疑人的画像:RUNNABLE、无锁、无等待,为什么最容易骗人
很多人的第一反应是看线程状态:BLOCKED 是锁竞争,WAITING 是等待通知,TIMED_WAITING 是睡了多久。那我看到一条 RUNNABLE 的线程,通常会怎样判断?——它正在正常运行,可能是无辜的。
这次的真凶恰恰就是这种状态。
3.1 RUNNABLE 不等于“正在干活”,更不等于“没发生问题”
RUNNABLE 状态从 JVM 角度看,只是说线程可以在 CPU 上运行;它可能正占着 CPU 执行一个死循环,也可能在执行一次超长正则回滚,也可能只是在等一个网络读取返回(在 Java NIO 模型中这也可能显示成 RUNNABLE)。它并不是“安全”或“正常”的同义词。
反过来,BLOCKED 和 WAITING 也不一定是问题本质。很多线程阻塞是因为排队等待一个锁,而锁被谁持有、为什么持有,才是关键。如果只按状态找线程,你会理所当然地盯住几个 WAITING 和 BLOCKED 线程,然后发现它们等了很久但问题依旧——因为它们只是“受害者”,不是“加害者”。
3.2 “干净栈”的欺骗性:同一帧反复出现才是重点
当时 dump 文件里,pay-callback-pool-1-thread-7 的栈大概是这样的伪代码形式:
code复制"pay-callback-pool-1-thread-7" #36 prio=5 os_prio=0 cpu=23840.00ms
java.lang.Thread.State: RUNNABLE
at java.util.regex.Pattern$GroupTail.match(Pattern.java:4724)
at java.util.regex.Matcher.match(Matcher.java:1290)
at java.util.regex.Matcher.find(Matcher.java:676)
at com.example.PaymentCallbackValidator.checkSignature(PaymentCallbackValidator.java:88)
at com.example.CallbackProcessor.process(CallbackProcessor.java:42)
at com.example.threadpool.TaskWorker.run(TaskWorker.java:21)
这个栈最大的特点是:没有锁、没有等待、没有阻塞,非常干净。如果只看这个局部信息,你大概率会认为它只是正在执行一个普通校验动作。但结合三次采样都能看到类似方向的方法调用,以及它的 cpu 时间明显高于同一池里的其他线程,指向就清晰了——它在重复执行 Matcher.find,而且每次都落在同一个方法里。
单个 find 调用本身没毛病,正则匹配中偶尔卡一次也很常见。但如果线程在短时间内反复到达同一个匹配点,很可能触发了正则灾难性回溯:某个字符序列让匹配引擎穷举了大量分支,CPU 时间被耗尽,程序表面却不报错。
3.3 为什么叫它“完美嫌疑人”
我复盘时对同事说,这个线程具备“完美嫌疑人”的三个特征:
- 身份无害:它不在 GC 线程、编译器线程、JMX 线程的名目里,就是个业务回调线程。
- 行为无害:它没有拿锁不放,没有等锁,没有主动睡眠。
- 表面干净:栈很浅,看起来就像一个普通方法调用。
这三点组合在一起,让它在 36 个线程里显得非常无辜。真正暴露它的不是单次 dump 内容,而是多次采样后线性的 CPU 时间和栈帧重复。
提示:看线程栈不要只看“当前在做什么”,还要看“它过去一段时间都在做什么”。线程 CPU 时间、多次采样栈帧一致性,比单张快照更有决策价值。
4. 锁死证据链:从 CPU 热点验证到根因确认
怀疑归怀疑,要确认这条线程是真凶,我需要完整的证据链。排查 JVM 线程问题,最忌讳的就是“猜了个可能”就直接改代码。下面是我这次实际操作中的验证路径。
4.1 线程 ID 转换:把操作系统的线程号匹配到 dump 中的 nid
先用 top -H -p <jvm_pid> 找到 CPU 占用最高的线程号。比如 top 输出里 PID 列显示 23456,这是操作系统线程号,而 jstack 里对应的是十六进制 nid=0x...,需要换算。
bash复制printf "%x\n" 23456
输出可能是 5ba0,然后去 dump 里找 nid=0x5ba0。这一步看起来简单,但很多人会在转换时看错进制,导致匹配到错误线程。建议每次匹配后都确认线程名和栈顶方向是否符合预期。
我第一次用 top -H 看到占用 CPU 最高的线程号时,发现它对应的恰好就是 pay-callback-pool-1-thread-7,这为“完美嫌疑人”补上了第一个直接证据。但注意,这只是单次快照,所以我隔了几秒又用 top -H 抓了一次,确认它持续占据 CPU 高峰。
4.2 用 Arthas 和 async-profiler 做交叉确认
jstack 只能证明线程存在和状态,top -H 能证明它在消耗 CPU,但要进一步看清它到底在跑什么,可以用 Arthas 的 thread 命令:
bash复制thread -n 3
这条命令会打印 CPU 占用最高的前 3 个线程,并展示它们当前栈。如果输出里第一个就是 pay-callback-pool-1-thread-7,并且栈帧和 jstack 抓到的方向一致,证据链基本成形。
如果环境允许,也可以使用 async-profiler 抓火焰图,以 CPU 采样方式聚合方法调用。不过在生产环境上,async-profiler 的开销和权限问题会更明显,我通常只在测试环境复现问题后用来做精细定位,线上首选用 Arthas 快速验证。
Arthas 也有局限:attach 本身会触发 JVM 暂停,在高流量时段可能造成性能抖动。 我建议在流量谷底或者问题影响可接受时使用,不要长时间挂着 thread -n 这类命令。
4.3 别忘了线程池队列这个“共犯”
线程转储只显示线程当前正在执行的任务,不能直接显示线程池还有多少个排队任务。单看 dump,你只会看到一条线程卡在某个方法里,但你可能看不到队列里有几千个任务在等待执行。
这次排查中,我除了看线程栈,还看了 ThreadPoolExecutor 的 getQueue().size(),并且检查了提交方的代码。确认是某个上游接口回调后,程序把同一批消息循环提交给了 pay-callback-pool,提交速度超过处理速度,而处理速度被一次正则回溯拖慢了。线程池队列越堆越长,导致业务延迟持续上升。
如果只看线程栈,你会陷入“为什么只有一条线程卡住”的困惑;只有结合执行器队列长度和提交日志,才能还原出**“多对一”的因果关系**。
4.4 修复与验证
根因定位到正则回溯后,修复路径比较直接:把原来的正则表达式换成更严格的分段校验,或者使用 String 的普通查找方法替代复杂正则;如果非要用正则,就设置超时控制或者用 DFA 类型匹配器。修复后我在测试环境复现压力场景,观察 top -H 里的线程 CPU 曲线,确认该线程不再长时间停留在 Matcher.find。
验证时不要只看 CPU 是否降下来,还要盯一段时间稳定性,避免修复了一个正则之后,把压力转移到其他热点方法上。
5. 复盘误判:线程诊断的边界与盲区
这次排查用时并不长,真正让我想写下来的,是过往几次在类似场景中“抓错人”的经历。线程诊断这个东西,工具再多,最后拼的是对边界和盲区的理解。
5.1 高 CPU 可能落在“编译器线程”身上,但它往往是共犯不是主犯
有一次我在另一个服务上看到 C2 CompilerThread0 的 CPU 很高,差点把它当成热点。后来才发现,C2 编译器线程高 CPU 通常是因为有个热点方法被频繁调用,触发了 JIT 的深度编译优化。它是在“努力优化”,不是故障源。
判断方法是看编译日志或 jstat -compiler,如果你能看到某个方法反复编译,真正的源头是那个方法,而不是编译器线程本身。
5.2 GC 线程高 CPU,问题往往在分配或锁
GC 线程的 CPU 占用高,看起来像 “GC 把 CPU 吃光了”,但这个说法不够准确。GC 线程只是负责回收,它变累的原因通常是业务线程分配对象太频繁,或者存活对象太多。只盯着 GC 线程优化没有意义,要看内存分配速率、对象存活率和锁竞争情况。
线程转储里 GC 线程可能只是 VM Thread 或者 GC Thread,单看 dump 看不出是谁在促使 GC 工作。配合 jstat -gcutil 或者内存分配日志,才能找到推动 GC 的业务来源。
5.3 单次采样最容易造成“错杀”
这是我感受最深的一点。线程池线程是复用的,一次采样只能定格它在那一瞬间的栈。如果你只抓一次,很可能抓到的是一个恰好正在处理普通任务的无辜线程;真正频繁占用 CPU 的线程,可能在另一个采样点才是它的高光时刻。
所以我在本文一再强调多次采样。遇到线程数少、状态正常的场景,不要相信单张快照,要以“是否反复出现在同一栈帧 + CPU 时间是否持续偏高”作为入罪标准。 这是我从各种误判里总结出的最有效排查习惯。
最后再分享一个小技巧:排查线程问题时,我会开一个本地文档,把每一次采样时间、线程名、nid、栈顶方法、CPU 占用逐项列出来。排查完再回看这份文档,你会发现自己花时间最多的地方往往不是工具,而是如何把一次次采样拼成一个顺理成章的完整过程。这份记录不只是排查笔记,更是下一次遇到类似问题时最可靠的参照。
