前言
凌晨两点,告警群炸了。cpu 打到 99%,或者服务直接 oom 挂掉,重启后没多久又复现。这种场景每个 java 后端都躲不掉,区别只在于你是手忙脚乱还是有条不紊。
这篇文章把 cpu 飙升和 oom 两条排查链路完整走一遍,每一步都给具体命令和操作,拿过去就能用。
一、总体排查思路
先建立全局视角,后面每个场景再深入细节。
告警触发
│
├── cpu 飙升 → 找到占用 cpu 的进程 → 找到进程内占用 cpu 的线程
│ → 定位到具体代码 → 分析根因 → 修复/止血
│
└── oom → 确认 oom 类型 → 拿到 heap dump / 分析内存分布
→ 找到内存泄漏点 → 分析根因 → 修复/止血
核心原则:先止血,再排查,最后根治。
止血手段包括:重启、降级、限流、回滚。不要为了排查问题让线上一直挂着——先让服务恢复,再留一台机器做分析。
二、cpu 飙升排查实战
step 1:找到占用 cpu 最高的进程
# 交互式查看,按 p 按 cpu 排序 top # 或者一行命令直接拿 ps aux --sort=-%cpu | head -10
记下 pid,比如 18234。
step 2:找到该进程内占用 cpu 最高的线程
# 查看进程内线程的 cpu 占用 top -hp 18234 # 或者 ps -lp 18234 cu | sort -nk3 -r | head -10
输出里会看到线程 id(lwp 列),比如 18256 占了 95% 的 cpu。
step 3:线程 id 转十六进制
java 线程 dump 里显示的线程 id 是十六进制,需要转换:
# 十进制 18256 → 十六进制 printf "%x\n" 18256 # 输出:4750
step 4:抓线程 dump,定位到具体线程
# 抓线程快照 jstack 18234 > /tmp/thread_dump.log # 直接 grep 定位 jstack 18234 | grep -a 30 "nid=0x4750"
你会看到类似输出:
"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. 死循环 / 无限循环
// ❌ 经典 case:边界条件写错
while (list.size() > 0) {
item item = list.get(0);
process(item);
// 忘了 remove,或者 remove 的是另一个 list
}
2. 正则表达式灾难性回溯(redos)
// ❌ 这个正则遇到超长字符串会指数级回溯
pattern pattern = pattern.compile("(a+)+$");
pattern.matcher(userinput).matches(); // userinput = "aaaaaaaaaaaaaaaa!"
3. 频繁 gc(gc 线程本身占 cpu)
用 top 看到的是 gc 线程占 cpu,说明内存不够用,gc 在拼命干活。这时候问题本质是内存问题,跳到 oom 排查流程。
确认方法:
# 看 gc 情况 jstat -gcutil 18234 1000 10
如果 fgc 列在快速上涨、fgct 很大,说明 full gc 频繁,应用几乎在停顿。
4. 锁竞争 / 大量线程 blocked
线程 dump 里如果看到大量线程卡在同一个锁上:
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,排查效率会高很多:
# 启动 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 参数自动生成(推荐,提前配好)
-xx:+heapdumponoutofmemoryerror -xx:heapdumppath=/data/logs/heapdump.hprof
这个一定要在 jvm 启动参数里配好,出问题时自动 dump,不额外增加运行时开销。
方式二:手动抓取(服务还活着时)
# 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
方式三:容器环境
# 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(按类统计实例数和内存占用)
重点关注:
histogram 里: - 某个自定义对象实例数异常多(比如几十万个 orderdto) - 占用内存排名前列的是 arraylist、hashmap、自己写的类 dominator tree 里: - 找到占用内存最大的 gc root 引用链 - 顺着链看:是谁持有了这些对象没释放
step 4:常见 oom 根因
1. 集合类内存泄漏(最常见)
// ❌ 静态 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 还不停增长
}
}
// ❌ threadlocal 用完没 remove(线程池场景下尤其致命)
private static threadlocal<usercontext> contextholder = new threadlocal<>();
public void handlerequest() {
contextholder.set(new usercontext());
// 处理完没 remove,线程池线程复用,threadlocal 里的对象一直挂着
}
// ✅ 修复:try-finally 确保 remove
public void handlerequest() {
contextholder.set(new usercontext());
try {
dowork();
} finally {
contextholder.remove(); // 关键
}
}
2. 大对象 / 一次加载过多数据
// ❌ 一次性查全表
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
java.lang.outofmemoryerror: metaspace
常见原因:
- 大量使用 cglib、asm、javaassist 动态生成类(如频繁创建代理对象)
- groovy 脚本引擎重复解析脚本生成新类
- 热部署框架(如 devtools)反复加载类
排查:
# 看类加载数量 jstat -class 18234 # 看 metaspace 使用 jstat -gc 18234
4. 堆外内存 oom
java.lang.outofmemoryerror: direct buffer memory
常见原因:
- netty 的
bytebuf没 release - nio
bytebuffer.allocatedirect()分配后没被 gc(directbytebuffer 依赖 cleaner 机制回收,不及时)
排查:
# 查看堆外内存使用 jcmd 18234 vm.native_memory summary # 或者 pmap -x 18234 | sort -nk3 -r | head -20
step 5:不用 dump 的快速排查
如果 dump 文件太大(几十 gb)拿不下来,或者没法 dump,可以用这些手段:
# 实时看堆内存各区域使用 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 分析
jstack 18234 > /tmp/thread_dump.log
死锁检测
jstack 18234 | grep -a 20 "deadlock"
或者 jstack 输出末尾会自动检测并报告死锁:
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
"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 杀了。
# 查看系统日志 dmesg | grep -i "oom|killed" # 或者 journalctl -k | grep -i oom
输出类似:
[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。
解决:
# jdk 8u191+ 支持容器内存感知 -xx:+usecontainersupport -xx:maxrampercentage=75.0 # 堆占容器内存的 75% -xx:initialrampercentage=50.0 # 或者明确指定 -xmx2g -xms2g
容器里 jstack / jmap 用不了
# 用 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 | 最强线上工具 |
七、事前预防(比事后排查更重要)
排查能力是保底,预防才是根本。以下配置建议全部加上:
# 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 分钟复现。
排查过程:
# 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 日志、监控告警,这些配置不花钱但能救命
到此这篇关于线上java项目cpu飙升、oom排查与解决的整实战流程的文章就介绍到这了,更多相关java项目cpu飙升与oom内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论