1. 从一次线上接口超时说起:为什么需要 jstack
线上服务突然出现接口大面积超时,日志里没有任何异常堆栈,CPU 使用率也不算高,但请求就是卡在那里不动。这种场景相信不少后端同学都遇到过。第一次碰到的时候,我盯着监控面板看了半天,完全找不到方向——没有报错、没有 OOM、没有明显的资源瓶颈,问题像是凭空出现的。后来一位经验更丰富的同事提醒我:先抓一份线程 dump 看看。那是我第一次真正用上jstack,也是第一次意识到,JVM 里那些"看不见的线程状态"才是排查这类问题的关键入口。
jstack是 JDK 自带的一个命令行工具,作用是打印指定 Java 进程的线程快照,也就是我们常说的 thread dump。这份快照里包含了 JVM 中每一个线程在某一时刻的调用栈、线程状态、锁持有情况等核心信息。它解决的核心问题是:当程序"卡住"但又不报错时,帮你把每个线程"正在干什么"完整地拍下来,让你能顺着调用栈找到阻塞点。适合所有做 Java 后端开发、运维、性能调优的同学,尤其是需要处理线上问题的一线工程师。哪怕你平时只写业务代码,掌握jstack也能让你在排查死锁、线程池耗尽、接口假死这类问题时少走很多弯路。
这篇文章不会只给你一条命令就完事,而是把jstack背后的线程模型、输出内容的逐行解读、不同场景下的抓取策略、以及我踩过的那些坑,完整地讲一遍。读完你至少能做到:看到一份 thread dump 不慌,知道从哪里开始看,能定位到具体是哪一行代码、哪一把锁出了问题。
2. jstack 到底抓了什么:线程模型与 dump 的本质
2.1 JVM 线程与操作系统线程的映射关系
要理解jstack的输出,得先搞清楚 JVM 里的线程到底是什么。在主流 JVM 实现中,一个 Java 线程通常对应一个操作系统原生线程(在 Linux 上就是 pthread)。JVM 本身并不"模拟"线程调度,而是把线程的调度权交给操作系统内核。这意味着jstack抓到的每一个线程,背后都对应着操作系统里一个真实存在的执行单元。
这个映射关系很重要,因为它决定了jstack能给你什么、不能给你什么。它能给你的是 Java 层面的调用栈,也就是从某个入口方法一路调用到当前正在执行的方法的完整链路。它不能直接给你的是操作系统层面的调度信息,比如这个线程被分配了多少 CPU 时间片。所以jstack通常要和top -Hp、pidstat这类系统级工具配合使用,才能形成完整的排查链路。
还有一个细节值得注意:JVM 内部线程(比如 GC 线程、JIT 编译线程)也会出现在 dump 里。这些线程的名字通常带有明确的标识,比如GC task thread、C2 CompilerThread。排查业务问题时,这些线程一般可以先跳过,重点看那些名字和你业务相关的线程,比如 Tomcat 的http-nio-8080-exec-*、Dubbo 的DubboServerHandler-*。
2.2 thread dump 里到底记录了哪些字段
一份标准的 thread dump,每个线程块大致包含这些信息:
- 线程名称:如
http-nio-8080-exec-5,这是定位线程归属的第一线索。 - 线程优先级与 JVM 内部标识:如
prio=5 tid=0x00007f... nid=0x5a3c,其中nid是操作系统层面的线程 ID,和top -Hp看到的十六进制线程号能对上。 - 线程状态:如
RUNNABLE、BLOCKED、WAITING、TIMED_WAITING,这是判断线程"卡在哪"的核心依据。 - 调用栈:从栈顶到栈底的完整方法调用链,包含类名、方法名、行号(如果编译时保留了调试信息)。
- 锁信息:如果线程涉及锁,会显示
waiting to lock、locked等字样,以及锁对象的地址。
这些字段组合起来,就构成了一张"线程此刻在做什么"的完整快照。关键在于,单看一个线程往往看不出问题,必须把多个线程放在一起对比,才能发现死锁、锁竞争、线程池耗尽这类全局性问题。
2.3 为什么 dump 是"某一时刻"的快照
jstack抓取的是瞬时状态,这一点经常被忽略。JVM 在抓取时会进入一个安全点(safepoint),暂停所有线程,然后逐个记录它们的调用栈,记录完再恢复。所以一份 dump 反映的是某一个精确时刻的状态,而不是一段时间内的平均情况。
这个特性带来两个实际影响。第一,如果问题不是持续性的,而是间歇性的,单次 dump 可能刚好抓到"正常"的时刻,什么都看不出来。这时候需要间隔性地多抓几次,比如每隔 5 秒抓一次,连续抓 3 到 5 次,对比线程状态的变化。第二,抓取动作本身会让 JVM 短暂停顿,虽然通常只有几十毫秒,但在高并发场景下频繁抓取仍可能造成可感知的影响,所以生产环境抓 dump 要控制频率。
3. 动手抓一份 dump:命令参数与实操细节
3.1 最基础的用法与进程定位
最基础的命令形式是:
jstack <pid>其中<pid>是目标 Java 进程的进程号。获取 pid 的常见方式有两种:
# 方式一:通过 jps 列出所有 Java 进程 jps -l # 方式二:通过 ps 结合关键字过滤 ps -ef | grep javajps是 JDK 自带的工具,输出更干净,会直接列出 Java 进程的 pid 和主类名。我个人的习惯是优先用jps -l,因为它能直接告诉你每个进程对应的是哪个应用,避免在多实例部署时抓错进程。ps的方式更通用,但需要自己从一长串启动参数里辨认目标进程。
注意:
jstack必须由和目标进程相同用户身份、或拥有足够权限的用户执行。如果目标进程是其他用户启动的,普通用户执行会报 "Unable to open socket file" 之类的错误。这种情况下需要用sudo -u <目标用户>切换身份,而不是简单加sudo。
3.2 常用参数逐个拆解
jstack的参数不多,但每一个都有明确用途:
| 参数 | 作用 | 适用场景 |
|---|---|---|
-l | 额外打印锁的附加信息 | 排查死锁、锁竞争时必加 |
-F | 强制抓取,进程无响应时使用 | 进程 hang 死、普通方式抓不到时 |
-m | 同时打印 native 栈 | 涉及 JNI 调用、native 方法阻塞时 |
-h | 打印帮助信息 | 忘记参数时快速查阅 |
-l是最常用的增强参数。不加-l时,dump 里只会显示线程是否持有锁;加上-l后,会额外显示锁对象的类信息和持有者,排查死锁时信息量差别很大。-F是"最后手段",当进程已经卡到无法响应正常的 dump 请求时,用-F可以强制抓取,但代价是抓到的信息可能不完整,而且强制抓取本身有一定风险,非必要不用。
3.3 把 dump 输出到文件而不是刷屏
直接执行jstack <pid>会把内容全部打印到终端,几百上千个线程的 dump 能刷好几屏,根本没法看。正确做法是重定向到文件:
jstack -l <pid> > /tmp/dump_$(date +%Y%m%d_%H%M%S).txt用时间戳命名文件是个好习惯,尤其是需要连续抓多份做对比的时候,文件名自带时间信息,省得自己记。如果目标机器磁盘空间紧张,也可以直接通过管道传给grep做初步过滤,但我不建议一上来就过滤,因为过滤容易漏掉关键上下文,最好先完整保存,再在本地慢慢分析。
3.4 连续抓取:应对间歇性问题
对于间歇性卡顿,单次 dump 往往不够。我常用的做法是写一个简单的循环:
for i in 1 2 3 4 5; do jstack -l <pid> > /tmp/dump_$i.txt sleep 5 done这样每隔 5 秒抓一次,连续抓 5 次,覆盖大约 25 秒的时间窗口。如果问题表现为"每隔一段时间卡一下",这组 dump 里大概率能抓到卡顿时刻的状态。抓完之后,重点对比几次 dump 中同一个线程的调用栈有没有变化——如果某个线程在多次 dump 中都停在同一个位置,那基本可以确定它就是瓶颈所在。
提示:抓取间隔不宜过短。间隔太短,几次 dump 的状态几乎一样,起不到对比作用;间隔太长,又可能错过问题窗口。5 到 10 秒是比较稳妥的区间,具体可以根据问题的复现频率调整。
4. 读懂 dump:线程状态与调用栈的逐行解读
4.1 五种线程状态分别意味着什么
Java 线程状态在 dump 里以大写英文呈现,理解它们是解读 dump 的第一步:
- RUNNABLE:线程正在运行或准备运行。注意,处于 RUNNABLE 不代表一定在消耗 CPU,它也可能在等待网络 IO,因为 Java 层面无法区分"等 CPU"和"等 IO"。
- BLOCKED:线程在等待获取一个监视器锁(synchronized),说明发生了锁竞争。
- WAITING:线程在无限期等待,通常是在调用
Object.wait()、Thread.join()或LockSupport.park()。 - TIMED_WAITING:线程在限时等待,比如
Thread.sleep()、带超时的wait()。 - TERMINATED:线程已结束,dump 里一般看不到。
排查时,大量线程处于 BLOCKED 通常指向锁竞争;大量线程处于 WAITING 且调用栈指向线程池的take()方法,往往是线程池空闲的正常表现,不用紧张。关键是要结合调用栈判断,不能只看状态。
4.2 一个真实线程块的完整解剖
下面是一个典型的线程块(为保护隐私,类名做了脱敏处理):
"http-nio-8080-exec-5" #35 daemon prio=5 os_prio=0 tid=0x00007f8a4c1a8000 nid=0x5a3c waiting on condition [0x00007f8a3b7e0000] java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for <0x00000000f0a1b2c0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039) at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442) at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074) 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)逐行看:第一行给出了线程名http-nio-8080-exec-5,说明这是 Tomcat 处理 HTTP 请求的工作线程;nid=0x5a3c是操作系统线程号,可以和top -Hp的输出对应;waiting on condition表示它在等待某个条件。第二行明确状态是WAITING (parking)。调用栈从下往上看,最终停在LinkedBlockingQueue.take(),这是线程池在等待新任务时的正常阻塞。所以这个线程块是健康的,不用管它。
4.3 从调用栈定位到具体代码行
调用栈里最有价值的信息是行号。比如看到这样一行:
at com.example.service.OrderService.calculatePrice(OrderService.java:128)就能直接定位到OrderService第 128 行。前提是编译时保留了调试信息(默认的javac编译是保留的,但如果用了-g:none就会丢失行号)。如果发现 dump 里只有方法名没有行号,就要检查构建配置是不是把调试信息关掉了。
定位到行号之后,再结合那一行的代码逻辑判断:是在等锁、在等 IO、还是在做耗时计算。这一步是jstack排查的核心价值——它把"程序卡住了"这种模糊描述,变成了"卡在第 128 行的这个锁上"这种精确结论。
5. 三类高频问题的排查链路
5.1 死锁:如何从 dump 里一眼认出
死锁是jstack最擅长的场景。当两个或多个线程互相持有对方需要的锁时,JVM 能自动检测到,并在 dump 末尾直接给出提示:
Found one Java-level deadlock: ============================= "Thread-1": waiting to lock monitor 0x00007f8a4c0a3b00 (object 0x00000000f0a1b2c0, a java.lang.Object), which is held by "Thread-0" "Thread-0": waiting to lock monitor 0x00007f8a4c0a3b40 (object 0x00000000f0a1b2d0, a java.lang.Object), which is held by "Thread-1"看到Found one Java-level deadlock这行字,基本就实锤了。下面会清楚列出哪个线程在等哪把锁、这把锁被谁持有,形成闭环。排查死锁时,-l参数一定要加,否则可能看不到完整的锁持有关系。
我遇到过一次死锁,两个线程分别按不同顺序获取两把锁,代码里看起来完全正常,但并发一上来就互相卡死。dump 一抓,JVM 直接告诉我死锁在哪两个对象上,顺着对象地址找到对应的代码,问题一目了然。如果没有jstack,这种问题靠读代码可能要排查很久。
5.2 线程池耗尽:从线程名和数量入手
线程池耗尽的表现是:接口响应越来越慢,最后完全无响应,但 CPU 和内存都正常。这时候抓 dump,会看到大量业务线程都处于 WAITING 或 TIMED_WAITING 状态,且调用栈都指向同一个下游依赖。
排查思路是这样的:先数一数 dump 里业务线程的总数,和线程池配置的最大线程数对比。如果已经打满,再看这些线程都卡在哪里。常见原因是下游接口响应慢,导致线程池里的线程全被占住,新请求进不来。这时候调用栈会指向发起下游调用的那一行,比如某个 HTTP 客户端的execute()方法。
解决方向有两个:一是给下游调用加超时,避免线程被无限期占住;二是调整线程池参数,但这个要谨慎,盲目调大只是把问题推迟,治标不治本。
5.3 CPU 飙高:jstack 与 top 的配合打法
CPU 飙高时,jstack单独用效果有限,需要和top配合。完整链路是:
- 用
top找到 CPU 最高的 Java 进程 pid。 - 用
top -Hp <pid>找到该进程内 CPU 最高的线程,记下它的线程号(十进制)。 - 把十进制线程号转成十六进制:
printf "%x\n" <线程号>。 - 在
jstack的输出里搜索这个十六进制值(对应nid字段),定位到具体线程。 - 看这个线程的调用栈,找到消耗 CPU 的代码位置。
这套打法我用了很多次,屡试不爽。有一次线上 CPU 突然打满,用这个方法定位到一个正则表达式在特定输入下发生了灾难性回溯,调用栈直接指向那一行Pattern.matcher()。如果没有jstack,光看代码根本想不到是正则的问题。
6. 那些文档里不会写的坑
6.1 抓不到 dump 的几种情况和应对
最常见的是权限问题,前面提过,不再重复。另一种情况是进程已经卡到无法响应 dump 请求,普通jstack会超时或报错,这时候才轮到-F出场。但-F有个坑:它通过操作系统的信号机制强制抓取,抓到的调用栈可能缺少行号,甚至部分线程信息缺失。所以-F是"有总比没有强"的选择,不能指望它给出完美结果。
还有一种情况是容器环境。在容器里跑 Java 应用时,jstack需要能访问到目标进程的 socket 文件,如果容器权限配置过严,可能抓不到。这时候要么调整容器权限,要么在容器内以正确用户执行。
6.2 dump 文件太大怎么高效分析
生产环境动辄几百个线程,dump 文件几千行,肉眼扫根本不现实。我的做法是先用grep做粗筛:
# 统计各状态线程数量 grep "java.lang.Thread.State" dump.txt | sort | uniq -c # 找出所有 BLOCKED 的线程 grep -A 20 "BLOCKED" dump.txt # 搜索特定线程名 grep -A 30 "http-nio-8080-exec" dump.txt先看状态分布,如果 BLOCKED 数量异常多,就往锁竞争方向查;如果 WAITING 集中在某几个调用栈,就往依赖阻塞方向查。粗筛之后再针对可疑线程细看,效率比从头读到尾高得多。
6.3 抓取时机比抓取本身更重要
这一点是我踩坑最多的地方。刚开始用jstack时,我总是在问题"已经过去"之后才去抓,结果 dump 里一切正常,什么都看不出来。后来才明白,dump 是瞬时快照,必须在问题正在发生的那一刻抓才有意义。
所以现在我的习惯是:一旦监控告警,第一时间抓 dump,而不是先去翻日志。日志可以慢慢看,但 dump 的时机窗口稍纵即逝。对于可复现的问题,可以在测试环境反复触发、反复抓取,积累多份样本对比。对于不可复现的偶发问题,可以考虑在应用里埋一个"手动触发 dump"的入口,出问题时一键抓取,避免错过窗口。
7. 把 jstack 用成肌肉记忆
jstack这个工具,命令本身简单到只有几个参数,但真正用好它,靠的是对线程模型的理解、对 dump 内容的熟悉,以及大量实战积累出来的排查直觉。我现在的习惯是,只要线上出现"卡住但不报错"的问题,第一反应就是抓 dump,而不是先怀疑代码逻辑。因为 dump 给出的信息是客观的、精确的,它不会骗你,能帮你快速排除掉大量猜测。
如果你还没怎么用过jstack,建议找个测试环境,写一段故意制造死锁或线程阻塞的代码,亲手抓几份 dump 看看长什么样。看多了,那些线程状态和调用栈就会变成一种"直觉",出问题时一眼就能看出哪里不对劲。这种直觉,是任何文档都教不会的,只能靠一次次实操喂出来。
最后分享一个小技巧:把常用的抓取命令做成一个 shell 脚本,带上时间戳、自动压缩、自动清理旧文件,出问题时一条命令搞定,省得手忙脚乱敲错参数。排查问题的时候,少一分慌乱,就多一分找到根因的机会。