上周三下午,正在摸鱼,突然钉钉群里炸了:
css
[告警] 订单服务 POD重启
[告警] 订单服务 POD重启
[告警] 订单服务 POD重启
3个Pod连续重启,打开监控一看,内存直接打满然后被K8s杀掉了。
经典的OOM。
现象
- 服务:订单服务(Java,Spring Boot)
- 部署:K8s,3个Pod,每个限制4G内存
- 现象:内存缓慢增长,到达4G后被OOM Kill
- 频率:每隔2-3小时重启一次
第一反应:加内存?
领导说:内存不够就加嘛。
我:...加内存治标不治本,得找到根因。
排查过程
1. 先看GC日志
bash
# JVM参数里加上GC日志
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/logs/gc.log
等了一个小时,服务挂了,捞出GC日志:
csharp
[Full GC (Allocation Failure) 3800M->3750M(4096M), 5.234 secs]
[Full GC (Allocation Failure) 3780M->3760M(4096M), 5.567 secs]
[Full GC (Allocation Failure) 3790M->3785M(4096M), 6.012 secs]
Full GC后内存几乎没释放,说明有内存泄漏,有东西一直占着不放。
2. dump内存
在容器里加了个脚本,OOM前自动dump:
bash
# JVM参数
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/logs/heap.hprof
但K8s的OOM Kill太快了,还没来得及dump就被杀了。
换个方式,手动dump:
bash
# 找到Java进程PID
jps
# 手动dump(等内存涨到3G左右时执行)
jmap -dump:format=b,file=/logs/heap.hprof <pid>
dump出来一个3G的文件。
3. 分析heap dump
把文件拷到本地,用MAT(Memory Analyzer Tool)打开。
yaml
Leak Suspects Report:
Problem Suspect 1:
256,789 instances of "com.xxx.OrderDTO"
占用内存:2.1 GB (54%)
25万个OrderDTO对象?这订单量也没这么大啊。
点进去看引用链:
rust
java.util.concurrent.ConcurrentHashMap
-> com.xxx.cache.LocalCache
-> OrderDTO (256789 instances)
LocalCache,本地缓存。
4. 定位代码
java
// LocalCache.java
public class LocalCache {
private static final Map<String, OrderDTO> cache = new ConcurrentHashMap<>();
public static void put(String orderId, OrderDTO order) {
cache.put(orderId, order);
}
public static OrderDTO get(String orderId) {
return cache.get(orderId);
}
// 没有remove方法
// 没有过期机制
// 没有大小限制
}
经典错误:只往缓存里放,不清理。
一查代码提交记录,是3个月前一个同事为了"优化性能"加的,从那之后这个服务就开始间歇性OOM。
5. 修复
方案一:直接删掉这个缓存(最简单)
方案二:换成带过期的缓存
java
// 用Caffeine替代
private static final Cache<String, OrderDTO> cache = Caffeine.newBuilder()
.maximumSize(10000) // 最多1万条
.expireAfterWrite(5, TimeUnit.MINUTES) // 5分钟过期
.build();
public static void put(String orderId, OrderDTO order) {
cache.put(orderId, order);
}
public static OrderDTO get(String orderId) {
return cache.getIfPresent(orderId);
}
上线后,内存稳定在1.5G左右,再也没OOM过。
JVM参数调优
趁这个机会,把JVM参数也优化了一下。
之前的配置
bash
-Xms2g -Xmx4g
# 就这两个参数...
优化后的配置
bash
# 堆内存
-Xms4g -Xmx4g # 初始和最大一样,避免动态调整
-XX:NewRatio=2 # 年轻代:老年代 = 1:2
# GC选择(JDK11+推荐G1)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目标停顿时间200ms
-XX:G1HeapRegionSize=8m # Region大小
# 元空间
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=256m
# GC日志
-Xlog:gc*:file=/logs/gc.log:time,level,tags:filecount=5,filesize=100m
# OOM时dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/logs/
# 容器感知(JDK8u191+)
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=75.0 # 使用容器内存的75%
监控指标
加了几个关键监控:
yaml
# Prometheus指标
- jvm_memory_used_bytes
- jvm_gc_pause_seconds_sum
- jvm_gc_pause_seconds_count
- jvm_classes_loaded_classes_total
设置告警:
- 老年代使用率 > 80% 持续5分钟
- Full GC 频率 > 1次/分钟
- GC停顿时间 > 1秒
排查工具汇总
| 场景 | 工具 | 命令 |
|---|---|---|
| 查看堆内存 | jmap | jmap -heap |
| dump内存 | jmap | jmap -dump:format=b,file=heap.hprof |
| 分析dump | MAT | 图形界面 |
| 实时监控 | jstat | jstat -gcutil 1000 |
| 查看线程 | jstack | jstack |
| 在线分析 | Arthas | dashboard / heapdump |
Arthas神器
推荐用Arthas,在线诊断特别方便:
bash
# 下载启动
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar
# 选择要attach的进程后:
# 查看堆内存
dashboard
# 查看最占内存的对象
heapdump --live /tmp/heap.hprof
# 查看某个类的实例数
sc -d com.xxx.OrderDTO
vmtool --action getInstances --className com.xxx.OrderDTO --limit 10
# 查看方法调用
watch com.xxx.LocalCache put '{params, returnObj}' -x 2
远程调试
这次OOM排查还好在公司,能直接连到服务器。
如果在家收到告警,K8s集群在公司内网,怎么办?
之前的方案是远程专线,但经常断,而且手机上操作kubectl很难受。
后来用星空组网把电脑和跳板机组到一起,在家也能kubectl进容器排查了。
总结
这次OOM排查的经验:
| 步骤 | 工具/方法 |
|---|---|
| 1. 确认OOM | 监控告警、GC日志 |
| 2. dump内存 | jmap / HeapDumpOnOutOfMemoryError |
| 3. 分析dump | MAT / jhat |
| 4. 定位代码 | 引用链分析 |
| 5. 修复上线 | 代码修改 |
| 6. 加固监控 | JVM监控指标 |
常见的内存泄漏原因:
- 缓存无限增长(本次)
- 静态集合持续添加
- 未关闭的连接/流
- ThreadLocal使用不当
- 监听器未注销
最后,写缓存的时候一定要想清楚:
- 什么时候过期?
- 最大存多少?
- 怎么清理?
不然就等着半夜被电话叫醒吧。
有JVM相关问题欢迎评论区交流。