半夜的 OOM,却没留下证据
有天凌晨一个服务 OOM 自杀重启,等我早上到公司,堆已经被 GC 一遍、镜像重建了,啥都看不到。这种"看过现场却没拍照"的故障最折磨人,只能凭日志猜是哪块吃内存,结果猜了一上午猜错方向。从那以后我定下规矩:任何 Java 服务 OOM 必须自动留证,不能让现场随重启蒸发。
先让 OOM 自己留证
JVM 加上这两个参数,OOM 时自动落盘堆转储:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps/heap_%p.hprof
-XX:OnOutOfMemoryError="kill -9 %p"
注意 %p 是 PID,避免多次 dump 互相覆盖。dump 文件约为堆上限大小,我们堆设 4G,所以 dump 出来也是 ~4G,得保证磁盘够——曾经有个节点磁盘只有 5G 剩余,dump 没写完就撑爆,反而更糟。所以 -XX:HeapDumpPath 指向的盘至少留 1.2 倍堆大小。
另外,光 dump 还不够。OOM 后进程可能还活着占着资源,OnOutOfMemoryError 里 kill -9 让它在 dump 完立刻退出,避免半死不活拖垮整个 Pod。
自动上传流水线
落盘只是第一步,容器重启后 /data 多半没了(emptyDir 随 Pod 消亡)。我们写了个小脚本,在 OnOutOfMemoryError 里触发:
# on-oom.sh
gzip -1 /data/dumps/heap_$1.hprof &
curl -T /data/dumps/heap_$1.hprof.gz \
ftp://dump-archive/$(hostname)-$1.hprof.gz
更现代的做法是用 sidecar 或把目录挂到 PVC,让 dump 持久化,不依赖上传带宽。我们的 K8s 集群给关键服务挂了 10G 的 PVC 到 /data,OOM 后即便 Pod 重启,PVC 里的 dump 还在,运维登录就能取。
还有个细节:gzip 可能很慢(4G 压到 1.5G 要几十秒),会拖慢 Pod 恢复。我们改成后台压缩 + 先传原始 hprof 再压缩,优先保证现场不丢。
MAT 分析流程
下载 hprof 用 Eclipse MAT 打开,我固定看三个视图:
- Leak Suspects:自动给出怀疑对象,省一半时间;
- Dominator Tree:谁占着大头,按 Retained Heap 排序;
- Histogram + 按包过滤:找业务类实例数为啥这么多,比如 com.xxx.Order 有 80 万个就反常。
上次就是在这里发现一个 ThreadLocal 没 remove,每次请求塞一个 2MB 的 HashMap,2000 并发撑一天就爆了。Histogram 里 ThreadLocalMap 的 retained heap 高得离谱,点进去一看全是这个 HashMap。
把流水线固化成模板
这套配置我们抽成了 Dockerfile 的 ENV 和一个共享的 on-oom.sh,所有新服务直接继承,不用各写各的。CI 里也加了检查:扫描启动参数,缺 HeapDumpOnOutOfMemoryError 的就构建告警。这样不会再出现"某个服务忘了配"的漏洞。
我们还接了 Prometheus + 告警:OOM kill 对应的容器重启事件会触发飞书通知,值班同学能第一时间知道"谁 OOM 了、dump 在哪",而不是等用户投诉。
dump 文件太大怎么办
4G 的 hprof 用 MAT 打开,普通开发机 8G 内存根本扛不住,MAT 自己也要配 -Xmx。我们给分析机配了 16G,MAT 的 -vmargs 里 -Xmx 设到 12G 才勉强打开。另一个办法是用 jmap -histo 先出个直方图(只占文本,几 MB),快速看哪类实例多,确认方向再开全量 MAT。histo 这招在紧急定位时比开 MAT 快得多。
jmap -histo:live <pid> | head -30
除了 OOM 还要盯 GC
OOM 是终局,但很多服务是"慢死"——老年代慢慢涨,Full GC 越来越频繁,RT 越来越高但没真 OOM。我们加了 GC 监控:Full GC 后老年代回收不掉、且频率上升,就是内存泄漏的前兆。Prometheus 里看 jvm_gc_pause_seconds 的 count 和 duration,连续上涨就告警,比等 OOM 早一步。有一次靠这个提前三天发现一个缓存没设上限的 Map,在 OOM 前就修了。
容器环境的特殊处理
容器里 OOM 还有个坑:JVM 的堆上限如果按宿主机内存设,容器被 limit 住时会被 Kubernetes 的 OOMKiller 直接杀,连 HeapDumpOnOutOfMemoryError 都没机会执行。所以必须用 -XX:+UseContainerSupport(JDK 8u191+ 默认开)让 JVM 读容器 limit,再按容器内存设堆(如容器 4G、堆 3G,留 1G 给非堆和系统)。我们曾因没读容器 limit,堆设 4G 但容器 limit 3G,结果被 OOMKiller 静默杀掉,连 dump 都没有,排查半天。容器化后这两个参数必须配对。
从一次误判说起
最早我们以为配了 HeapDumpOnOutOfMemoryError 就万事大吉,结果第一次 OOM 在容器里被 OOMKiller 静默杀(堆按宿主机设大了),连 dump 都没生成。那次让我们补上 UseContainerSupport + 容器内存配对。第二次是 dump 落了但 Pod 重启 PVC 没挂,文件随 emptyDir 没了,又补 PVC。第三次才是真正留住了现场。OOM 留证这条流水线,是我们用三次失败一点点补完整的,现在固化成模板,新服务直接继承,不再交学费。
留证文化的价值
比工具更重要的是"出事必须留现场"这个意识。我们把它写进团队规范:任何中间件、任何服务,OOM 必须 dump、异常必须留日志、事故必须有时间线。OOM 自动 dump 只是其中一环。文化到位了,工具才好落地;工具再好,人不重视也白搭。这套流水线能推广开,靠的是大家认同"现场比面子重要"。
小结
OOM 排查最怕"无现场"。HeapDumpOnOutOfMemoryError + 自动归档 + MAT 三件套,把一次偶发故障变成可复盘的案子。关键细节:dump 盘要够大、OOM 后及时 kill、容器环境用 PVC 持久化。这 pipeline 现在是我们所有 Java 服务的标配,再没出现过"OOM 了却不知道为啥"的窘境。