Lesson 31 · JVM 原理与调优
CPU 100% 排查:top → jstack → 代码定位五步法
凌晨三点,CPU 100%,服务即将崩溃
"线上服务 CPU 打满了,你怎么排查?"
这是 Java 后端面试中出现频率最高的实战题之一。面试官不是在考你会不会背命令——他要看你有没有真正处理过线上故障。
凌晨三点,手机震动。监控系统弹出告警:
[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 解析、复杂报表计算未做限流
不管根因是什么,排查路径永远是一样的——五步法:
CPU 问题的排查思路是"从大到小":进程 → 线程 → 栈帧 → 代码行。每一步都在缩小范围,直到精确定位。
Step 1:top 命令 — 谁在吃 CPU?
SSH 登录服务器,敲下第一个命令:top。这一步的目标很简单——找到 CPU 占用最高的进程。
$ 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
关键信息一目了然:
| 字段 | 值 | 含义 |
|---|---|---|
| PID | 8842 | CPU 最高的进程 ID |
| %CPU | 387.2 | 占用了 387% CPU(4 核机器上约等于打满) |
| COMMAND | java | 确认是 Java 进程 |
| load average | 12.43 | 系统负载远超 CPU 核数(4 核),排队严重 |
%CPU 是按单核 100% 计算的。一台 8 核机器上,%CPU 最大值是 800%。所以 387% 意味着大约 4 个核被完全占满。如果 load average 远超 CPU 核数,说明有大量线程在排队等 CPU。
确认 PID = 8842 是肇事进程。如有多个 Java 进程,用 ps -ef | grep java 确认是哪个应用。
锁定 Java 进程 PID = 8842,确认应用为 order-service。
Step 2:top -Hp PID — 哪个线程在飙?
进程找到了,但一个 Java 进程里可能有几百个线程。我们需要知道具体是哪些线程在消耗 CPU。
$ 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%
在 top -Hp 的输出中,PID 列显示的实际上是线程 ID(TID),也叫 LWP(Light Weight Process)。这是 Linux 的一个"历史遗留"——内核把线程视为轻量级进程。
三个线程都在飙,说明可能是同一类请求触发了相同的问题代码。先抓一个 TID = 9127 来分析。
锁定高 CPU 线程 TID = 9127(占用 99.3% 单核 CPU)。
Step 3:printf '%x' — 十进制转十六进制
拿到 TID 之后,有一个关键步骤很多人会忘记——把十进制的 TID 转成十六进制。因为 jstack 输出中的线程 ID(nid)用的是十六进制。
"为什么 jstack 要用十六进制?"
因为 JVM 内部使用 native thread ID,也就是操作系统分配的 LWP ID,在 jstack 中以 0x 开头的十六进制形式展示。而 top 显示的是十进制。不转换就对不上号。
# TID = 9127,转为十六进制
$ printf '%x\n' 9127
23a7
# 验证另外两个线程
$ printf '%x\n' 9128
23a8
$ printf '%x\n' 9131
23ab
记住我们要找的 nid:
| TID(十进制) | nid(十六进制) | CPU 占用 |
|---|---|---|
| 9127 | 0x23a7 | 99.3% |
| 9128 | 0x23a8 | 98.7% |
| 9131 | 0x23ab | 96.1% |
很多候选人在面试时直接说"用 jstack 看线程",但说不出十进制转十六进制这一步。这个细节是区分"背过八股文"和"真正排查过问题"的分水岭。
TID 9127 → nid = 0x23a7,下一步用它去 jstack 里搜索。
Step 4:jstack PID — 抓线程快照
现在用 jstack 导出整个 JVM 的线程栈快照,然后在里面搜索 nid = 0x23a7。
$ jstack 8842 > /tmp/jstack.log # 无响应时加 -F 强制 dump
$ grep -A 30 'nid=0x23a7' /tmp/jstack.log
搜到了!线程栈信息如下:
"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-127 | Tomcat 工作线程,正在处理 HTTP 请求 |
| State | RUNNABLE | 线程正在执行(不是 BLOCKED / WAITING) |
| 栈顶 | HashMap.resize() | 正在做 HashMap 的扩容操作 |
| 业务代码 | CacheManager.java:87 | 第 87 行,刷新缓存时往 HashMap 里 put 数据 |
建议间隔 5-10 秒抓 3 次 jstack,然后 diff 对比。如果同一个线程每次都停在相同位置,基本可以确认是死循环或计算密集操作。如果每次位置不同,可能是大量短请求导致的 CPU 累积。
# 间隔 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% 死循环
线程 0x23a7 持续停在 HashMap.resize(),状态 RUNNABLE,三次采样不变——确认死循环。
Step 5:定位代码行 — 根因分析
栈帧已经告诉我们问题在 CacheManager.java:87。打开代码看看:
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 直接打满。修复:简化正则 + 限制输入长度。
jstack 里看到大量 "GC task thread#N" 处于 RUNNABLE,说明 CPU 都花在了垃圾回收上。此时应转向 GC 日志分析,检查 Full GC 频率和堆内存趋势。
确认根因:CacheManager.java:87 多线程并发写 HashMap,触发扩容死循环。修复为 ConcurrentHashMap 后问题解决。
完整 SOP 流程 + 自动化监控
把五步法画成流程图,这是你面试时应该能在白板上 30 秒画出来的图:
常见根因速查表
| 根因 | 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 命令,一条命令完成全部工作:
$ 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。
thread -b:找出阻塞其他线程的线程(排查死锁)。thread --state WAITING:过滤特定状态的线程。profiler start:启动 CPU 火焰图采样,直观看到哪些方法最耗时。
自动化监控预警
排查是事后补救,监控才是事前防御。建议配置以下自动化措施:
#!/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 飙高时自动留证,再也不用担心"事发突然没抓到现场"。
全站回顾
- top — 找到 CPU 最高的 Java 进程(PID)
- top -Hp PID — 找到进程内 CPU 最高的线程(TID)
- printf '%x' TID — 十进制转十六进制(得到 nid)
- jstack PID — 导出线程栈,用 nid 搜索目标线程
- 定位代码行 — 根据栈帧定位到具体 Java 文件和行号,分析根因
Arthas 的 thread -n 3 一条命令可以替代前四步。面试时五步法要能脱口而出,实操时优先用 Arthas 提效。