导读 :Java 服务 CPU 打满,网上的标准答案是
top -Hp找线程号、printf '%x'转十六进制、再去 jstack 里搜 nid。这套流程在 JDK 8 时代没错,但 JDK 11 以后 jstack 的每个线程都自带cpu=耗时,两次 dump 一减就知道谁在烧 CPU。本文用 JDK 8、11、17 实测三种常见的 CPU 打满场景(死循环、正则回溯、频繁 GC),给一个能直接用的排查脚本,也讲清楚它在哪些情况下会算错。
版本说明:JDK 8u321、JDK 11.0.2、JDK 17.0.12,macOS(Apple Silicon,8 核)。三种场景是我写的一个不到 60 行的演示程序,不是线上代码。Linux 上 top -H 那套做法本文没有实测,涉及的地方会单独说明。
一、先说清楚:CPU 100% 时到底要找什么
CPU 100% 的意思是:进程里有线程一直在跑,不睡觉、不等 IO。Java 进程里有几十上百个线程,业务线程、Tomcat 线程池、GC 线程、JIT 编译线程都在里面,排查要回答两个问题:
- 是哪个线程在烧 CPU
- 这个线程在执行哪行代码
第 2 个问题 jstack 一直都能答,它会打印每个线程的调用栈。难的是第 1 个:jstack 只是一张快照,快照里一堆 RUNNABLE 的线程,你分不出谁是真的在算、谁只是恰好被拍到。
不管它会怎样?一个死循环会把一个核吃满,只要进程不重启就一直吃下去(Java 没有"跑太久自动停"的机制)。核数少的容器里,一两个这样的线程就能让接口全面变慢;如果是 GC 线程在烧 CPU,通常说明堆快满了,离 OOM 不远。
什么时候值得用本文的方法:服务 CPU 持续偏高、top 看到 Java 进程占用 100% 以上,或者压测时 CPU 上去了但吞吐没上去。
二、传统做法:top -H 找线程,再转十六进制
这是网上流传最广的一套,Linux 上通用(本节命令没有在 Linux 上实测,是通用做法):
bash
# 1. 找到 CPU 最高的 Java 进程
top -c
# 2. 看这个进程里哪个线程 CPU 最高(-H 显示线程),记下线程的 PID,例如 12345
top -Hp 12345
# 3. 线程号转十六进制,jstack 里的 nid 是十六进制
printf '%x\n' 12346
# 4. 在 jstack 里按 nid 搜到那个线程的调用栈
jstack 12345 | grep -A 20 'nid=0x303a'
这套做法能用,但有三个麻烦:
| 麻烦 | 说明 |
|---|---|
| 步骤多、要手工换算 | 线上着急时最容易在十六进制这步出错 |
| 只能看到一瞬间 | top 的刷新和 jstack 不是同一时刻,偶发的尖刺对不上 |
| macOS 上走不通 | macOS 的 top 没有 -H,ps -M 能看到每个线程的 CPU,但看不到线程号,和 jstack 的 nid 对不上 |
第三条是我在本机实测时碰到的。JDK 8 的进程跑着两个死循环线程,ps -M 的输出是这样:
text
63923 94.9 R 31T 0:00.09 0:42.72
63923 94.5 R 31T 0:00.10 0:42.51
63923 0.4 S 31T 0:00.00 0:00.01
能看出有两个线程各吃了 95% 左右的 CPU,但没有任何一列能和 jstack 对上。
三、JDK 11 起 jstack 直接告诉你每个线程花了多少 CPU
同一个演示程序,分别用三个 JDK 启动,跑 7~8 秒后各拍一次 jstack。看线程头那一行:
text
# JDK 8u321
"http-nio-idle-87" #15 prio=5 os_prio=31 tid=0x00007f8ca2809800 nid=0x6203 waiting on condition [0x000000030afd2000]
# JDK 11.0.2
"order-retry-worker" #12 prio=5 os_prio=31 cpu=7101.41ms elapsed=7.55s tid=0x00007fe0d8837000 nid=0x6c03 runnable [0x000000030717a000]
# JDK 17.0.12
"order-retry-worker" #14 prio=5 os_prio=31 cpu=7742.02ms elapsed=8.17s tid=0x000000012a808a00 nid=0x7303 runnable [0x000000016fa92000]
JDK 11 和 17 多了两个字段:
cpu=7742.02ms:这个线程从启动到现在一共用了多少 CPU 时间elapsed=8.17s:这个线程活了多久
order-retry-worker 活了 8.17 秒、用了 7.74 秒 CPU,说明它几乎没停过。而旁边空闲的线程池线程是 cpu=0.18ms,一眼就能分开。
jcmd <pid> Thread.print 的输出和 jstack 一样,也带这两个字段;我在 JDK 17 上数了一下,输出里有 32 个线程带 cpu=。
⚠️ JDK 8 没有这两个字段,下面的脚本在 JDK 8 上什么都输出不了。JDK 8 的服务还是得用第二节的
top -H那套,或者用 Arthas 的thread -n(本文没有实测 Arthas)。
四、两次采样相减:排出最耗 CPU 的线程
cpu= 是累计值,直接按它排序会有误判:一个线程启动时跑了一大段初始化,累计值很大,但现在已经闲下来了。真正要看的是"最近这几秒谁在烧",所以拍两次 dump,用差值排序。
下面这个脚本就做这件事,只依赖 JDK 自带的 jstack 和系统自带的 awk:
bash
#!/bin/bash
# 用法: hot_threads.sh <pid> [间隔秒数,默认 5] [显示前几名,默认 5]
# 需要 JDK 11+(jstack 输出 cpu=...ms elapsed=...s)
# 拍两次线程 dump,按两次之间用掉的 CPU 排序,用每个线程自己的 elapsed 差值做分母
pid=$1; gap=${2:-5}; n=${3:-5}
dump() {
jstack "$pid" | awk '/^"/ && /cpu=/ {
name=$0; sub(/^"/,"",name); sub(/".*/,"",name)
match($0,/cpu=[0-9.]+ms/); cpu=substr($0,RSTART+4,RLENGTH-6)
match($0,/elapsed=[0-9.]+s/); el=substr($0,RSTART+8,RLENGTH-9)
print name "\t" cpu "\t" el }'
}
a=$(mktemp); b=$(mktemp)
dump > "$a"; sleep "$gap"; dump > "$b"
awk -F'\t' 'NR==FNR { c[$1]=$2; e[$1]=$3; next }
($1 in c) { dc=$2-c[$1]; de=($3-e[$1])*1000; if (de>0) printf "%7.1f%% %8.0fms %s\n", dc*100/de, dc, $1 }' "$a" "$b" | sort -rn | head -n "$n"
rm -f "$a" "$b"
用 jstack 的那个 JDK 要和目标进程一致,执行前把它的 bin 放到 PATH 前面。在 JDK 11 的进程上跑一次,间隔 3 秒:
bash
# 在服务器上执行,12345 换成 Java 进程号
PATH=/usr/lib/jvm/jdk-11/bin:$PATH ./hot_threads.sh 12345 3 3
本机的输出:
text
98.8% 3417ms email-check-worker
97.7% 3389ms order-retry-worker
0.1% 3ms GC Thread#7
第一列是"这几秒里占了一个核的百分之几",100% 就是一个核跑满。两个业务线程各吃满一个核。同一个程序第一次跑时,ps 看到的进程 CPU 是 192% 左右,两者对得上。
一个我踩到的坑:分母不能用 sleep 的秒数
脚本第一版用 sleep 的秒数做分母,在 JDK 11 上算出来是:
text
132.3% 3970ms order-retry-worker
129.9% 3897ms email-check-worker
一个线程不可能超过 100%。原因是 jstack 本身要花时间,两次 dump 的实际间隔比 3 秒长,3 秒的分母偏小(常见的解释是 jstack 要等 JVM 进入安全点再遍历线程,我没有单独测 jstack 每次花多久)。改成用线程自己的 elapsed= 差值做分母后,数字回到 98% 左右。如果你自己写类似的脚本,别用 sleep 的秒数算百分比。
五、找到线程以后:看调用栈判断是哪种问题
排出线程名以后,回到 jstack 里看它的调用栈。演示程序里埋了三种问题,调用栈长得都不一样。
1. 死循环:栈顶停在自己的业务代码
text
"order-retry-worker" #14 prio=5 os_prio=31 cpu=7742.02ms elapsed=8.17s tid=0x000000012a808a00 nid=0x7303 runnable [0x000000016fa92000]
java.lang.Thread.State: RUNNABLE
at CpuDemo.busyLoop(CpuDemo.java:29)
at CpuDemo$$Lambda$1/0x000000a801000a08.run(Unknown Source)
at java.lang.Thread.run(java.base@17.0.12/Thread.java:842)
栈很浅,栈顶就是业务方法和行号。演示代码是一个"重试失败就立刻再试"的循环,没有 sleep,也没有重试上限:
java
// 典型的忙等:远程调用一直失败时,这个循环会吃满一个核
static void busyLoop() {
int attempts = 0;
while (true) {
if (!tryLock(attempts++)) {
continue;
}
}
}
改法是给重试加间隔和上限,或者换成 ScheduledExecutorService 定时重试。多拍几次 jstack,如果栈顶的行号一直在同一小段代码里转,基本就是这一类。
2. 正则回溯:栈里全是 java.util.regex.Pattern
text
"email-check-worker" #15 prio=5 os_prio=31 cpu=7703.37ms elapsed=8.17s tid=0x0000000143114800 nid=0x6503 runnable [0x000000016fc9e000]
java.lang.Thread.State: RUNNABLE
at java.util.regex.Pattern$GroupCurly.match0(java.base@17.0.12/Pattern.java:4585)
at java.util.regex.Pattern$GroupCurly.match(java.base@17.0.12/Pattern.java:4536)
at java.util.regex.Pattern$GroupHead.match(java.base@17.0.12/Pattern.java:4790)
at java.util.regex.Pattern$Loop.match(java.base@17.0.12/Pattern.java:4899)
at java.util.regex.Pattern$GroupTail.match(java.base@17.0.12/Pattern.java:4821)
栈很深,前面几十帧全是 Pattern$GroupCurly、Pattern$Loop 这些内部类,往下翻才能看到是哪个业务方法调的。演示用的是嵌套量词的正则,输入一段不匹配的字符串就会陷入回溯:
java
// (x+.)+ 这种嵌套量词遇到不匹配的输入,回溯次数随长度指数增长
Pattern p = Pattern.compile("^(([a-z])+.)+[A-Z]([a-z])+$");
String input = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!";
p.matcher(input).matches();
这一类在 JDK 17 上照样能把线程卡死,换 JDK 版本解决不了,要改正则本身,或者限制输入长度。
3. 频繁 GC:烧 CPU 的是 GC 线程,不是业务线程
把演示程序换成"不停分配、一直持有"的模式,用 -Xmx64m 启动,进程 CPU 到了 243.8%。脚本的结果:
text
22.5% 1147ms VM Thread
19.6% 999ms GC Thread#0
19.5% 992ms GC Thread#7
19.5% 992ms GC Thread#6
19.4% 989ms GC Thread#2
19.3% 984ms GC Thread#1
19.3% 982ms GC Thread#5
19.3% 980ms GC Thread#3
排在前面的全是 GC Thread#N 和 VM Thread,真正在分配内存的业务线程 report-export-worker 只有 5.3%。这时去看业务线程的栈没用,要看 GC:
bash
# 每秒打印一次 GC 统计,连打 3 次
jstat -gcutil 12345 1000 3
本机输出(截取):
text
S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
0.00 100.00 20.54 99.56 83.06 50.93 19819 6.738 6296 8.668 38 0.020 15.425
0.00 100.00 0.00 99.15 83.06 50.93 20808 7.135 6578 9.127 48 0.023 16.285
0.00 100.00 16.15 98.69 83.06 50.93 21774 7.563 6886 9.595 50 0.023 17.181
老年代(O 列)一直在 99% 左右,两秒内 Full GC(FGC 列)从 6296 次涨到 6886 次。堆满了,GC 一直在回收又回收不掉多少,CPU 全花在 GC 上。下一步是 dump 堆看是谁占着内存,这就是另一个话题了。
💡 GC 线程的数量和 CPU 核数有关,8 核机器上默认 8 个
GC Thread。如果脚本结果里一排GC Thread的占比差不多,基本可以确定是 GC 问题。
六、排查步骤速查
| 步骤 | JDK 11 及以上 | JDK 8 |
|---|---|---|
| 确认是 Java 进程在烧 CPU | top / ps -o %cpu -p <pid> |
同左 |
| 找出最耗 CPU 的线程 | 本文的 hot_threads.sh(两次 jstack 相减) |
Linux:top -Hp <pid> + printf '%x';或 Arthas thread -n |
| 看这个线程在干什么 | jstack <pid> 按线程名搜 |
jstack <pid> 按 nid 搜 |
| 判断类型 | 栈顶在业务代码:死循环/忙等;栈里全是 Pattern:正则回溯;前几名全是 GC Thread:堆快满了 |
同左 |
| GC 类问题下一步 | jstat -gcutil <pid> 1000 3 看 O 列和 FGC |
同左 |
几条经验:
- 至少拍两次。一次 jstack 只是快照,用差值才能排除"以前忙、现在闲"的线程。
- 给线程起名字 。演示程序里线程叫
order-retry-worker、email-check-worker,jstack 里一眼就知道是哪块业务;如果全叫pool-3-thread-7,就得一个个看栈。线程池用自定义ThreadFactory起名,几行代码的事。 - jstack 会让 JVM 停一下。按 HotSpot 的安全点机制,拍 dump 时业务线程要停下来(停多久本文没测),排查时拍几次没问题,别写成每秒一次的监控。
七、常见问题
Q:容器里的 Java 进程没有 jstack 怎么办?
很多镜像只装了 JRE,没有 jstack 和 jcmd。可以在宿主机上用同版本的 JDK 对容器里的进程执行,或者给镜像装一个同版本 JDK 的工具包。版本要一致,否则会报连不上目标进程。这条我没有在容器里实测。
Q:cpu= 是怎么算的,准不准?
它是 JVM 从操作系统读到的线程 CPU 时间,单位毫秒。我在本机对了一下:两个死循环线程的差值各是 98% 左右,同一个程序 ps 看到进程 192% 左右,两者一致。本文只在 macOS 上对过,Linux 没有对。
Q:进程 CPU 很高,但脚本排出来每个线程都不高?
可能是线程太多、每个都只占一点,比如几百个线程在抢锁、频繁上下文切换。这时脚本的前几名加起来接近进程 CPU 就说明没漏;加起来差得远,可能是短命线程(两次 dump 之间创建又结束的线程不会出现在结果里)。
Q:JDK 8 有没有不用 top -H 的办法?
可以在应用里用 ThreadMXBean.getThreadCpuTime(threadId) 读每个线程的 CPU 时间,自己做两次采样相减,原理和本文脚本一样。或者直接用 Arthas。这两种本文都没有实测。
参考链接
你们线上碰到的 CPU 100%,最后查出来是死循环、正则、GC,还是别的原因?服务现在是 JDK 8 还是 11 以上?评论区说说,我挑典型的补进 FAQ。
觉得有用的话点个赞、收个藏,下次服务 CPU 打满时翻得到。