JAVA · Vol.III · DAY 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 之前,先做一个 10 秒的"分诊"——看高 CPU 线程是谁。JVM 进程里的线程分两大类,排查方向完全不同:
| 线程名特征 | 身份 | 含义与排查方向 |
|---|---|---|
http-nio-*-exec-*、pool-*-thread-*、DubboServerHandler- | 业务线程 | CPU 花在业务代码里,按五步法继续定位到代码行 |
GC task thread#N、G1 Conc#N | GC 线程 | CPU 花在垃圾回收上,问题在内存侧 → 转「GC 日志分析」,查 Full GC 频率与堆趋势 |
C1 CompilerThread0、C2 CompilerThreadN | JIT 编译线程 | 热点方法正在被编译,通常是启动或发布后的短暂现象,观察几分钟即可;长期飙高说明热点方法集一直在剧烈变化 |
VM Thread | JVM 内部调度线程 | 负责挂起/恢复线程、协调 GC,业务问题会"投影"到它身上,要继续往下查业务线程 |
这一步的价值在于:如果飙的是 GC 线程,五步法后面的"定位代码行"根本走不通——你该去做的是 GC 日志分析和堆 dump,而不是在 jstack 里找业务栈帧。先分诊、再开刀。
三个线程都在飙,说明可能是同一类请求触发了相同的问题代码。先抓一个 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% 死循环
看栈之前先校准一个认知:RUNNABLE ≠ 运行正常。jstack 里的 RUNNABLE 只表示"没在主动等待",它既包括正常干活,也包括死循环、自旋,甚至执行 native 代码(JNI 调用、Netty 的 epoll 系统调用都显示 RUNNABLE)。所以高 CPU 线程几乎一定是 RUNNABLE——但 RUNNABLE 不代表有问题,关键看栈顶反复出现什么。还有一个容易卡住的场景:高 CPU 的 TID 在 jstack 里搜不到。jstack 只导出 JVM 认识的线程,如果是 native 库自己创建的线程(JNI 注册、本地 C++ 线程),它不在输出里。此时换系统级工具:perf top -t 加上 TID 直接看这个线程在哪些 native 函数上耗时间,或者用 gdb 附加后 thread apply all bt 看 native 栈。另外,如果 JVM 已经假死到 jstack 都无响应,用 jstack -F 强制 dump(走 Serviceability Agent,慢一些但能出结果)。
线程 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 频率和堆内存趋势。
五步法的盲区也要心里有数:jstack 是"采样时线程停在哪"的快照,对"单次调用很短、但调用极其频繁"的方法(热路径上的小方法、序列化框架内部函数)不敏感——每次采样时线程都"恰好"在别处。这种情况要换时间维度的工具:perf record -g -p 加 PID 采 30 秒后生成火焰图,横轴是方法占 CPU 时间的比例,最宽的那条"火焰"就是真凶;经验法则一句话——jstack 回答"线程卡在哪一行",火焰图回答"CPU 时间花在了哪些方法",两者互补,不是替代关系。
确认根因: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 提效。
面试回答模板
"先定位进程,再定位线程:top 找高 CPU 进程的 PID,top -Hp 找高 CPU 线程的 TID,printf 把 TID 转成十六进制,再到 jstack 输出里搜对应的 nid 看栈帧——同一个位置多次采样不变,基本就是死循环或计算密集,从栈帧定位到文件行号。实操中我会直接用 Arthas 的 thread -n 3 一步到位。"
在 30 秒版基础上加五点:① 先看线程名分诊——GC 线程热就去查 GC 日志和堆,JIT 编译线程热多是发布后短暂现象,业务线程热才定位代码;② RUNNABLE 不等于正常,高 CPU 线程几乎必是 RUNNABLE,关键看栈顶反复出现什么;③ jstack 里搜不到的 TID 是 native 线程,换 perf top 或 gdb;④ jstack 对高频短方法不敏感,必要时上火焰图看 CPU 时间分布;⑤ 修复后要做回归验证:压测复现 → 修复 → 再压测对比,并配置 CPU 超 80% 自动留证的监控,防止复发。
高频追问表
| 追问 | 答案要点 |
|---|---|
| 为什么要转十六进制? | jstack 的 nid 是操作系统 LWP ID 的十六进制表示,top 显示十进制,进制不同对不上 |
| jstack 无响应怎么办? | 加 -F 强制 dump(走 Serviceability Agent);仍失败用 pstack / eu-stack 看 native 栈 |
| 高 CPU 线程一定是 RUNNABLE 吗? | 几乎一定——BLOCKED/WAITING 的线程不消耗 CPU;但 RUNNABLE 包含死循环、自旋和 native 调用 |
| JDK 8 的 HashMap 并发 put 还会死循环吗? | 环形链表问题已修复,但并发 put 仍会丢数据、size 不准,极端情况扩容时仍可能死循环;生产一律 ConcurrentHashMap |
| 怎么区分"GC 吃 CPU"和"业务吃 CPU"? | 看线程名(GC task thread)+ GC 日志(Full GC 频率)+ 堆趋势(jstat -gcutil 老年代曲线) |
| 有 Arthas 为什么还要背五步法? | Arthas 内部就是 top + jstack 的自动化封装;面试考的是排查思路(从大到小缩小范围),Arthas 考的是实操效率 |
Comments · 评论