先说一个我自己的经历。上周五下午,监控突然报警,线上一个服务节点的 CPU 直接顶到 100%,首页接口超时率肉眼可见地往上涨。群里第一反应是“赶紧看日志”,但十几个人围着日志平台 grep 了半天,只看到一堆业务报错,CPU 还是下不来。后来我让同事别再刷日志了,在服务器上敲了三行命令,一分钟不到就定位到某个类里面第 117 行的死循环。今天把这个方法完整写下来说清楚,核心思路就一句话:CPU 再复杂,终归是某个线程在跑,找到这个线程,就等于找到了代码行号。
这个方法适合所有跑在 Linux 上的 Java 应用,尤其是 Spring Boot、微服务、容器化部署这类场景。你不需要装额外 agent,也不需要等监控平台出火焰图,只要服务器上有 JDK 自带的 jstack,再加上系统自带的 top,就够了。如果你恰好也经历过“生产环境 CPU 飙升 100%,不知道从哪下手”的窘境,这篇内容能帮你把排查时间从小时级压缩到 1 分钟。
1. 为什么先别翻日志:CPU 飙升的排查思路
1.1 翻日志的低效之处
遇到 CPU 飙高,很多人的第一反应是打开日志文件,搜 error、exception。说实话,这是最容易走弯路的地方。日志能告诉你业务出了什么异常,但很难告诉你哪一行代码把 CPU 吃满了。原因有三个。
第一,日志量太大。高并发服务的应用日志每秒都能产生几百上千行,你自己都不知道该搜什么关键词。第二,日志信息滞后。日志是程序跑完某个片段后的记录,CPU 打满可能发生在热路径的最深处,等你看到异常时,现场已经过了几十秒。第三,日志里根本没有线程和 CPU 的关联关系。即使你发现某个接口疯狂报错,也没办法知道这些报错到底绑在哪一个线程上,更不知道这个线程是不是占总 CPU 的大头。
所以,翻日志应该作为事后验证手段,而不是第一排查手段。正确的第一手段,是看 Linux 自身的进程和线程状态。
1.2 排查主线:进程 → 线程 → 代码栈
CPU 负载来自操作系统里的可执行线程。一个 Java 进程内部可能有几百个线程,其中某个线程一旦进入死循环或空转,它占用的核数就会飙升。顺着这条线,我们需要回答三个问题:
- 哪个进程在烧 CPU?
- 这个进程里的哪个线程在烧 CPU?
- 这个线程当前执行到了哪段代码的行号?
这就是经典的“进程 → 线程 → 代码栈”三层定位法。先缩小范围,再拿到线程栈,最后结合字节码或者源码定位到具体行号。整个过程本质上是在做“资源消耗”到“执行路径”的映射。
这样做的核心优势是:不需要预先埋点,不需要修改代码,不需要等待监控曲线。只要服务器还活着,你就能从 /proc 里拿到现场。与进程 ID、线程 ID、线程栈这些操作系统层信息相比,日志更偏业务层,业务层是虚的,系统层是实的。
1.3 为什么是 top + jstack,而不是其他监控工具
有人会问,现在监控平台都有 CPU 火焰图、在线 profiler,为什么不直接去看火焰图?这里有一个很现实的问题:生产环境不一定接入了完整的 profiling 组件,部分公司甚至没有部署 Arthas 或 async-profiler。等你登录监控系统找到对应时段的数据,业务可能已经挂了几分钟。而且这些工具本身也会产生额外性能开销,线上环境不一定愿意开。
top 是 Linux 自带命令,jstack 是 JDK 自带命令,两个加起来几乎零成本。top 负责看系统资源,jstack 负责看 Java 线程栈,两者通过“线程 ID”这个桥梁连接。这也是很多老运维的默认手段。它不依赖任何外部服务,甚至在容器里也基本可用,只是需要一点点额外注意,这个后面会讲。
2. 三行命令全集:每个参数都拆明白
2.1 第一行:用 top 找到“吞 CPU 的进程”
第一条命令是最常规的:
top -c启动后按下键盘上大写的P,让进程列表按 CPU 使用率排序。-c参数的作用是显示完整的命令行,否则你只能看到java或者python这样的缩写,看不出是哪个服务。这点在服务器上部署了多个 Java 应用时非常重要。
观察几秒后,找到最顶部那个进程的 PID。比如输出:
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 12345 root 20 0 43.2g 12.1g 15236 R 100.0 15.3 33:41.89 java -Xms2g -Xmx2g -jar order-service.jar这个12345就是我们要的进程号。需要注意,%CPU这一列是相对单个核的占用比例,而不是相对整机所有核。如果服务器是 8 核,单线程打满会显示 100%,整体机器还有 700% 空闲,所以不要看到 100% 就觉得是整机打满。
如果你希望后续步骤用命令行自动处理,也可以用非交互模式采样一次:
top -b -n 1 -o %CPU | head -20-b表示 batch 模式,可以输出到管道;-n 1表示只采样一次。但只采样一次可能抓不准瞬时尖峰,建议手动跑的时候先观察一两秒,确认它持续处于高位再往下走。
2.2 第二行:用 top -Hp 找到“进程内部的显眼线程”
拿到进程 PID 后,进入第二步:
top -Hp 12345这里的-H表示开启线程模式,-p指定进程。进入界面后同样按大写的P按 CPU 排序。这时候屏幕上每一行不再代表一个进程,而是一个线程。注意看 PID 列,它此时叫 TID,是线程 ID。
假设输出如下:
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 12666 root 20 0 43.2g 12.1g 15236 R 99.9 0.4 9:12.41 java这里的12666就是罪魁祸首线程的十进制线程 ID。有些版本的 top 会显示线程名,比如http-nio-8080-exec-11或GC task thread。看到线程名之后,你可以初步判断是不是业务线程。
如果只想拿一行结果,也可以这样写:
top -Hp 12345 -b -n 1 | awk '$9 > 50 {print $1}' | head -1但这一步我们手动做最直观。第二个命令和第一个命令看起来很像,只是多了-H,但这正是从进程维度下钻到线程维度的关键。
2.3 第三行:用 jstack 把线程 ID 翻译成代码行号
拿到线程 ID 之后,不能直接拿12666去 jstack 里搜,因为 jstack 输出里的线程 ID 是十六进制。所以先做一次转换:
printf '%x\n' 12666得到317a。然后执行:
jstack 12345 | grep -A 30 "nid=0x317a"jstack会 dump 出当前 Java 进程所有线程的栈。grep -A 30表示匹配到包含nid=0x317a的行后,继续往下打印 30 行,这样就能看到完整调用栈。如果这条线程正好在死循环里,输出大概是:
"order-thread-1" #11 prio=5 os_prio=0 cpu=... tid=... java.lang.Thread.State: RUNNABLE at com.example.service.OrderService.handleOrder(OrderService.java:117) at com.example.service.OrderService.run(OrderService.java:45) at java.lang.Thread.run(Thread.java:829)注意看到at com.example.service.OrderService.handleOrder(OrderService.java:117),这里的 117 就是精确到行的代码行号。打开源码看一眼,死循环大概率就写在这里。整个过程真正做到 1 分钟以内。
如果觉得一行 grep 太长,可以封装成一句话:
jstack 12345 | grep -A 30 "nid=0x$(printf '%x' 12666)"2.4 基于场景的小变体:jstack -l、jcmd 和 kill -3
实际生产环境不一定那么顺利。如果你的目标是定位锁等待、死锁这类问题,可以在 jstack 后加-l:
jstack -l 12345 | grep -A 30 "nid=0x317a"-l会额外打印锁信息,包括 owner 和 waiting to lock,对付 BLOCKED 状态非常有用。如果 jstack 报错说版本不匹配,可以试试 JDK 自带的 jcmd:
jcmd 12345 Thread.print它的输出格式和 jstack 类似,本质上是一个更现代的替代品。
还有一种比较暴力的做法:向 Java 进程发送kill -3信号,让 JVM 自动把线程栈打印到标准输出,也就是应用日志里。命令是:
kill -3 12345这个方法不需要执行 jstack,更不需要进入容器,只要应用日志能被看到,就能拿到线程栈。不过它打出来的是所有线程栈,文件会很大,需要自己筛。作为备选方案没问题。
3. 完整实战:从 CPU 100% 到代码行号,只花 1 分钟
3.1 准备一个 CPU 打满的 Java 进程
纸上谈兵没意思,我们来模拟一次真实事故。写一个简单但足够有代表性的 Java 程序:
import java.util.Random; public class CpuDemo { public static void main(String[] args) { OrderService service = new OrderService(); service.process(); } static class OrderService { private final Random random = new Random(); void process() { System.out.println("start"); // 模拟业务死循环 while (true) { calculatePrice(); } } private void calculatePrice() { double total = 0; for (int i = 0; i < 10000; i++) { total += Math.log(i + 1) * random.nextDouble(); } if (total < 0) { System.out.println("never"); } } } }编译运行后,这个进程的 CPU 会打到 100%。现在我们假装不知道问题在哪,开始排查。
3.2 执行命令并解读关键输出
第一步执行top -c,看到最上面一行:
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 24100 root 20 0 ... ... ... R 100.0 1.2 0:05.33 java CpuDemo锁定 PID 是 24100。第二步执行:
top -Hp 24100输出里有一个线程 CPU 也是 100%,TID 是 24118:
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 24118 root 20 0 ... R 100.0 0.1 0:04.31 java第三步转换线程 ID:
printf '%x\n' 24118得到5e36。最后执行:
jstack 24100 | grep -A 15 "nid=0x5e36"输出:
"main" #1 prio=5 os_prio=0 cpu=... tid=... java.lang.Thread.State: RUNNABLE at CpuDemo$OrderService.calculatePrice(CpuDemo.java:24) at CpuDemo$OrderService.process(CpuDemo.java:15) at CpuDemo.main(CpuDemo.java:7)马上可以看到CpuDemo.java:24是循环内累计计算的那一行,问题一目了然。如果是真实业务,你只需要把calculatePrice里的逻辑拉出来 review,看看是循环退出条件写错还是算法复杂度太高。
3.3 定位到业务线程后的下一步
拿到代码行号后,第一件事不是急着改代码,而是确认是不是“偶发尖峰”。很多 CPU 飙高是因为定时任务、大促活动、突发流量触发的短时间高负载,等你看线程栈的时候它可能已经降下来了。
我的习惯是连续执行两次线程栈:
jstack 24100 > /tmp/stack1.log sleep 2 jstack 24100 > /tmp/stack2.log diff /tmp/stack1.log /tmp/stack2.log如果两次栈里都卡在同一个业务方法,那基本可以断定是持续性死循环;如果第一次在方法 A,第二次在方法 B,那很可能是在大量新建线程或频繁上下文切换,问题的根源不一定是代码行号本身。
另外,拿到行号后应该立刻保存现场,包括 PID、TID、十六进制 id、线程名、完整调用栈,以及当前的应用版本号和最近发布记录。这样既可以回滚代码,也能给研发同学提供一手信息。遇到内存不足或连接池耗尽的情况,线程栈文件还能用来做二次分析。
3.4 如果最后发现是 GC/锁问题怎么办
不是所有 CPU 飙升都来自业务代码死循环。有时候我们根据线程名会看到GC task thread、VM Thread、C2 CompilerThread这些 JVM 内部线程占了高 CPU。这时候定位到的代码行号可能不直接对应业务逻辑,需要换一个排查方向。
先拿 GC 来说。如果你在 jstack 输出里看到:
"GC task thread#0 (ParallelGC)" os_prio=0 tid=... java.lang.Thread.State: RUNNABLE说明 CPU 高烧很可能是因为 Full GC。最常用的验证命令是:
jstat -gcutil 24100 1000 10它会每秒打印一次堆内存各区域的使用比例和 GC 次数。关注FGC那列,如果它在快速增长,或者每次 Full GC 后老年代使用率还是纹丝不动,那确认是内存分配或回收问题。下一步应该抓堆 dump,用 MAT 分析对象引用,而不是继续盯着业务代码行号。
再比如锁竞争导致的自旋。jstack 输出里能看到大量线程 BLOCKED,或者waiting to lock <0x...>。这种情况 CPU 可能不会到 100%,但服务器负载会非常高。排查时要多打印几十行栈,找到锁的持有者。我记得有一次线上问题就是因为一个静态锁对象里的逻辑调用了慢 SQL,导致所有请求全部堆在锁等待上,线程栈和 SQL 日志一对比就清楚了。
4. 生产环境实战避坑手册
4.1 jstack 执行失败的三个常见原因
jstack 不是任何时候都能顺利执行,最常见的坑有三个。
第一是权限不足。jstack 要以和 Java 进程同样的用户身份运行,比如进程是www用户启动的,执行sudo -u www jstack 24100,或者直接切到 root。否则会遇到 “Unable to open socket file” 错误。第二是容器环境。在 Docker 或 k8s 环境里,宿主机上的 jstack 可能无法 attach 到容器内的 JVM,因为进程的磁盘路径和网络空间都不一样。解决办法是进入容器再执行:kubectl exec -it pod-name -- jstack 24100,或者docker exec -it container-name jstack 24100。第三是 JDK 版本不一致。如果服务器上装了多个 JDK,而你当前 PATH 里的java版本和启动进程的版本不一致,也会失败。可以先执行ps -ef | grep java看看进程的完整路径,再决定用哪个 jstack。
还有一个小技巧:jstack 执行时会短暂暂停 JVM,虽然时间很短,但在核心业务高峰期也有小概率造成停顿。如果承受不住,优先用kill -3,或者直接上jstack -F强制模式,但-F也会更危险,能不用就不用。
4.2 线程 ID 进制转换失误:一个小白必踩的坑
这是我见过最多人翻车的地方。top 里显示的是十进制 TID,比如 24118。jstack 里的nid是十六进制,比如0x5e36。很多同学直接拿 24118 去 grep,结果自然搜不到,然后又怀疑 jstack 卡死,折腾了十分钟才发现没转进制。
一定要养成肌肉记忆:
printf '%x\n' 24118结果不必补零,5e36就是最终的0x5e36。如果你希望一次性写出完整命令,可以这样:
jstack 24100 | grep -A 30 "nid=0x$(printf '%x' 24118)"注意 shell 里的%x是小写 x,得到十六进制用小写字母,和 jstack 输出保持一致。
4.3 如何批量筛查多个高 CPU 线程
单线程把 CPU 打到 100% 很好定位。但有时候是多核飙升,比如 8 核机器直接 700% 以上,说明有多个线程在同时运行。逐个手敲太慢,可以写一个简单循环:
PID=24100 TIDS=$(top -Hp $PID -b -n 1 | awk '$9 > 30 {print $1}') for TID in $TIDS; do HEX=$(printf '%x' $TID) echo "===== TID=$TID HEX=0x$HEX =====" jstack $PID | grep -A 20 "nid=0x$HEX" donetop -Hp $PID -b -n 1会生成一批线程列表,awk 里的$9是 CPU 百分比列,超过 30 的我们认为都是可疑目标。然后循环打印对应线程栈。建议执行前把输出重定向到文件:
bash cpuscan.sh > /tmp/cpu_scan.log这样不会因为输出太长把终端刷爆,还能保留证据。
4.4 区分 CPU 高和负载高的排查差异
我们讲的是 CPU 飙升,但生产环境还有一种常见情况是load average很高,CPU 却不忙。比如大量进程处于 D 状态,也就是不可中断睡眠,通常发生在磁盘 IO 故障、网络文件系统挂死、内存回收卡住时。用 top 你会看到wa这一列数值很高,或者大量进程是 D 状态。
这个时候用 jstack 不一定能看到问题,因为 Java 线程虽然卡着,但 CPU 并没有被它消耗。排查思路要切成iostat、dmesg、strace这类 IO 工具。所以提醒一句:看到 CPU 100% 用本文的方法没问题;看到 load 高但 CPU 低,先检查 IO,别让 jstack 背黑锅。
4.5 其他语言场景的快速定位参考
虽然标题场景是 Java 技术栈最常见,但我们也经常遇到非 Java 服务,这里简单列一下快速参考,以免换语言时毫无头绪:
- Node.js:先用
top -Hp <pid>找线程,再用node --prof或perf record -p <pid> -g收集热点,或者用0x工具生成火焰图。 - Python:有
py-spy dump --pid <pid>,可以直接打印 Python 调用栈,不需要重启服务,非常方便。 - C/C++:可以用
gdb -p <pid>然后thread apply all bt,但生产环境慎用,最好用perf top或perf record来采样,再离线分析。
这些工具的底层思路和 jstack 一样:把“正在执行的线程/进程”和“代码调用路径”对应起来,核心都是栈采样。
4.6 排查过程中的注意事项与速查表
总结几个我在生产环境摸爬滚打总结出来的注意事项:
- 不要一上来就 kill -9。确认是持续高 CPU 之后再考虑重启,否则重启后问题还会回来。
- 不要在业务高峰期长时间反复执行 jstack。一两次没问题,上十几次就会对应用产生产线停顿风险。
- 优先保留线程栈文件,再讨论修复方案。复盘时没有现场栈,全靠回忆很难讲清楚。
- 排查完记得把最后一条命令打包成脚本,下次直接复制,不要在紧张环境下手敲命令出错。
速查表整理如下,可以截图收藏:
| 场景 | 命令 | 关键输出 |
|---|---|---|
| 找进程 | top -c后按 P | PID、%CPU、完整命令 |
| 找线程 | top -Hp <PID>后按 P | TID、线程名、%CPU |
| 转十六进制 | printf '%x' <TID> | 十六进制 nid |
| 打印线程栈 | jstack <PID> | 线程状态、调用栈、代码行号 |
| 批量筛线程 | 循环脚本配合 top/jstack | 多个可疑线程栈 |
| 排障 GC | jstat -gcutil <PID> 1000 10 | FGC 频率、堆使用率 |
最后分享一个我自己的惯用脚本,我已经把它写进了登录服务器的默认环境变量里,遇到 Java 服务 CPU 异常直接调:
function javacpu() { local pid=$1 local tid=$(top -Hp "$pid" -b -n 1 | awk '$9 > 50 {print $1; exit}') if [ -z "$tid" ]; then echo "no high cpu thread found, please check again" return 1 fi local hex hex=$(printf '%x' "$tid") echo "PID=$pid TID=$tid HEX=0x$hex" jstack "$pid" | grep -A 30 "nid=0x$hex" }例如:
javacpu 24100它会自动帮你找到 CPU 占比超过 50 的线程,然后打印出对应的线程栈。如果看到输出里最后几行是CpuDemo.java:24,恭喜,问题已经水落石出。以后别再翻日志了,让命令替你说话,省下的时间用来复盘代码逻辑不香吗。