线上告警:某服务 CPU 使用率 100%,负载飙升,接口 RT 从 50ms 涨到 3s。
本文完整记录从top→top -Hp→ 十进制转十六进制 →jstack→ 定位代码行 的排查全过程,并给出可复用脚本和常见坑。
一、问题现象
值班时收到监控告警:
[ALERT] cpu_usage > 90% instance: prod-app-03 current: 99.2%登录机器一看,top 输出:
$ top
top - 14:32:11 up 32 days, 3:12, 2 users, load average: 8.42, 8.10, 7.98
Tasks: 213 total, 3 running, 210 sleeping, 0 stopped, 0 zombie
%Cpu(s): 98.5 us, 0.8 sy, 0.0 ni, 0.0 id, 0.7 wa, 0.0 hi, 0.0 si
MiB Mem : 16000.0 total, 1200.3 free, 9000.5 used, 5799.2 buff/cache
MiB Swap: 0.0 total, 0.0 free, 0.0 used
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1111 app 20 0 12.3g 3.1g 20000 S 398.7 19.8 1234:56 java注意这里 %CPU 是 398.7,说明是多核被同时打满(假设是 4 核),不是单一 CPU 跑满。这个 Java 进程 1111 显然就是元凶。
第一原则:先确认「是不是 Java 进程」,再往里钻。ps -ef | grep 1111 确认是业务服务,然后进入下一步。
二、定位到具体线程
一个 Java 进程里可能跑着几十甚至几百个线程(Tomcat 工作线程、GC 线程、定时任务、业务线程池……),top 只看得到进程级别,看不到线程。这时要用 top -Hp(-H = 显示线程,-p = 指定进程)。
$ top -Hp 1111
top - 14:33:02 up 32 days, 3:13, 2 users, load average: 8.42, 8.10, 7.98
Threads: 87 total, 2 running, 85 sleeping, 0 stopped, 0 zombie
%Cpu(s): 98.6 us, 0.7 sy, 0.0 ni, 0.0 id, 0.7 wa, 0.0 hi, 0.0 si
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
12205 app 20 0 12.3g 3.1g 20000 R 99.3 0.0 1200:11 java <-- ★
12206 app 20 0 12.3g 3.1g 20000 R 98.8 0.0 1198:40 java
12207 app 20 0 12.3g 3.1g 20000 R 98.1 0.0 1197:22 java
12208 app 20 0 12.3g 3.1g 20000 R 97.6 0.0 1196:01 java
1112 app 20 0 12.3g 3.1g 20000 S 1.2 0.0 23:11 java
...看到 4 个线程(12205/12206/12207/12208)几乎同时 100%,这正好对应 4 核。
这不是"某一个线程偶尔卡一下",而是有 4 个线程在并行地做同一件高 CPU 的事——典型的线程池里所有 worker 同时进入了死循环 / 死锁自旋 / 大计算。
关键点:top -Hp输出的第一列PID其实是操作系统的LWP(Light Weight Process,轻量级进程),也就是 Java 里的「线程 ID」,不是进程 ID。很多人这里会懵,以为看到 4 个不同进程。
如果机器上没装 top -H 或者版本输出不规整,可以换 ps:
# -L 显示线程,lwp 是线程 ID,--sort=-%cpu 按 CPU 排序
$ ps -L -p 1111 -o lwp= -o pcpu= -o stat= -o comm= --sort=-pcpu | head -10
12205 99.3 R java
12206 98.8 R java
12207 98.1 R java
12208 97.6 R java结果完全一致,而且更适合脚本处理。
三、十进制线程 ID 转十六进制(关键一步)
这一步是很多人卡住的地方。
jstack 输出的线程标识是 nid=0x2fad 这种十六进制格式,而 top / ps 给出的 12205 是十进制。
不转换就永远匹配不上。
$ printf "%x\n" 12205
2fad所以 12205 对应十六进制 2fad,要在 jstack 里找 nid=0x2fad。
顺手把 4 个线程都转一下:
$ for t in 12205 12206 12207 12208; do echo -n "$t -> 0x"; printf "%x\n" $t; done
12205 -> 0x2fad
12206 -> 0x2fae
12207 -> 0x2faf
12208 -> 0x2fb0常见写法对比:
printf "%x\n" 12205— 最稳,任何 Linux 都有echo "obase=16; 12205" | bc— 需要bcpython3 -c "print(hex(12205))"— 输出0x2fad,注意会带0x前缀printf "0x%x\n" 12205— 直接带前缀输出,推荐
jstack里nid匹配时注意要带上0x,否则会误匹配。
四、jstack 抓取并匹配线程栈
$ jstack 1111 > /tmp/jstack_1111.txt建议 jstack 抓 2~3 次,每次间隔 5~10 秒。 因为单次快照可能正好抓到 GC 或阻塞的瞬间,多次对比才能确认热点。
然后从文件中匹配目标线程栈:
# 抓取 nid=0x2fad 这个线程接下来的 40 行栈
$ grep -A 40 "nid=0x2fad " /tmp/jstack_1111.txt | sed '/^$/q'输出:
"http-nio-8080-exec-8" #38 daemon prio=5 os_prio=0 tid=0x00007f3a5c1e8000 nid=0x2fad runnable [0x00007f3a2b3e6000]
java.lang.Thread.State: RUNNABLE
at com.demo.order.OrderServiceImpl.matchCoupon(OrderServiceImpl.java:132)
at com.demo.order.OrderServiceImpl.calcTotal(OrderServiceImpl.java:88)
at com.demo.order.OrderController.query(OrderController.java:41)
at sun.reflect.GeneratedMethodAccessor102.invoke(Unknown Source)
...
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)几个必须读懂的信息:
| 字段 | 含义 |
|---|---|
http-nio-8080-exec-8 | 线程名,一眼能看出是 Tomcat 工作线程(web 请求线程) |
#38 | JVM 内的线程编号 |
daemon prio=5 | 守护线程、优先级 |
tid=0x00007f3a5c1e8000 | JVM 内部线程 ID(注意不是 OS 的!别拿这个去 top 里找) |
nid=0x2fad | 操作系统线程 ID(LWP),十六进制,这才是和 top 对应的 |
runnable | 线程状态,RUNNABLE 说明在跑或准备跑 |
java.lang.Thread.State: RUNNABLE | Java 层线程状态 |
[0x00007f3a2b3e6000] | 线程栈起始地址 |
at com.demo... | 栈顶就是最耗 CPU 的代码位置 |
再检查其余 3 个线程:
$ for h in 0x2fad 0x2fae 0x2faf 0x2fb0; do
echo "===== $h ====="
grep -A 5 "nid=$h " /tmp/jstack_1111.txt | head -6
done===== 0x2fad =====
"http-nio-8080-exec-8" ... runnable
at com.demo.order.OrderServiceImpl.matchCoupon(OrderServiceImpl.java:132)
===== 0x2fae =====
"http-nio-8080-exec-9" ... runnable
at com.demo.order.OrderServiceImpl.matchCoupon(OrderServiceImpl.java:132)
===== 0x2faf =====
"http-nio-8080-exec-10" ... runnable
at com.demo.order.OrderServiceImpl.matchCoupon(OrderServiceImpl.java:132)
===== 0x2fb0 =====
"http-nio-8080-exec-11" ... runnable
at com.demo.order.OrderServiceImpl.matchCoupon(OrderServiceImpl.java:132)4 个线程全部卡在 OrderServiceImpl.matchCoupon(OrderServiceImpl.java:132)!
到这里,问题就从"哪台机器、哪个进程、哪个线程"缩小到了"哪一行代码"。
五、打开源码看那一行
OrderServiceImpl.java:132:
// 错误示范(就是它!)
private List<Coupon> matchCoupon(Order order, List<Coupon> coupons) {
List<Coupon> result = new ArrayList<>();
for (Coupon c : coupons) {
if (order.getAmount() >= c.getThreshold()
&& order.getAmount() <= c.getMaxAmount()
&& Pattern.matches(c.getRuleRegex(), order.getUserTag())) { // 132 行 ★
result.add(c);
}
}
return result;
}发现两个问题:
Pattern.matches(regex, input)每次都重新编译正则——正则编译是 CPU 密集操作。更致命的是:
c.getRuleRegex()是用户后台配置的,某运营同学刚配了一条灾难性回溯的正则:^(a+)+$匹配失败时会指数级回溯。正好那个时段大量请求的
userTag都是aaaa...ab格式,
直接把 CPU 打满。
验证:用一个隔离环境跑这段匹配:
public static void main(String[] args) {
String regex = "^(a+)+$";
String input = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; // 30 个 a + b
Pattern.matches(regex, input); // 直接 CPU 跑飞,几秒都出不来
}六、修复方案
三个层次,从止血到根治:
1. 紧急止血
- 把那条灾难正则从配置里删掉(后台改动,无需重启);
- 或在入口处对
userTag长度加限制。
2. 代码层面修复(必须做)
(a)正则预编译缓存
private static final ConcurrentHashMap<String, Pattern> PATTERN_CACHE = new ConcurrentHashMap<>();
private Pattern getPattern(String regex) {
return PATTERN_CACHE.computeIfAbsent(regex, Pattern::compile);
}
// 使用
if (getPattern(c.getRuleRegex()).matcher(order.getUserTag()).matches()) {
...
}(b)业务短路:把便宜的判断放前面,正则放最后(原代码其实已经这么做了,但正则本身炸了还是没救)。
(c)给正则加超时保护(正则回溯是 Java 一直以来的坑):
// 方案一:用独立的 Executor 做超时控制
private static final ExecutorService REGEX_POOL = Executors.newFixedThreadPool(4);
public static boolean matchWithTimeout(Pattern p, String input, long timeoutMs)
throws Exception {
Future<Boolean> f = REGEX_POOL.submit(() -> p.matcher(input).matches());
try {
return f.get(timeoutMs, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
f.cancel(true);
throw new RuntimeException("regex timeout: " + p.pattern());
}
}更彻底的做法是用 RE2/J(com.google.re2j:re2j),它保证线性时间、不会回溯爆炸,兼容大部分 PCRE 语法。(d)后台校验正则:运营配置正则时用 RE2 检查一遍,拒绝 (a+)+、(a|aa)+ 这类危险模式。
3. 复盘治理
- 后台可配正则 → 必须走安全审核;
- 给 CPU 密集操作加超时 / 熔断;
- 加线程级别的 CPU 监控告警(Arthas / Prometheus 都支持)。
七、可复用一键排查脚本
手动敲 4 条命令每次都累,写个脚本存成 find-hog.sh:
#!/bin/bash
# 用法: ./find-hog.sh <java-pid> [采样次数, 默认 1]
# 输出: 每次采样最耗 CPU 的线程栈
PID=$1
TIMES=${2:-1}
if [ -z "$PID" ] || [ ! -d "/proc/$PID" ]; then
echo "usage: $0 <java-pid> [times]"
exit 1
fi
echo "pid = $PID ($(ps -p $PID -o comm=))"
for i in $(seq 1 $TIMES); do
echo
echo "=========== sample #$i @ $(date '+%F %T') ==========="
# 取 CPU 最高的线程 LWP(兼容 top -H 和 ps -L)
TID=$(ps -L -p "$PID" -o lwp= -o pcpu= --sort=-pcpu | head -1 | awk '{print $1}')
HEX=$(printf "0x%x" "$TID")
echo "TID=$TID -> $HEX"
# 抓一次快照
JSTACK_FILE="/tmp/jstack_${PID}_${i}.txt"
jstack -l "$PID" > "$JSTACK_FILE" 2>/dev/null
# 匹配 nid
echo "----- thread stack -----"
grep -A 30 "nid=$HEX " "$JSTACK_FILE" | sed '/^$/q'
echo "(full dump: $JSTACK_FILE)"
[ $i -lt $TIMES ] && sleep 5
done使用:
chmod +x find-hog.sh
./find-hog.sh 1111 3建议采 3 次以上,看哪个方法反复出现在栈顶——那才是真热点。单次快照有运气成分。
八、常见坑 & FAQ
Q1:top -Hp 里 PID 列为什么是 12205 这种?不是 1111?
top -H 的 -H 是 "Show threads instead of processes",输出里第一列 PID 其实是 LWP(线程 ID)。要不要关掉多核合并?不需要——-H 本身就会把每个线程单独列一行。真正要小心的是打开 -H 后 %CPU 可能显示 >100%(多核),这是正常的,说明该线程用了多个 CPU 时间片,不代表出错。
Q2:jstack 里的 tid 和 nid 有什么区别?
tid:JVM 内部的线程 ID(Java 里Thread.getId()的十六进制),用于关联 Java 对象,不能拿去和 top 对应。nid:操作系统的线程 ID(LWP),十进制就是 top 里看到的那个数,十六进制就是jstack打印出来的。只有 nid 能和 top 对上。
Q3:jstack 报 Unable to open socket file / well-known file is not secure?
通常因为你 不是启动 Java 的那个用户。用 ps -ef | grep 1111 看启动用户,su - thatuser 后再 jstack。或者用 jcmd 1111 Thread.print 替代。
Q4:jstack 输出 Thread XXX: State: RUNNABLE,但代码看不出耗时?
RUNNABLE 也可能是在做 native 调用(JNI、GC、磁盘 IO),或者在等锁(BLOCKED 之外的情况)。这时抓 3 次栈对比,看是不是每次都卡在同一个 at ... 行。真正跑 CPU 的线程栈顶一定是业务/框架代码的某个 at,不会停在 Thread.run 就完事。
Q5:会不会是 GC 导致的 CPU 高?
非常可能。先看 GC:
jstat -gcutil 1111 1000 10如果 FGC(Full GC 次数)一直在涨、GCT(GC 总耗时)占比很高,那就是内存问题导致 GC 线程疯狂跑。这时候 top -Hp 里高 CPU 的可能是 GC task thread#... 或 G1 ... 这类名字的线程,jstack 栈里看不到业务代码。
jstack 里 GC 线程名一般是 GC task thread#0 (ParallelGC) 或 G1 Main Marker 等,看到这个就该去查堆内存/泄漏,而不是查业务代码。
Q6:top 里 12205 对应线程名怎么快速确认?
用 jstack 之外的工具:
# 直接看线程名和 LWP 的对应
jcmd 1111 Thread.print | grep -E "nid=0x2fad|^\""或者干脆用 Arthas 一步到位(见下节)。
九、更高效的替代工具
如果线上允许装工具,下面两个能省掉 90% 手工步骤:
1. Arthas(强烈推荐)
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar # 选择 1111然后:
# 一步到位:列出 CPU 前 3 的线程 + 栈(自动做了十进制转十六进制)
$ thread -n 3
# 直接看某个线程 id
$ thread 12205
# 抓取 CPU 火焰图(配合 async-profiler)
$ profiler start
$ profiler stop --format html不用手动转十六进制,不用怕用户权限,这是它最大的价值。
2. async-profiler(找热点最准)
./profiler.sh -d 30 -f /tmp/cpu.html 1111打开 cpu.html 就是火焰图,热点方法按宽度一眼可见,比读栈快得多,特别适合"知道是哪个类但不知道具体是哪个方法"的场景。
十、总结
整个排查链路可以浓缩成一张图:
监控告警 CPU 高
│
▼
top ──────────────────► 找到 java 进程 PID(1111)
│
▼
top -Hp 1111 ─────────► 找到 %CPU 最高的线程 LWP(12205)
│
▼
printf "%x" 12205 ────► 得到 0x2fad(十进制 → 十六进制)
│
▼
jstack 1111 ──────────► 抓取全部线程栈到文件
│
▼
grep "nid=0x2fad" ────► 匹配到具体线程名 + 栈顶代码行
│
▼
看源码那一行 ─────────► 定位根因(本次是灾难正则)
│
▼
修复 + 复盘 + 加防护核心记忆点:
top看进程,top -Hp看线程;jstack里的nid才是 OS 线程 ID,必须十六进制;- 多抓几次栈对比,别信一次快照;
- 栈顶那个
at xxx.java:NNN就是你要找的那一行; - 工具化(Arthas / async-profiler)能免掉 90% 手工操作,生产环境强烈建议常备。
希望这篇排查记对遇到同类问题的同学有帮助。CPU 打满不可怕,怕的是不知道从哪下手——掌握了这条链路,下次再遇到就能 5 分钟定位到行。
附录:常用命令速查
| 目的 | 命令 |
|---|---|
| 找 CPU 高的进程 | top -c |
| 找 CPU 高的线程 | top -Hp <pid> 或 ps -L -p <pid> -o lwp,pcpu --sort=-pcpu |
| 十进制转十六进制 | printf "0x%x\n" <tid> |
| 抓线程栈 | jstack -l <pid> > /tmp/js.txt |
| 匹配线程 | grep -A 30 "nid=0x<hex> " /tmp/js.txt |
| 看 GC | jstat -gcutil <pid> 1000 10 |
| Arthas 一步到位 | thread -n 3 |
| 火焰图 | ./profiler.sh -d 30 -f cpu.html <pid> |