线上告警:某服务 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 — 需要 bc
  • python3 -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 请求线程)
#38JVM 内的线程编号
daemon prio=5守护线程、优先级
tid=0x00007f3a5c1e8000JVM 内部线程 ID(注意不是 OS 的!别拿这个去 top 里找)
nid=0x2fad操作系统线程 ID(LWP),十六进制,这才是和 top 对应的
runnable线程状态,RUNNABLE 说明在跑或准备跑
java.lang.Thread.State: RUNNABLEJava 层线程状态
[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;
}

发现两个问题:

  1. Pattern.matches(regex, input) 每次都重新编译正则——正则编译是 CPU 密集操作。
  2. 更致命的是: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"  ────►  匹配到具体线程名 + 栈顶代码行
     │
     ▼
看源码那一行  ─────────►  定位根因(本次是灾难正则)
     │
     ▼
修复 + 复盘 + 加防护

核心记忆点:

  1. top 看进程,top -Hp 看线程;
  2. jstack 里的 nid 才是 OS 线程 ID,必须十六进制;
  3. 多抓几次栈对比,别信一次快照;
  4. 栈顶那个 at xxx.java:NNN 就是你要找的那一行;
  5. 工具化(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
看 GCjstat -gcutil <pid> 1000 10
Arthas 一步到位thread -n 3
火焰图./profiler.sh -d 30 -f cpu.html <pid>
最后修改:2026 年 10 月 22 日
如果觉得我的文章对你有用,请随意赞赏