Java 面试笔记III · 08 / 14

Lesson 31 · JVM 原理与调优

CPU 100% 排查:top → jstack → 代码定位五步法

高级·🔥 极高·#JVM·#调优·#实战

第 1 站

凌晨三点,CPU 100%,服务即将崩溃

"线上服务 CPU 打满了,你怎么排查?"

这是 Java 后端面试中出现频率最高的实战题之一。面试官不是在考你会不会背命令——他要看你有没有真正处理过线上故障。

凌晨三点,手机震动。监控系统弹出告警:

alert.log
[2024-03-15 03:12:07] CRITICAL  host: prod-order-03
CPU usage: 98.7% (sustained 5 min)
Service: order-service  Status: DEGRADED
Active threads: 342   Response P99: 12,400ms

服务降级、用户投诉、老板在群里 @你——你现在有 10 分钟定位问题。慌吗?如果你有一套标准 SOP,就不会慌。

CPU 100% 是线上最常见的故障类型之一,典型诱因包括:

  • 死循环:HashMap 并发扩容导致的环形链表(JDK 7)或业务逻辑 bug
  • 正则灾难:catastrophic backtracking,一个复杂正则让线程卡死
  • GC 抖动:内存不足 → 频繁 Full GC → CPU 全花在垃圾回收上
  • 自旋锁 / 忙等:CAS 竞争激烈,线程反复空转
  • 序列化 / 计算密集:大 JSON 解析、复杂报表计算未做限流

不管根因是什么,排查路径永远是一样的——五步法

top(找进程)→ top -Hp(找线程)→ printf '%x'(转十六进制)→ jstack(抓线程栈)→ 定位代码行
核心原则

CPU 问题的排查思路是"从大到小":进程 → 线程 → 栈帧 → 代码行。每一步都在缩小范围,直到精确定位。

第 2 站

Step 1:top 命令 — 谁在吃 CPU?

SSH 登录服务器,敲下第一个命令:top。这一步的目标很简单——找到 CPU 占用最高的进程

terminal — top
$ top

top - 03:14:22 up 127 days,  4:32,  1 user,
load average: 12.43, 10.87, 8.21
Tasks: 218 total,   3 running, 215 sleeping,   0 stopped

  PID  USER    PR  NI   VIRT    RES    SHR S  %CPU %MEM  COMMAND
 8842  admin   20   0  8.2g   3.1g   18m S  387.2  19.8  java
 1024  root    20   0  412m   82m   12m S    2.1   0.5  nginx
 1536  mysql   20   0  2.1g   1.4g   48m S    1.3   8.7  mysqld
    1  root    20   0   52m    4m    3m S    0.0   0.0  systemd

关键信息一目了然:

字段含义
PID8842CPU 最高的进程 ID
%CPU387.2占用了 387% CPU(4 核机器上约等于打满)
COMMANDjava确认是 Java 进程
load average12.43系统负载远超 CPU 核数(4 核),排队严重
注意 %CPU 的含义

%CPU 是按单核 100% 计算的。一台 8 核机器上,%CPU 最大值是 800%。所以 387% 意味着大约 4 个核被完全占满。如果 load average 远超 CPU 核数,说明有大量线程在排队等 CPU。

确认 PID = 8842 是肇事进程。如有多个 Java 进程,用 ps -ef | grep java 确认是哪个应用。

Step 1 产出

锁定 Java 进程 PID = 8842,确认应用为 order-service。

第 3 站

Step 2:top -Hp PID — 哪个线程在飙?

进程找到了,但一个 Java 进程里可能有几百个线程。我们需要知道具体是哪些线程在消耗 CPU

terminal — top -Hp
$ top -Hp 8842

top - 03:15:47 up 127 days,  4:33,  1 user,
Threads: 342 total,   4 running, 338 sleeping

  PID  USER    PR  NI   VIRT    RES    SHR S  %CPU %MEM  COMMAND
 9127  admin   20   0  8.2g   3.1g   18m R  99.3  19.8  java
 9128  admin   20   0  8.2g   3.1g   18m R  98.7  19.8  java
 9131  admin   20   0  8.2g   3.1g   18m R  96.1  19.8  java
 9004  admin   20   0  8.2g   3.1g   18m S    2.3  19.8  java
 8923  admin   20   0  8.2g   3.1g   18m S    1.1  19.8  java
 8844  admin   20   0  8.2g   3.1g   18m S    0.7  19.8  java

三个线程几乎各占一个核:

  • TID 9127 — CPU 99.3%
  • TID 9128 — CPU 98.7%
  • TID 9131 — CPU 96.1%
PID vs TID

top -Hp 的输出中,PID 列显示的实际上是线程 ID(TID),也叫 LWP(Light Weight Process)。这是 Linux 的一个"历史遗留"——内核把线程视为轻量级进程。

