线上 Java 项目 CPU 飙升、OOM 排查思路:完整实战流程
凌晨两点,告警群炸了。CPU 打到 99%,或者服务直接 OOM 挂掉,重启后没多久又复现。这种场景每个 Java 后端都躲不掉,区别只在于你是手忙脚乱还是有条不紊。
这篇文章把 CPU 飙升和 OOM 两条排查链路完整走一遍,每一步都给具体命令和操作,拿过去就能用。
一、总体排查思路
先建立全局视角,后面每个场景再深入细节。
markdown
告警触发
│
├── CPU 飙升 → 找到占用 CPU 的进程 → 找到进程内占用 CPU 的线程
│ → 定位到具体代码 → 分析根因 → 修复/止血
│
└── OOM → 确认 OOM 类型 → 拿到 Heap Dump / 分析内存分布
→ 找到内存泄漏点 → 分析根因 → 修复/止血
核心原则:先止血,再排查,最后根治。
止血手段包括:重启、降级、限流、回滚。不要为了排查问题让线上一直挂着------先让服务恢复,再留一台机器做分析。
二、CPU 飙升排查实战
Step 1:找到占用 CPU 最高的进程
bash
# 交互式查看,按 P 按 CPU 排序
top
# 或者一行命令直接拿
ps aux --sort=-%cpu | head -10
记下 PID,比如 18234。
Step 2:找到该进程内占用 CPU 最高的线程
bash
# 查看进程内线程的 CPU 占用
top -Hp 18234
# 或者
ps -Lp 18234 cu | sort -nk3 -r | head -10
输出里会看到线程 ID(LWP 列),比如 18256 占了 95% 的 CPU。
Step 3:线程 ID 转十六进制
Java 线程 dump 里显示的线程 ID 是十六进制,需要转换:
perl
# 十进制 18256 → 十六进制
printf "%x\n" 18256
# 输出:4750
Step 4:抓线程 dump,定位到具体线程
perl
# 抓线程快照
jstack 18234 > /tmp/thread_dump.log
# 直接 grep 定位
jstack 18234 | grep -A 30 "nid=0x4750"
你会看到类似输出:
php
"http-nio-8080-exec-23" #45 daemon prio=5 os_prio=0 tid=0x00007f8a nid=0x4750 runnable
java.lang.Thread.State: RUNNABLE
at com.example.service.OrderService.calculateDiscount(OrderService.java:127)
at com.example.service.OrderService.processOrder(OrderService.java:88)
at com.example.controller.OrderController.create(OrderController.java:45)
到这里已经定位到具体代码行了。 下一步就是分析这段代码为什么疯狂占 CPU。
Step 5:常见 CPU 飙升根因
1. 死循环 / 无限循环
scss
// ❌ 经典 case:边界条件写错
while (list.size() > 0) {
Item item = list.get(0);
process(item);
// 忘了 remove,或者 remove 的是另一个 list
}
2. 正则表达式灾难性回溯(ReDoS)
ini
// ❌ 这个正则遇到超长字符串会指数级回溯
Pattern pattern = Pattern.compile("(a+)+$");
pattern.matcher(userInput).matches(); // userInput = "aaaaaaaaaaaaaaaa!"
3. 频繁 GC(GC 线程本身占 CPU)
用 top 看到的是 GC 线程占 CPU,说明内存不够用,GC 在拼命干活。这时候问题本质是内存问题,跳到 OOM 排查流程。
确认方法:
yaml
# 看 GC 情况
jstat -gcutil 18234 1000 10
如果 FGC 列在快速上涨、FGCT 很大,说明 Full GC 频繁,应用几乎在停顿。
4. 锁竞争 / 大量线程 BLOCKED
线程 dump 里如果看到大量线程卡在同一个锁上:
vbnet
java.lang.Thread.State: BLOCKED (on object monitor)
- waiting to lock <0x00000000f8c3a0b0> (a java.lang.Class)
说明存在严重的锁竞争,比如 synchronized 加在了大方法上,或者用了全局锁。
5. HashMap 并发修改导致链表成环(JDK 7)
多线程并发 put 导致链表成环,get 时死循环。升级 JDK 8+ 用 ConcurrentHashMap 解决。
Step 6:实时观察(arthas 快速定位)
如果服务器上能装 arthas,排查效率会高很多:
bash
# 启动 arthas,attach 到目标进程
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar
# 查看 CPU 占用最高的线程
dashboard
# 查看方法执行耗时
trace com.example.service.OrderService calculateDiscount
# 查看方法调用次数和耗时
monitor -c 5 com.example.service.OrderService processOrder
# 反编译线上代码确认
jad com.example.service.OrderService
三、OOM 排查实战
Step 1:确认 OOM 类型
OOM 不是一个错误,是一堆不同的错误。先看异常信息:
| 错误信息 | 含义 |
|---|---|
java.lang.OutOfMemoryError: Java heap space |
堆内存不够,最常见 |
java.lang.OutOfMemoryError: Metaspace |
类元数据区满了,一般是动态生成类太多 |
java.lang.OutOfMemoryError: GC overhead limit exceeded |
98% 时间在做 GC 但只回收了 <2% 内存 |
java.lang.OutOfMemoryError: Unable to create new native thread |
线程数达到系统上限 |
java.lang.OutOfMemoryError: Direct buffer memory |
NIO 堆外内存溢出 |
java.lang.OutOfMemoryError: Requested array size exceeds VM limit |
试图分配超大数组 |
不同类型排查方向完全不同,下面以最常见的 堆 OOM 为主线。
Step 2:拿到 Heap Dump
最重要的一步。没有 dump 文件,OOM 排查基本靠猜。
方式一:JVM 参数自动生成(推荐,提前配好)
ruby
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/heapdump.hprof
这个一定要在 JVM 启动参数里配好,出问题时自动 dump,不额外增加运行时开销。
方式二:手动抓取(服务还活着时)
perl
# jmap 抓 dump(会 STW,生产慎用,服务会暂停几秒到几十秒)
jmap -dump:format=b,file=/tmp/heap_dump.hprof 18234
# JDK 8+ 推荐用 jcmd,对服务影响更小
jcmd 18234 GC.run
jcmd 18234 GC.heap_dump /tmp/heap_dump.hprof
方式三:容器环境
bash
# k8s 里 copy 出来
kubectl cp <pod-name>:/tmp/heap_dump.hprof ./heap_dump.hprof
# 或者用 ephemeral container
kubectl debug -it <pod-name> --image=openjdk:8 -- jcmd 1 GC.heap_dump /tmp/dump.hprof
Step 3:分析 Heap Dump
工具选择
| 工具 | 特点 |
|---|---|
| Eclipse MAT | 最强大,功能全面,内存占用大 |
| JProfiler | 商业,可视化好 |
| JVisualVM | JDK 自带,轻量,功能一般 |
| arthas heapdump | 线上快速抓,配合分析 |
MAT 分析流程
- 打开 dump 文件
- 看 Leak Suspects Report(MAT 会自动给出疑似泄漏点)
- 看 Dominator Tree(按对象保留内存大小排序)
- 看 Histogram(按类统计实例数和内存占用)
重点关注:
diff
Histogram 里:
- 某个自定义对象实例数异常多(比如几十万个 OrderDTO)
- 占用内存排名前列的是 ArrayList、HashMap、自己写的类
Dominator Tree 里:
- 找到占用内存最大的 GC Root 引用链
- 顺着链看:是谁持有了这些对象没释放
Step 4:常见 OOM 根因
1. 集合类内存泄漏(最常见)
typescript
// ❌ 静态 Map 只放不删
public class CacheHolder {
private static Map<String, Object> cache = new HashMap<>();
public static void put(String key, Object value) {
cache.put(key, value); // 永远不 remove,key 还不停增长
}
}
csharp
// ❌ ThreadLocal 用完没 remove(线程池场景下尤其致命)
private static ThreadLocal<UserContext> contextHolder = new ThreadLocal<>();
public void handleRequest() {
contextHolder.set(new UserContext());
// 处理完没 remove,线程池线程复用,ThreadLocal 里的对象一直挂着
}
csharp
// ✅ 修复:try-finally 确保 remove
public void handleRequest() {
contextHolder.set(new UserContext());
try {
doWork();
} finally {
contextHolder.remove(); // 关键
}
}
2. 大对象 / 一次加载过多数据
scss
// ❌ 一次性查全表
List<Order> orders = orderMapper.selectAll(); // 500 万条,直接 OOM
// ✅ 分页或流式处理
PageHelper.startPage(1, 1000);
List<Order> orders = orderMapper.selectByPage();
// 或者 MyBatis 流式查询
@Select("SELECT * FROM orders")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = 1000)
void selectAll(ResultHandler<Order> handler);
3. 内存泄漏导致 Metaspace OOM
makefile
java.lang.OutOfMemoryError: Metaspace
常见原因:
- 大量使用 CGLIB、ASM、Javaassist 动态生成类(如频繁创建代理对象)
- Groovy 脚本引擎重复解析脚本生成新类
- 热部署框架(如 devtools)反复加载类
排查:
python
# 看类加载数量
jstat -class 18234
# 看 Metaspace 使用
jstat -gc 18234
4. 堆外内存 OOM
arduino
java.lang.OutOfMemoryError: Direct buffer memory
常见原因:
- Netty 的
ByteBuf没 release - NIO
ByteBuffer.allocateDirect()分配后没被 GC(DirectByteBuffer 依赖 Cleaner 机制回收,不及时)
排查:
bash
# 查看堆外内存使用
jcmd 18234 VM.native_memory summary
# 或者
pmap -x 18234 | sort -nk3 -r | head -20
Step 5:不用 dump 的快速排查
如果 dump 文件太大(几十 GB)拿不下来,或者没法 dump,可以用这些手段:
bash
# 实时看堆内存各区域使用
jstat -gcutil 18234 2000
# 输出示例:
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 95.42 88.30 99.87 92.15 89.33 15234 320.5 188 4500.2 4820.7
# ↑ 老年代 99.87%,快满了
# 看对象分布(不用 dump,开销小)
jmap -histo:live 18234 | head -20
jmap -histo:live 会触发 Full GC,生产环境慎用,但信息量很大------直接告诉你内存里什么对象最多。
四、线程问题排查(BLOCKED / 死锁)
有时候 CPU 不高,但服务响应极慢,大概率是线程卡住了。
抓线程 dump 分析
javascript
jstack 18234 > /tmp/thread_dump.log
死锁检测
perl
jstack 18234 | grep -A 20 "deadlock"
或者 jstack 输出末尾会自动检测并报告死锁:
vbnet
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00007f8a (object 0x00000000f8c3a0b0, a java.lang.Object)
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x00007f8b (object 0x00000000f8c3a0c0, a java.lang.Object)
which is held by "Thread-1"
大量线程 WAITING / TIMED_WAITING
php
"http-nio-8080-exec-50" #50 daemon prio=5 ... TIMED_WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolPoolExecutor.java:1067)
如果大量线程卡在等任务,说明线程池配置有问题或者队列满了。
五、容器化环境特殊注意事项
内存限制与 OOM Killer
容器里 Java 进程被 kill,但日志里没有 OOM 异常?大概率是被 Linux OOM Killer 杀了。
perl
# 查看系统日志
dmesg | grep -i "oom|killed"
# 或者
journalctl -k | grep -i oom
输出类似:
sql
[timestamp] Out of memory: Kill process 18234 (java) score 800 or sacrifice child
[timestamp] Killed process 18234 (java), UID 1000, total-vm:8192000kB, anon-rss:4096000kB
原因:JVM 没感知到容器内存限制,堆设大了,加上堆外内存、线程栈等,总内存超过容器 limit,被系统 kill。
解决:
ini
# JDK 8u191+ 支持容器内存感知
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=75.0 # 堆占容器内存的 75%
-XX:InitialRAMPercentage=50.0
# 或者明确指定
-Xmx2g -Xms2g
容器里 jstack / jmap 用不了
lua
# 用 jcmd 替代
jcmd <pid> Thread.print > thread_dump.log
jcmd <pid> GC.heap_dump /tmp/dump.hprof
# k8s 里用 nsenter
kubectl debug -it <pod-name> --image=busybox --target=<container-name>
nsenter -t 1 -m -p -n jcmd 1 Thread.print
六、排查工具箱速查表
| 场景 | 命令 | 说明 |
|---|---|---|
| 找 CPU 最高进程 | top / ps aux --sort=-%cpu |
|
| 找进程内 CPU 最高线程 | top -Hp <pid> |
|
| 线程 ID 转十六进制 | printf "%x\n" <tid> |
|
| 抓线程 dump | jstack <pid> |
|
| 看 GC 状态 | jstat -gcutil <pid> 1000 |
|
| 看类加载 | jstat -class <pid> |
|
| 看对象分布 | jmap -histo:live <pid> |
触发 Full GC |
| 抓 heap dump | jcmd <pid> GC.heap_dump <path> |
推荐 |
| 看堆外内存 | jcmd <pid> VM.native_memory summary |
|
| 看系统 OOM | `dmesg | grep oom` |
| 实时诊断 | arthas dashboard / trace / watch |
最强线上工具 |
七、事前预防(比事后排查更重要)
排查能力是保底,预防才是根本。以下配置建议全部加上:
ruby
# JVM 启动参数模板
java \
-Xms2g -Xmx2g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/data/logs/heapdump.hprof \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-Xloggc:/data/logs/gc.log \
-XX:+UseGCLogFileRotation \
-XX:NumberOfGCLogFiles=5 \
-XX:GCLogFileSize=100M \
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-jar app.jar
再加上:
- 监控告警:CPU > 80% 持续 2 分钟告警,Full GC 频率突增告警
- arthas 常驻:至少留一台机器能随时 attach
- 压测:上线前做容量评估,知道极限在哪里
- Code Review:重点审查集合类使用、ThreadLocal、线程池配置、大对象分配
八、一个完整实战案例
最后用一个真实案例串起来。
现象:服务 CPU 打到 100%,响应超时,重启后 10 分钟复现。
排查过程:
perl
# 1. top 找到 PID 18234
top
# CPU 99%,PID 18234
# 2. 找线程
top -Hp 18234
# 线程 18256 占 98%
# 3. 转十六进制
printf "%x\n" 18256
# 4750
# 4. jstack
jstack 18234 | grep -A 20 "nid=0x4750"
# 定位到:com.example.OrderService.validateOrder:203
# 5. 看代码
jad com.example.OrderService validateOrder
# 发现正则:Pattern.compile("^(A+)+$") 用于校验订单号
# 用户输入了一个超长字符串 "AAAAAAAAAAAA!",触发灾难性回溯
# 6. 止血:紧急回滚 + 修改正则为 ^[A-Z]{1,32}$
根因:正则 ReDoS,一行代码打挂整个服务。
修复:改用简单校验 + 长度限制,加输入校验白名单。
总结
排查线上问题的核心心法就三句话:
- 先止血再排查------重启、限流、回滚,别让问题扩大
- 工具比经验可靠------top、jstack、jstat、jcmd、arthas,数据说话
- 预防比排查重要------HeapDumpOnOutOfMemoryError、GC 日志、监控告警,这些配置不花钱但能救命