你有没有遇到过这种情况:线上服务突然卡住,接口超时报警刷屏,日志翻了一圈却什么异常都没打出来。CPU看着正常,内存也没报OOM,可请求就是处理不完,线程池里的任务全部堆积,新请求进不来,老请求出不去。面对这种“查日志查不出、看监控看不出”的局面,JVM 自带的命令行诊断工具就派上用场了,而其中最常用、也最容易上手的一个,就是 jstack。
jstack 是 JDK 自带的一个命令行工具,核心功能是生成 Java 进程当前时刻的线程快照(thread dump)。所谓线程快照,就是把 JVM 内部所有线程这一刻的栈信息全部打印出来,包括线程名、线程状态、锁信息、调用栈,以及当前正在执行的代码位置。拿到这份快照,就等于拿到了一张事故现场的全景图,很多平时藏在代码里的问题——死锁、线程池耗尽、CPU 飙高、锁竞争——都会在栈帧里现出原形。
这篇文章不是简单的 jstack 参数手册,而是想把我实际排查问题时的完整思路、看输出时的判断方法、以及踩过的那些坑都讲清楚。适合刚接触 JVM 诊断工具的同学,也适合那些用过 jstack 但总感觉看不懂 dump 文件的人。读完你至少能自己做一次完整的线程问题定位,而不是抓着一份几百行的 dump 发呆。
1. 为什么线上出问题的时候,最先想起的是 jstack
1.1 jstack 到底是什么:JDK 自带的线程快照工具
jstack 的全称是 Java Stack Trace,从名字就能看出它的定位:查看 Java 线程的栈信息。它不需要额外安装,只要你机器上有 JDK,在 bin 目录下就能找到这个可执行文件。它的工作方式很简单——通过 attach 机制挂到目标 Java 进程上,请求 JVM 导出所有线程的当前栈状态,然后输出到控制台或文件。
从 JVM 诊断工具链的角度看,jstack 和 jstat、jmap、jcmd 这几个工具各有分工:jstat 看 JVM 内部统计数据(GC、类加载、编译情况),jmap 看堆内存对象分布和生成堆转储,jcmd 是个综合命令入口,而 jstack 专门负责线程栈。线程栈回答的问题是“我的线程们此刻在干什么”,这正好补上了其他工具够不着的一块:运行时行为。
我自己的使用感受是,jstack 是这几个工具里上手门槛最低的。jstat 的一堆 JVM 参数和 GC 数据要理解半天,jmap 的堆转储分析流程又长又重,唯独 jstack,一条命令跑完,输出的就是人能读的文本。只要你能看懂 Java 中线程的运行逻辑,就能从这份文本里提取有价值的信息。
1.2 什么场景下真正需要 jstack:症状与工具匹配
很多人在线上出问题时没有第一时间想到 jstack,原因是不知道什么症状该用它。我根据实际经验整理了一张对应表,遇到下面这类情况,优先考虑拿线程快照看看:
| 症状表现 | 可能原因 | 首选诊断手段 |
|---|---|---|
| 接口长时间不返回,请求堆积 | 业务线程阻塞在某个锁或 IO 等待上 | jstack 看线程状态 |
| 服务整体“卡死”,日志却无异常 | 死锁或线程池全部耗尽 | jstack 找 deadlock 和等待中的线程 |
| CPU 飙升到 100% 但 GC 正常 | 某个线程在死循环或自旋 | top 定位线程 + jstack 看对应栈帧 |
| 线程数持续增长,内存缓慢上涨 | 线程泄漏或有大量线程创建未回收 | 连续多次 jstack 对比线程名变化 |
| 应用假死,无法响应 dump 请求 | 进程已严重异常 | jstack -F 强制抓取(配合其他手段) |
这份表的逻辑是:凡是问题表现为“线程不正常”,并且你怀疑是阻塞、死锁、自旋、线程泄漏这类运行时行为问题,jstack 基本都能派上用场。反过来,如果问题是内存溢出、GC 频繁、对象泄漏,那优先用 jmap 或 jstat,别拿 jstack 硬磕。
1.3 使用 jstack 前的第一件事:先拿到目标 PID
无论你用哪个 JVM 工具,第一步都是找到目标 Java 进程的 PID。这一步经常被新手忽略,直接输入 jstack 却报错“process not found”,其实就是 PID 没找对。
推荐用 jps 而不是 ps。jps 是 JDK 自带的,专门列出 JVM 进程,输出比 ps 干净得多,而且能精确识别 Java 进程。
jps -l输出示例:
12345 com.example.order.OrderApplication 23456 sun.tools.jstatd.Jstatd第一列是 PID,第二列是主类或 jar 包路径。如果你想看完整的启动参数,可以加-v;想看进程启动时传入的 main 参数,用-m。找到 PID 之后,直接执行:
jstack 12345如果执行时报错说权限不足或无法 attach,检查两件事:一是当前系统用户是否和启动 Java 进程的用户一致,二是 JDK 版本是否和运行时版本匹配。跨用户 attach 基本都会失败,这是最常遇到的坑。
2. 看懂线程快照:jstack 输出里的每一行都在说什么
拿到 jstack 输出后,很多人的第一反应是“这么多行,从哪看起”。别慌,本质上线程快照就是一长串线程信息的集合,每个线程的信息块格式是固定的,看懂一个就能看懂全部。
2.1 线程状态的真正含义:RUNNABLE 不代表一定在跑
JVM 里线程状态分为六种:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。在 jstack 输出中,每种状态都对应一行java.lang.Thread.State: XXX。
这里最容易产生误解的是 RUNNABLE。很多初学者以为 RUNNABLE 就是线程正在执行 CPU 指令,其实不是。RUNNABLE 只表示线程“可以运行”,它可能真的在 CPU 上执行,也可能正在等待 IO 完成、等待系统调用返回、等待获取内部锁。比如下面这个栈:
java.lang.Thread.State: RUNNABLE at java.net.SocketInputStream.socketRead0(Native Method) at java.net.SocketInputStream.socketRead(SocketInputStream.java:116) at java.net.SocketInputStream.read(SocketInputStream.java:171)这绝对不是“线程在干活”,它卡在 socket 读数据上,也就是等待网络数据返回。所以看 RUNNABLE 状态时,一定要结合栈帧顶部的代码位置判断它到底在“真运行”还是“假运行”。
我把不同线程状态的处理建议整理成一个表格,方便排查时对照:
| 线程状态 | 含义 | 排查时怎么理解 |
|---|---|---|
| RUNNABLE | 正在运行或等待系统资源 | 看栈顶:CPU 计算 / 锁自旋 / IO 读都在这 |
| BLOCKED | 等待进入 synchronized 代码块或方法 | 有竞争 monitor,锁被其他线程持有 |
| WAITING | 无限期等待,如 wait()、join()、park() | 常见于线程池空闲或条件等待 |
| TIMED_WAITING | 有超时时间的等待 | sleep、wait(带超时)、join(带超时) 均在此列 |
2.2 线程信息块的字段拆解:从线程名到 nid
每个线程的信息块开头是这样一行:
"http-nio-8080-exec-10" #25 daemon prio=5 os_prio=0 cpu=12.34ms elapsed=123.45s tid=0x00007f9c3c04a800 nid=0x2a1b runnable [0x00007f9c0b3fe000]逐个拆解:
"http-nio-8080-exec-10"是线程名,通常由线程工厂或框架指定,比如 Tomcat 的工作线程、Netty 的 IO 线程等。#25是 JVM 内部线程编号。daemon表示守护线程,非守护线程旁边不会出现这个词。prio=5是 Java 优先级,os_prio=0是操作系统优先级。cpu从高版本 JDK 开始会显示线程累计消耗 CPU 时间,短时间内多次采样对比这个值,能快速判断哪个线程在“烧 CPU”。tid是 JVM 内部线程 ID,nid是操作系统线程 ID,排查 CPU 问题时需要和 top 输出对上号的就是 nid。runnable是线程调度状态快照,后面的[0x00007f9c0b3fe000]是线程栈基地址。
实战中最有用的两个字段是线程名和 nid。线程名告诉你是谁创建的线程,nid 让你能结合操作系统工具定位到具体线程。
2.3 锁相关的三行标记:locked / waiting to lock / waiting on
线程快照里和锁相关的输出是排查死锁和竞争的关键。常见的有这几种:
- locked <0x000000076b5f4a50> (a java.lang.Object) - waiting to lock <0x000000076b5f4a50> (a java.lang.Object) - waiting on <0x000000076b5f4a50> (a java.lang.Object)locked表示线程当前持有了这个对象上的 monitor。synchronized 是锁住对象,在 dump 中就会这样显示。waiting to lock表示线程正在尝试进入 synchronized 块,但锁被别人持有,所以进入 BLOCKED 状态。waiting on表示线程已经持有 monitor,并执行了 wait(),正在等待其他线程调用 notify(),通常对应 WAITING 状态。
ReentrantLock 在 dump 中的表现和 synchronized 不同。ReentrantLock 用的是park机制,所以等待锁的线程状态通常是 WAITING,栈帧中会出现LockSupport.park,配合其他标记表示等待某个锁。jstack -l 参数会在线程信息块后面额外输出它持有和等待的锁,排查 ReentrantLock 相关问题最好加上 -l。
2.4 栈帧部分怎么读:从栈顶开始找答案
线程状态行下面的从at开始的每一行就是一个栈帧,排列顺序是从栈顶到栈底。栈顶是线程当前正在执行的代码,栈底是线程的入口方法。排查时我一般从最上面的at行开始看,因为那才代表线程当下真正卡住的位置。
同样是“什么都不干活”的线程,卡在socketRead0和卡在LockSupport.park的排查方向完全不同。前者要去看网络、看下游依赖,后者要去看锁的持有者是谁。栈帧会一层层地把调用链路铺开,只要你熟悉项目代码,看到一个包名和方法名,基本能判断出这是业务代码的哪个逻辑分支。
3. 三大高频战场:死锁、CPU 飙高与线程池被打满
jstack 知识点聊完,真正有价值的还是实战。我挑三个线上最常遇到、也是 jstack 最能发挥价值的场景,完整走一遍排查链路。
3.1 死锁定位:jstack 自带死锁检测
死锁是并发编程里最经典的问题,特点是两个或多个线程互相持有锁,又互相等待对方释放锁。死锁发生时,相关线程都会阻塞,但日志往往非常干净。
之前处理过一个内部交易系统的问题。A 线程持有账户 A 的锁,等待账户 B 的锁;B 线程持有账户 B 的锁,等待账户 A 的锁。转账接口大批量超时,业务日志里只有“超时”字样。执行 jstack 后,很快在输出末尾看到:
Found one Java-level deadlock: ============================= "thread-transfer-B": waiting to lock monitor 0x00007f9c3c039800 (object 0x000000076b5f4a60, a com.example.Account), which is held by "thread-transfer-A"jstack 在 dump 阶段会主动检测死锁,并在文件末尾打印死锁列表,包含线程名、锁对应对象和持有者。看到这个,问题基本就定性了,不用再东猜西猜。
修复死锁的思路我建议按顺序尝试:最简单的是让所有线程按固定顺序获取多个锁,从根上消除循环等待;如果锁顺序难以统一,改用ReentrantLock的tryLock并加超时,超时后主动回滚或重试;或者把多个锁合并为一个粒度更粗的锁,牺牲一点并发度换稳定性。
排查死锁时有个小技巧:单独执行一次 jstack 能看到死锁快照,但如果死锁导致进程已经无响应,建议用jstack -F强制抓取,否则命令可能挂住。
3.2 CPU 飙高:从 top 到 nid 再到栈帧的一套固定打法
CPU 飙高是线上最紧急的故障之一。遇到这种情况,我第一反应不是马上 jstack,而是先拿操作系统的工具定位到底是哪个线程在消耗 CPU。完整流程是这样的:
第一步,用 top 查看进程级 CPU 占用,找到 Java 进程的 PID:
top -c第二步,查看这个进程内哪些线程 CPU 占用最高:
top -Hp 12345第三步,把占用最高的线程的十进制 ID 转成十六进制:
printf "%x\n" 6789输出可能是1a85,这个十六进制值就是 jstack 输出里的 nid。
第四步,执行 jstack 抓取线程快照,然后在输出中搜索 nid:
jstack 12345 > /tmp/thread_dump.log grep -A 20 "nid=0x1a85" /tmp/thread_dump.log这样就能看到到底是哪段业务代码在烧 CPU。
举个例子。有个订单扣减服务 CPU 频繁飙到接近 100%,看着像是在做大量计算。按上面的流程抓了线程栈,发现栈帧一直停留在某个 JSON 工具类的一个方法上,而且连续多次采样都是同一位置。顺着调用链往下一层,发现是某个配置表在每次请求时重复解析、重复构建对象,加上循环嵌套,一个请求可能触发几千次重复计算。问题定位到之后,把解析结果挪到启动时缓存,CPU 立刻降下来了。
这套打法的核心思想是:jstack 负责给“线程在干什么”提供证据,top 负责缩小范围,两者配合才能避免拿着 JVM 工具在几百个线程里大海捞针。CPU 高但 top 看到的却是 GC 线程,那就要换方向看 GC 日志和堆内存了。
3.3 线程池被打满:从 WAITING 堆叠与线程名找线索
线程池耗尽的问题,表现通常是接口全部超时,线程数达到最大值,队列堆积。jstack 在这类问题里的作用是告诉你这些线程到底“卡”在哪一步。
先看线程状态分布。如果大量业务线程处于 WAITING 或 TIMED_WAITING,并且栈帧停在队列 take/poll 上,那说明线程池本身没任务可干,是空闲等待,这类状态反而是正常的。真正要警惕的是两种异常形态。
第一种是大量 RUNNABLE 线程长时间停留在同一个业务代码方法上。这说明并发请求扎堆在同一条路径上,可能是热点资源竞争、慢 SQL 同步调用、或者是下游接口超时导致线程集体等待。第二种是大量 BLOCKED 线程,这几乎必然对应出现了一个被多个线程竞争的锁,比如某个全局锁、某个单例对象的同步块、某个静态集合。
线程名能帮我们快速区分线程池来源。比如 Tomcat 的http-nio-8080-exec-开头的线程,Netty 的nioEventLoopGroup线程,自定义的business-thread-线程。看到线程名后,再沿着栈帧回到业务入口,就能判断是调用上游超时、数据库等待还是锁竞争。
排查线程池问题时,我强烈建议多采样几次,每次间隔 3 到 5 秒,把多份 dump 放一起看。单份 dump 可能刚好抓到线程池空闲的瞬间,导致误判为“没有任务在跑”,连续多份才能看出线程状态的动态变化。
3.4 连续采样:瞬时异常往往需要两份以上的 dump 对照
有人问我,一份 dump 不够吗?说实话,对于死锁这类静态问题,一份就够;但对于“线程越来越慢”“CPU 间歇性飙高”“请求随机超时”这类动态问题,一份 dump 往往只能拍到某个瞬间,抓不到问题全貌。
连续采样的方法是:隔 3 到 5 秒分别抓一份 dump,连续抓 5 到 10 份。
for i in {1..5}; do jstack -l 12345 >> /tmp/dump_$i.txt sleep 5 done然后分析每份 dump 里线程状态分布的差异:RUNNABLE 比例是不是持续上升?WAITING 线程的栈帧位置是否一致?某个线程是否每次都出现在同一行代码上?如果某个栈帧连续出现在多份 dump 中,基本可以断定它就是一个热点,值得深挖。
另外,结合第一次和最后一次 dump 之间的线程数量差异,还能判断线程泄漏。比如第一次 200 个业务线程,最后一次 300 个,而且新增线程名都是同一种前缀、栈都停在同一个位置,说明每次请求都在创建新的线程并且没有释放。
4. 参数与组合拳:-F/-l/-m 以及和 jstat、jmap 的搭配
jstack 虽然基础用法简单,但几个参数选得对不对,直接决定抓出来的内容有没有价值。
4.1 jstack 的四个关键参数怎么选
常用参数就这几个:
| 参数 | 作用 | 使用建议 |
|---|---|---|
| 无参数 | 普通线程快照,不含锁的详细信息 | 日常优先使用 |
| -l | 长列表,打印锁的额外信息(拥有的锁和等待的锁) | 排查死锁、锁竞争时必须加 |
| -F | 强制 dump,进程无响应时使用 | 谨慎使用,可能影响生产进程 |
| -m | 混合模式,同时打印 Java 栈和 native 栈 | 排查系统调用、JNI 问题时使用 |
-l是我在大多数生产场景下默认加上的参数。不加 -l 的话,ReentrantLock 等信息显示不完整,排查锁问题时少了一半线索。不过 -l 会让 dump 文件变大,处理大数据量时要留意。
-F是强制模式,它的原理是使用 JVM 的底层调试接口直接读取线程状态,即使目标进程陷入类似死循环、完全无法响应正常 dump 请求的状态,也能抓到信息。但强制模式是对运行中 JVM 的一种侵入,极端情况下可能导致被诊断进程不稳定,所以能不用就不用,用了之后要格外关注目标进程状态。
-m通常用的比较少。它会把 native 层的栈帧也导出来,比如线程卡在原生 socket 读取或 JNI 调用中时,能看到更底层的调用。一般排查 Java 层问题用不到,但是做底层系统排查时能派上用场。
4.2 输出重定向与分析:dump 文件别直接肉眼扫
jstack 输出的内容可能很长,尤其线程数多的大应用,一份 dump 可能上千行。建议每次都重定向到文件,再用工具辅助分析。
jstack -l 12345 > /tmp/thread_dump.log文件生成后,先做三件事:第一,看文件头部的时间戳,确认采样时间点;第二,用 grep 统计各线程状态的数量;第三,搜索deadlock关键字,确认是否存在死锁。
grep -c "java.lang.Thread.State: RUNNABLE" /tmp/thread_dump.log grep -c "java.lang.Thread.State: BLOCKED" /tmp/thread_dump.log grep -c "java.lang.Thread.State: WAITING" /tmp/thread_dump.log统计出状态分布之后,如果 RUNNABLE 为主,重点看它们卡在哪个栈帧;如果 BLOCKED 数量多,去查锁信息;如果 WAITING 占比极高,去看是线程池空闲还是全部阻塞在某个调用上。
4.3 和 jstat、jmap、jcmd 的组合判断
jstack 只负责线程状态,但线上问题往往是多维度叠加的,只靠一份线程快照就给结论不够稳妥。我习惯把它和另外几个工具组合使用。
如果 jstack 显示线程大多 RUNNABLE,但 top 却看到 CPU 占用率低,说明线程可能卡在 IO 等待上,这个时候配合 jstat 看 GC 数据意义不大,更应该看网络和下游系统。
如果 top 显示 CPU 大部分消耗在 GC 线程上,jstack 里也看到大量G1或Parallel相关线程活跃,这时候问题很可能在堆内存上,应该用 jstat -gc 看 GC 频率,再用 jmap -heap 或 jmap -dump 看对象分布。
如果 jstack 里能明显看到某个线程反复出现在自定义的本地方法调用上,可以用 jcmdThread.print -l替代 jstack 再试一次。jcmd 是较新版本 JDK 的综合工具,输出的线程信息和 jstack 基本一致,但它在某些环境下对进程的侵入更小。
jcmd 12345 Thread.print -l > /tmp/thread_dump.log诊断工具之间不是互相替代的关系,而是互相印证的关系。线程栈给了线索,内存和 GC 数据给背景,操作系统工具给事实,串起来才是完整的故事。
5. 那些容易被忽略的坑:容器、大堆与采样姿势
jstack 用起来不难,但坑是真不少。我整理了几类高频问题,都是实操中很容易踩中的。
5.1 容器环境执行 jstack 的坑
容器化部署已经很普遍,但在容器里执行 jstack 有件容易被忽视的事:容器内看到的 PID 和宿主机上的 PID 不是同一个命名空间。如果你习惯先在宿主机上 top 找到了 Java 进程 PID,再进容器执行 jstack PID,很可能提示找不到进程。
正确做法是先进容器,再在容器内部用 jps 找 PID,然后执行 jstack。另外,很多精简容器镜像只装了 JRE 或者裁剪过 JDK,jstack 可能根本不存在,这时候要么换用自带诊断功能的镜像,要么通过挂载方式引入 JDK 工具,要么直接用 jcmd 代替。
容器场景还有一个隐蔽问题:内存限制。如果容器内存设置过小,执行 jstack 时 JVM 需要把线程状态序列化出来,可能临时占用较多内存,个别极端情况下会触发 OOM Killer。所以容器内执行诊断命令时,保证容器有足够的空闲内存会安全很多。
5.2 dump 大文件:线程数多时的数据处理
一个 JVM 里几百个线程并不罕见,如果线程名长、栈深、还开了 -l,一份 dump 很容易超过几万行。这种文件直接肉眼读效率太低,我一般这样处理:
先按线程名分组,统计同一类线程的数量。比如 Tomcat 的工作线程是否一致地停在同一个方法上,Netty 的 IO 线程是否全部阻塞在某个读写操作里。整理出线程名和数量分布,再挑代表性的线程看完整栈。
另外,把多次 dump 放在同一个文件里时,建议在每次 jstack 输出之间加一行明显分隔,方便按时间点切分。执行循环采集时可以用我前面写的 for 循环,把每次结果单独存文件,文件名里带上序号,事后逐一比对。
5.3 jstack 本身对目标进程的影响:别在生产环境放肆抓
jstack 需要 attach 到目标 JVM,attach 的过程会触发 JVM 的某些内部操作,比如线程转储时可能短暂停顿。大多数情况下这个停顿只有几十到几百毫秒,对业务影响可忽略。但如果目标 JVM 本身就处于高负载、内存极其紧张、或者堆很大的状态下,抓 dump 的副作用会被放大。
以前遇到过一个大堆应用,内存 30 GB 以上,线程数近千,执行 jstack 时目标进程出现秒级停顿,直接导致监控告警。所以操作生产环境时我有一条原则:先评估影响,再动手抓。真要在高峰期抓,尽量用低侵入的方式,并且一次只抓一份,不要连环执行。
5.4 JDK 版本差异导致的输出变化
不同 JDK 版本的 jstack 输出格式有点差异。比如较老的 JDK 8 某版本里os_prio可能不显示,较新的 JDK 17 里线程状态前会多出一些 GC 相关的线程信息。处理 dump 时要用对应版本的工具,否则可能出现 attach 失败或输出格式不兼容的情况。
还有一点:如果目标进程是较新 JDK 编译的,但系统的默认 jstack 是旧版 JDK,执行时大概率会报版本不匹配。解决办法是直接用目标 JDK 目录下的 jstack,或者在调用时加上完整路径。
/usr/local/java/bin/jstack -l 12345版本问题最容易在“开发机器上顺手执行一条 jstack”的时候踩中,所以脚本里显式指定 JDK 路径是个好习惯。
5.5 采样的姿势:单次 dump 的误判风险
我见过有人拿一份 dump 看到“所有线程都在 WAITING”,就断定线程池被打满了。结果是线程池空闲,那其实只是业务低峰期的正常状态。单次抓取最大的风险就是过度解读。
正确姿势是结合时间点、业务流量、监控指标一起看。如果是高峰期的 dump 中线程大量 BLOCKED,可信度很高;如果是凌晨低峰期的 dump 里线程都在等待,什么都不说明。另外,尽量在故障发生过程中抓取,故障结束后抓的 dump 往往找不到问题线索。
还有个容易误判的场景是自旋锁。现代 JVM 对 synchronized 做了自旋优化,线程在快照里显示为 RUNNABLE 且栈帧停留在AbstractQueuedSynchronizer或synchronized相关方法,实际上它可能是在自旋等待锁,而不是真的在忙业务逻辑。区分办法是连续采样看 CPU 时间,如果线程 cpu 字段持续增加,说明确实在自旋消耗 CPU。
5.6 最后分享一个抓取技巧:线程 CPU 时间的对比
从 JDK 8 某个版本开始,部分平台的 jstack 输出会在线程信息里显示cpu=字段,它表示线程累计使用的 CPU 时间。连续抓两份 dump,比较同一个线程的 cpu 值增长幅度,能非常直观地判断谁是真正的 CPU 大户。
比如一次故障中,某线程第一次采样 cpu=500ms,第二次采样 cpu=3200ms,说明两次采样之间它消耗了大量 CPU,结合它的栈帧就能确认是不是“热点线程”。这个技巧我在很多场合用过,比单纯看线程状态更有说服力。
jstack 这个工具看起来简单,但真正把它用好,需要对 JVM 线程模型有基本理解,也需要在实战中积累判断经验。遇到问题的时候,别急着盲目抓 dump,先想清楚症状、选对参数、连续采样,再结合其他工具交叉印证。这样一套流程下来,绝大多数线上线程问题都能找到方向。