三个线程都在飙,说明可能是同一类请求触发了相同的问题代码。先抓一个 TID = 9127 来分析。

Step 2 产出

锁定高 CPU 线程 TID = 9127(占用 99.3% 单核 CPU)。

第 4 站

Step 3:printf '%x' — 十进制转十六进制

拿到 TID 之后,有一个关键步骤很多人会忘记——把十进制的 TID 转成十六进制。因为 jstack 输出中的线程 ID(nid)用的是十六进制。

"为什么 jstack 要用十六进制?"

因为 JVM 内部使用 native thread ID,也就是操作系统分配的 LWP ID,在 jstack 中以 0x 开头的十六进制形式展示。而 top 显示的是十进制。不转换就对不上号。

terminal — printf 转换
# TID = 9127,转为十六进制
$ printf '%x\n' 9127
23a7

# 验证另外两个线程
$ printf '%x\n' 9128
23a8
$ printf '%x\n' 9131
23ab

记住我们要找的 nid:

TID(十进制)nid(十六进制)CPU 占用
91270x23a799.3%
91280x23a898.7%
91310x23ab96.1%
面试加分项

很多候选人在面试时直接说"用 jstack 看线程",但说不出十进制转十六进制这一步。这个细节是区分"背过八股文"和"真正排查过问题"的分水岭。

Step 3 产出

TID 9127 → nid = 0x23a7,下一步用它去 jstack 里搜索。

第 5 站

Step 4:jstack PID — 抓线程快照

现在用 jstack 导出整个 JVM 的线程栈快照,然后在里面搜索 nid = 0x23a7

terminal — jstack 抓栈 + 搜索
$ jstack 8842 > /tmp/jstack.log       # 无响应时加 -F 强制 dump
$ grep -A 30 'nid=0x23a7' /tmp/jstack.log

搜到了!线程栈信息如下:

jstack output — nid=0x23a7
"http-nio-8080-exec-127" #3842 daemon prio=5 os_prio=0
  java.lang.Thread.State: RUNNABLE
    at java.util.HashMap.resize(HashMap.java:715)
    at java.util.HashMap.putVal(HashMap.java:641)
    at java.util.HashMap.put(HashMap.java:612)
    at com.example.order.service.CacheManager.refreshCache(CacheManager.java:87)
    at com.example.order.service.OrderService.queryOrder(OrderService.java:142)
    at com.example.order.controller.OrderController.getOrder(OrderController.java:56)
    ...

关键信息解读:

字段含义
线程名http-nio-8080-exec-127Tomcat 工作线程,正在处理 HTTP 请求
StateRUNNABLE线程正在执行(不是 BLOCKED / WAITING)
栈顶HashMap.resize()正在做 HashMap 的扩容操作
业务代码CacheManager.java:87第 87 行,刷新缓存时往 HashMap 里 put 数据
多抓几次对比

建议间隔 5-10 秒抓 3 次 jstack,然后 diff 对比。如果同一个线程每次都停在相同位置,基本可以确认是死循环或计算密集操作。如果每次位置不同,可能是大量短请求导致的 CPU 累积。

terminal — 多次采样对比
# 间隔 10 秒抓 3 次,对比是否同一位置
$ jstack 8842 > j1.log; sleep 10; jstack 8842 > j2.log; sleep 10; jstack 8842 > j3.log

$ grep -c 'HashMap.resize' j1.log j2.log j3.log
j1.log:3   j2.log:3   j3.log:3   # 三次都在 resize → 100% 死循环
Step 4 产出

线程 0x23a7 持续停在 HashMap.resize(),状态 RUNNABLE,三次采样不变——确认死循环

第 6 站

Step 5:定位代码行 — 根因分析

栈帧已经告诉我们问题在 CacheManager.java:87。打开代码看看:

CacheManager.java
public class CacheManager {

    // 共享缓存,多个 Tomcat 线程并发读写
    private Map<String, OrderDTO> cache = new HashMap<>();

    public void refreshCache(String orderId) {
        OrderDTO dto = orderDao.findById(orderId);
        // 第 87 行:并发 put → HashMap 内部链表成环 → 死循环!
        cache.put(orderId, dto);  // ← 问题在这里
    }
}

经典 bug:在多线程环境下使用 HashMap。JDK 7 的 HashMap 在并发扩容时会形成环形链表,导致 resize() 死循环。JDK 8 虽然修复了环形链表问题,但并发 put 仍然会导致数据丢失和 size 不一致,触发无限扩容。

修复方案:把 HashMap 换成 ConcurrentHashMap——一行改动,解决线程安全问题。

除了 HashMap 死循环,还有其他几种常见根因(第 7 站有完整速查表),先看两个高频案例:

正则灾难案例

