
前阵子我还是被同一个问题反复折腾服务 CPU 不高、内存也正常但请求 RT 突然冲到几秒线程池队列肉眼可见地堆积。监控面板上所有指标都像是“正常”你根本不知道是哪个环节卡住了。后来我抓起线程快照Thread Dump看了一眼十分钟就定位到一把业务锁上的阻塞。那一次之后我再也不靠直觉猜了线程快照就是这种场景下最直接的“现场照片”。这篇内容适合谁呢我觉得是那些已经在用监控系统、但遇到疑难性能问题还是无从下手的后端开发、运维和 SRE。线程快照获取和分析说难不难说简单也不简单关键是你得知道什么时候抓、抓几张、看哪里。下面我把自己的完整思路和踩过的坑一起整理出来。1. 先说清楚线程快照解决什么问题它和监控指标的关系是什么1.1 什么时候该去抓线程快照很多人是在遇到下面这类现象时才开始想到快照CPU 占用率高但你不知道是哪个线程在忙忙什么接口变慢、超时增多但看不出是哪个环节拖了后腿线程池队列一直堆积任务提交不进去服务出现死锁表现为某些接口完全无响应但进程还活着内存没爆、GC 也没有频繁 Full GC但整机响应就是上不去。这些场景有一个共同点你看到的都是“结果”——CPU 高、RT 慢、队列长但“原因”藏在某个线程的当前执行位置里。监控指标是体温计告诉你有问题线程快照是 X 光片告诉你具体是哪根骨头断了。不过这里要有个清醒的认识快照不是一个持续的量也不是计数器。它是 JVM 在某一个瞬间把每个线程正在执行的栈、线程状态、持有的锁信息全部导出来。它告诉你“这个时刻发生了什么”而不是“过去一小时平均发生了什么”。也正因为这样它解决不了所有问题——比如 CPU 尖峰瞬时过去你才抓那就什么都看不到了。所以什么时候抓怎么抓后面会详细说。1.2 快照是“采样”不是录像我见过很多同事抓了一张快照就开始下结论某个线程 WAITING 了一定是线程池不够某个线程 BLOCKED 了一定有死锁。单张快照具有很大的偶然性因为线程是动态运行的。举个例子一个线程在某个瞬间 RUNNABLE栈顶是socketRead你看快照可能觉得它在“正常等待网络请求”但你不知道它前面十分钟里是不是一直在空转。反过来一个线程在快照里 WAITING也可能只是它在正常地等待任务队列下一秒就拿到任务继续跑了。所以我的习惯是抓快照至少抓三张每张间隔 5 到 10 秒。分析时对比这三张如果一个线程在三张快照里都停在同一个位置那才是真的“卡住了”。如果每次栈都不同说明系统还在运转只是暂时没赶上节奏。这个“多张对比”的思路比任何工具都重要。2. 三种获取方式与适用场景2.1 jstack 和 jcmd 的选择最常见的获取命令是jstack但这里我得先提一句在较新的 JDK 版本里官方其实更推荐用jcmd。比如你要抓某个进程的快照# 传统方式 jstack pid thread_dump_1.txt # 推荐方式JDK 8 之后的 jcmd jcmd pid Thread.print thread_dump_1.txt这两个命令的输出格式基本一致。为什么不直接用 jstack因为在新版本 JDK 里jstack有时候会提示你“This output will be deprecated”而且实现的底层逻辑就是Thread.print。另外jcmd还能做很多其他事情比如VM.native_memory、GC.heap_info排查问题时一个命令就能多拿好几类信息。需要注意执行这些命令的用户需要和启动 Java 进程的用户一致或者有足够的权限。否则你大概率会看到sun.jvm.hotspot.runtime.VMVersionMismatchException或者直接连不上进程。容器场景里更要小心别在宿主机上直接 jstack 容器里的 Java PID要进入容器或者用docker exec到容器内部执行。2.2 kill -3 与容器场景的坑还有一个不太被重视但很好用的方式向 JVM 进程发送 SIGQUIT 信号也就是kill -3 pid。它会让 JVM 把线程快照打到标准输出上。# 找到 Java 进程 PID ps -ef | grep java | grep -v grep # 发送 SIGQUIT kill -3 pid如果你的 Java 进程是前台启动、输出重定向到某个文件那么快照就会追加在那个文件里。这在我们不方便进入机器、或者容器内没有 jstack 工具时很有用。不过这里有个大坑在 Kubernetes 容器里Java 进程往往就是 1 号进程。kill -3 1在部分容器环境里可能不会正常触发线程 dump因为容器对 1 号进程的信号处理有特殊行为。我遇到过几次发完信号后日志里什么都没出现。所以容器场景下我一般首选用jcmd其次才考虑kill -3。另外别手滑打成kill -9那是直接让进程消失不是打快照。2.3 先定位忙线程再抓快照top -Hp 与 nid 换算如果你遇到的是 CPU 高的问题最有效的做法不是直接抓快照而是先找出到底哪个线程在烧 CPU再对照快照看它在干什么。# 查看进程内每个线程的 CPU 占用 top -Hp pidtop -Hp会列出进程内的线程每行有个 TID线程 ID十六进制形式就是后面快照里的nid。这个换算关系很多新手容易搞反快照里的nid0x1a3b你用计算器把它转成十进制然后去top -Hp输出里找那个十进制 TID这样才能对上。比如我常用的一行命令是# 把快照里 nid 的十六进制转成十进制 python3 -c print(int(1a3b, 16))然后把数字放到 top 的输出里一对比马上就能锁定烧 CPU 的那根线程。这个操作虽然简单但它把“系统层面看到的 CPU 消耗”和“Java 层面的代码位置”串联起来了。没有这个串联你光看快照很容易跑偏——比如看到一个 RUNNABLE 线程就以为它在干活实际上它可能是在自旋空转。3. 快照文件里到底写了什么3.1 一段真实快照的逐行解读先来看一段典型的 jstack 输出片段熟悉一下格式http-nio-8080-exec-12 #74 daemon prio5 os_prio0 cpu123.45ms elapsed360.12s tid0x00007f123400a000 nid0x1a3b waiting on condition [0x00007f12bffa9000] java.lang.Thread.State: TIMED_WAITING (parking or sleeping) at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:2078) at java.util.concurrent.LinkedBlockingQueue.poll(LinkedBlockingQueue.java:467) at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1073) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) at java.lang.Thread.run(Thread.java:748) Locked ownable synchronizers: - 0x000000076c2b79d8 (a java.util.concurrent.ThreadPoolExecutor$Worker)第一行是线程概览信息密度很高http-nio-8080-exec-12是线程名。如果你们没有自定义线程池的线程名这里就只会是pool-6-thread-1这种定位起来会痛苦很多。所以我一再建议创建线程池务必用自定义线程工厂把业务含义放进去比如order-query-worker这样快照一出你立刻知道是哪个业务模块的线程。nid0x1a3b是线程在操作系统里的原生线程 ID前面说了它用来和top -Hp对上号。java.lang.Thread.State: TIMED_WAITING (parking or sleeping)是线程状态。注意这里的 State 是 Java 层面的线程状态和操作系统状态不完全等同。接下来那些以at开头的行就是从栈顶到栈底的调用链。这其实就是线程“正在执行的代码位置”。分析问题的核心就是看这条调用链停在哪里。最后Locked ownable synchronizers表示这个线程当前持有哪些可重入锁。这在分析锁竞争时非常关键。3.2 线程状态是关键词但别只看状态Java 线程状态有六种NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。很多文章会把状态和问题简单对应比如“看到 BLOCKED 就是死锁”“看到 WAITING 就是线程不够”。实际分析时光看状态是不够的甚至会被误导。比如RUNNABLE并不代表它在“高效干活”。如果它在执行一个很重的正则回溯或者一个毫无退避的自旋循环状态照样是 RUNNABLE但 CPU 被它烧光了。又比如TIMED_WAITING可能是正常地Thread.sleep()也可能是在队列上等待超时具体是哪种必须看栈顶的方法。我的经验是不要被状态名称牵着走要把“状态 栈顶方法 完整调用栈”三样东西放一起看。状态只是给你一个初步方向栈才是问题的真正坐标。3.3 锁信息三兄弟locked、waiting to lock、parking to wait快照里和锁相关的关键字就那么几类我一一说明locked 0x...表示这个线程持有一把锁。注意这是“持有”不是“请求”。waiting to lock 0x...表示这个线程在等一把被别人持有的锁通常对应synchronized的阻塞。parking to wait for 0x...表示这个线程通过LockSupport.park在等待通常对应ReentrantLock或AbstractQueuedSynchronizer里的条件等待。举个常见场景某个线程状态是BLOCKED栈上有waiting to lock 0x000000076c2b79d8你就需要去其他快照里找谁持有了这个地址的锁。如果打了三张快照持锁线程一直都在而且锁没释放那基本可以断定是长占用。如果持锁线程只是间歇性出现那可能是锁竞争激烈不一定是不释放。这里有个容易犯的错看到waiting to lock就以为是死锁。死锁必须满足“两把锁以上互相等待”的环。单线程等待一把锁很可能只是资源竞争只要持锁线程能正常释放问题可能根本不在锁上而在持锁线程里做的事太慢。所以分析关键字一定要配合完整调用栈切忌断章取义。4. 快速定位根因的三种分析手法4.1 多快照对比抓三张找“纹丝不动”的线程前面反复强调了多张快照的重要性这里具体讲怎么对比。假设你在 10 秒内抓了三张快照分别存为dump_1.txt、dump_2.txt、dump_3.txt。打开三个文件同一个线程名在三个文件里的栈都一致、状态也一致那它就是“卡死”的嫌疑线程。如果它停在一个锁等待上那就继续找持锁线程如果它停在某个数据库调用上那就去查慢 SQL。如果三张快照里线程的顺序和栈都在剧烈变化那问题往往是另一种——比如 CPU 被大量线程争抢、频繁 GC、或者磁盘 IO 等待。这种情况下不要纠结单根线程要回到系统层面看上下文切换、GC 日志。我个人的习惯是把三张快照按线程名归类后直接做 diff或者干脆先把所有 BLOCKED、WAITING 的线程行grep出来再去看对应栈。不要一张一张从头读到尾那样效率太低。4.2 死锁与锁等待链路的追踪JVM 在输出线程快照时如果检测到死锁会在快照末尾显式写一段警告类似Found one Java-level deadlock: thread-A: waiting to lock monitor 0x00007f1234001111 (object 0x000000076c2b79d8, a java.lang.Object), which is held by thread-B thread-B: waiting to lock monitor 0x00007f1234002222 (object 0x000000076c2b79e0, a java.lang.Object), which is held by thread-A Java stack information for the threads listed above: ...如果快照末尾有这一段那问题就很明确了。我遇到过的真实死锁往往不是 jstack 检测不到而是“还没到死锁”的锁竞争——线程 A 等锁持锁线程 B 虽然持锁但还在正常做事只是特别慢。这时候 JVM 不会报 deadlock但业务已经堵死了。所以分析锁等待时我的步骤是找出所有BLOCKED的线程及它们等的锁对象地址找出所有locked同一地址的线程看持锁线程的完整栈判断它是在做正常业务还是卡在更底层的 IO/GC/锁等待里如果持锁线程也卡在等待另一把锁就继续往上追画一条锁等待链路出来。这条链路追到头才是真正的根因。4.3 RUNNABLE 高 CPU 类问题怎么配合分析前面提过top -Hp定位高 CPU 线程再对应到快照的nid。这里再补一个层级拿到线程栈之后如果栈顶或者栈里出现频繁的内置方法、正则、序列化、GC 相关调用你要小心了。我遇到过一个例子某个统计接口在高峰期 CPU 报警快照里大量线程的栈都停在java.util.regex.Pattern$Curly.match。就是因为某个业务用了一个极其复杂的正则去匹配大字符串导致 CPU 被吃满。这类问题从监控指标上看只是 CPU 高但一照快照位置一目了然。还有一种很隐蔽的情况高 CPU 不是业务线程而是 GC 线程。快照里能看到VM Thread或 GC 工作线程特别忙这时你应该去拿 GC 日志和堆信息而不是继续盯着业务线程。线程快照只能告诉你“谁在跑”至于它为什么这么累往往还需要jstat、jmap和 GC 日志配合。5. 一次线程池耗尽排查的完整过程5.1 现象与初步判断当时是某公司的订单处理微服务现象很典型高峰期接口超时率上升但 CPU 和内存都处于中低位不像资源不足。监控面板上能看到线程池相关指标涨上去了但看不到内部细节。我登录到机器上先用jcmd pid Thread.print连抓了三张快照间隔 5 秒。第一眼扫了一下大量业务线程集中在同一个状态和同一段栈上WAITING (parking)停在LinkedBlockingQueue.take。这是线程池里空闲 worker 在等任务的表现本来很正常但数量占比明显异常——大部分线程都闲着说明任务没有被及时派发或者线程池的队列被卡住了。真正可疑的是另一批线程它们状态是BLOCKED停在waiting to lock 0x000000076c2b79d8。这个锁对象地址很陌生不是线程池相关的对象。5.2 快照分析找到持锁线程我在这三张快照里搜索0x000000076c2b79d8发现持锁线程是一个名字叫order-db-batch-worker的线程它一直locked 0x000000076c2b79d8并且完整调用栈显示它停在一个 MyBatis 的批量查询方法上。这时候嫌疑就清晰了订单处理线程都在等待一把锁这把锁被一个批量查询工作线程持有而这根线程在查一个没有走索引的大表。查了一下数据库的慢日志果然有一个十几秒的慢查询。它一慢锁不释放后面所有需要抢锁的订单线程全部 BLOCKED线程池接着被占满任务就堆积到了一起。这其实是一个典型的主键锁被长期持有所引发的连锁反应。单看监控指标你只会看到“线程池活跃线程增多”但看不到锁的持有关系。快照的价值就在这里——它把线程、锁、调用栈摊在一起给你看。5.3 根因与优化措施最后的处理分了几步先救急给批量查询加上正确的联合索引慢查询直接从十几秒降到百毫秒级别拆锁不要让批量查询在持锁期间做耗时操作把数据分批查询减少单把锁的持有时间异步化订单处理链路里有一些不依赖结果的步骤改成异步提交别占着线程池里的核心线程调整线程池参数最大线程数和队列大小重新评估避免队列无限堆积导致 RT 持续恶化。复盘时大家其实都以为问题是线程池配置不合理因为监控面板上最刺眼的就是线程池活跃线程数打满。但真正原因在慢 SQL线程池只是“背锅”的地方。这个项目让我彻底养成一个习惯遇到线程池打满先抓快照看是否有一批线程在等同一把锁如果有优先查持锁线程在干什么而不是急着调大线程池。6. 让快照采集和分析变成日常工作6.1 定时采集脚本的简单实现性能问题的现场往往稍纵即逝等告警触发时再手动抓取很可能来不及。我习惯用一个小脚本做定时采集把快照留到本地事后慢慢分析。#!/bin/bash PID$1 DIR/opt/thread_dumps mkdir -p $DIR for i in 1 2 3 do jcmd $PID Thread.print ${DIR}/dump_$(date %Y%m%d_%H%M%S)_$i.txt 21 sleep 5 done再来一个循环清理旧文件避免快照把磁盘塞满。这个脚本不加告警只作为“事故现场还原”的保存手段。如果配合告警系统建议在触发慢接口告警或线程池活跃度告警时执行一次采集保留现场再通知人去分析。还有一点提醒快照文件里包含业务线程名和调用栈有些场景算敏感信息。定期归档时要做好权限管理别把带nid和栈信息的文件放到公共目录里。6.2 命令行快速过滤技巧拿到快照后我先不急着从头看而是一组命令快速判断这波快照“值不值得深入分析”。# 统计各线程状态数量 grep java.lang.Thread.State dump_*.txt | sort | uniq -c # 只看 BLOCKED 线程 grep -B1 java.lang.Thread.State: BLOCKED dump_*.txt | grep # 找出所有锁等待地址 grep waiting to lock dump_*.txt | sort | uniq -c有人会写 AWK 脚本一次性格式化但我更习惯分步来先看状态分布再找异常地址最后去快照里逐条确认。因为快照分析本质上是个“反复回查”的过程脚本可以帮你缩小范围但不能替代人眼对调用栈的比对。6.3 我踩过的几个坑与最终建议第一个坑是忘了多张对比。早期我拿到一张快照就下结论结果误判了好几次。线程快照是典型的采样数据单样本没有说服力。三张起步间隔 5 到 10 秒这个习惯强烈建议大家养成。第二个坑是只分析 Java 层忘了看 GC。有一次我把大量线程 RUNNABLE 归咎于业务代码折腾了很久后来才发现是频繁 Full GC 导致的全局停顿。线程状态是 RUNNABLE但它们其实都在等 GC 结束。所以不管快照里看到什么只要停顿类问题出现我都会先瞄一眼 GC 日志。第三个坑是线程名没有规范化。服务里有大量的pool-8-thread-1分析时你根本不知道它是哪个业务的线程池。后来我通过自定义线程工厂把所有线程池命名改成模块-用途-worker快照分析效率直接提高一个档次。这个是性价比最高的优化没有之一。最后一点建议快照分析不用追求一次抓到完美现场。我经历过很多次“虽然没抓到最准的那几秒但快照依然帮了大忙”。原因是线程卡住的问题通常不是一瞬间的而是持续几分钟甚至更久。只要你在问题还没恢复前抓了快照大概率能看到线索。特别是配合定期采集脚本把多个时间点的快照留底后来回溯起来非常有价值。