线上 Java 项目 CPU 飙升、OOM 排查思路:完整实战流程

线上 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 分析流程
  1. 打开 dump 文件
  2. Leak Suspects Report(MAT 会自动给出疑似泄漏点)
  3. Dominator Tree(按对象保留内存大小排序)
  4. 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,一行代码打挂整个服务。

修复:改用简单校验 + 长度限制,加输入校验白名单。


总结

排查线上问题的核心心法就三句话:

  1. 先止血再排查------重启、限流、回滚,别让问题扩大
  2. 工具比经验可靠------top、jstack、jstat、jcmd、arthas,数据说话
  3. 预防比排查重要------HeapDumpOnOutOfMemoryError、GC 日志、监控告警,这些配置不花钱但能救命
相关推荐
学编程就要猛1 小时前
流式编程及Spring中SSE实现
java·后端·spring·流式编程
wechatbot8881 小时前
SpringBoot Vue 企业微信多账号托管|扫码登录 代理 IP 消息回调
大数据·后端·微信·企业微信·ai编程
不才不才不不才2 小时前
Spring 源码系列(27): @Transactional 七大失效场景与源码归因
java·后端·spring
妙码生花2 小时前
PHP 各框架下和 Go 的性能比较
前端·后端·go
名字还没想好☜2 小时前
Python 字符编码实战:encode/decode、UnicodeDecodeError 与 open 的 encoding 坑
开发语言·后端·python·编程语言
大勇前进2 小时前
Java 线程创建的 4 种方式,优缺点对比,开发推荐写法
后端
明月_清风2 小时前
Foundry Web3 测试框架入门:从 0 开始写 Solidity 测试
后端·web3·solidity
万少2 小时前
等不到 Apple 的折叠 iPhone,我用 DeepV4.1Flash + workBuddy 一句话自己造了一台
前端·javascript·后端
卷无止境2 小时前
AI Agent编程中,重构节奏与安全检查的门道
后端·python