某团队用正则 ^([a-zA-Z0-9_.-]+)@([a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}$ 校验邮箱,遇到超长输入时触发 catastrophic backtracking,CPU 直接打满。修复:简化正则 + 限制输入长度。

GC 导致的 CPU 飙高

jstack 里看到大量 "GC task thread#N" 处于 RUNNABLE,说明 CPU 都花在了垃圾回收上。此时应转向 GC 日志分析,检查 Full GC 频率和堆内存趋势。

Step 5 产出

确认根因:CacheManager.java:87 多线程并发写 HashMap,触发扩容死循环。修复为 ConcurrentHashMap 后问题解决。

第 7 站

完整 SOP 流程 + 自动化监控

把五步法画成流程图,这是你面试时应该能在白板上 30 秒画出来的图:

Step 1: top 找到高 CPU 进程 PID Step 2: top -Hp PID 锁定高 CPU 线程 TID Step 3: printf '%x' TID 十进制 → 十六进制 nid Step 4: jstack PID 搜索 nid,获取线程栈 Step 5: 定位代码行 栈帧 → 源码 → 修复 常见根因 HashMap 并发死循环 正则回溯灾难 GC 抖动(频繁 Full GC) 自旋锁 / CAS 竞争激烈 大对象序列化 / 计算密集 快捷工具 arthas > thread -n 3 一步完成 5 步法! 排查原则:从大到小,逐步缩小范围——进程 → 线程 → 栈帧 → 代码行
图 1 CPU 100% 排查五步法完整流程

常见根因速查表

根因jstack 栈帧特征触发条件修复方案
HashMap 死循环 HashMap.resize / putVal 多线程并发读写 HashMap 换 ConcurrentHashMap
正则回溯 Pattern$NFA / Matcher.find 深层递归 嵌套量词 + 长输入 简化正则 / 限制输入长度
GC 抖动 GC task thread#N RUNNABLE 堆内存不足 / 内存泄漏 调大堆 / 排查泄漏
自旋锁竞争 AbstractQueuedSynchronizer CAS 循环 锁持有时间过长 减小临界区 / 读写锁
大 JSON 序列化 Jackson serialize 深调用栈 返回超大对象 分页 / DTO 裁剪 / 异步

Arthas 一键搞定

如果你觉得五步法太麻烦,Arthas 提供了 thread 命令,一条命令完成全部工作:

terminal — arthas thread
$ java -jar arthas-boot.jar   # 选择 8842

# 一条命令 = top + top -Hp + printf + jstack
[arthas@8842]$ thread -n 3
ID    NAME                        CPU%  STATE
9127  http-nio-8080-exec-127      99.3  RUNNABLE
9128  http-nio-8080-exec-128      98.7  RUNNABLE
9131  http-nio-8080-exec-131      96.1  RUNNABLE

# 查看线程完整栈
[arthas@8842]$ thread 9127
"http-nio-8080-exec-127" Id=9127 RUNNABLE
    at java.util.HashMap.resize(HashMap.java:715)
    at com.example.order.service.CacheManager.refreshCache(CacheManager.java:87)
    ...

Arthas 的 thread -n 3 直接帮你做了 top → top -Hp → 转十六进制 → jstack 四步,省去了手动转换的麻烦。强烈建议线上排查优先使用 Arthas

Arthas 进阶命令

thread -b:找出阻塞其他线程的线程(排查死锁)。thread --state WAITING:过滤特定状态的线程。profiler start:启动 CPU 火焰图采样,直观看到哪些方法最耗时。

自动化监控预警

排查是事后补救,监控才是事前防御。建议配置以下自动化措施:

cpu-monitor.sh — 自动采集脚本(crontab 每分钟执行)
#!/bin/bash
JAVA_PID=$(pgrep -f "order-service")
CPU_USAGE=$(top -bn1 -p $JAVA_PID | tail -1 | awk '{print $9}')

if (( $(echo "$CPU_USAGE > 80" | bc -l) )); then
    TS=$(date +%Y%m%d_%H%M%S)
    for i in 1 2 3; do
        jstack $JAVA_PID > /var/log/cpu-dumps/jstack_${TS}_${i}.log
        sleep 5
    done
    top -bn1 -Hp $JAVA_PID | head -12 > /var/log/cpu-dumps/top_${TS}.log
fi

CPU 飙高时自动留证,再也不用担心"事发突然没抓到现场"。

全站回顾

  1. top — 找到 CPU 最高的 Java 进程(PID)
  2. top -Hp PID — 找到进程内 CPU 最高的线程(TID)
  3. printf '%x' TID — 十进制转十六进制(得到 nid)
  4. jstack PID — 导出线程栈,用 nid 搜索目标线程
  5. 定位代码行 — 根据栈帧定位到具体 Java 文件和行号,分析根因

Arthas 的 thread -n 3 一条命令可以替代前四步。面试时五步法要能脱口而出,实操时优先用 Arthas 提效。