华为云CCE-Java容器OOMKilled排查
华为云 CCE 上 Java 容器反复 OOMKilled:堆只用了 60%,内存却被判定超限
Pod 每天重启几次,
kubectl describe显示OOMKilled、退出码 137。但 JVM 监控里堆内存常年只在 60% 上下,GC 也正常。这篇文章把"容器内存"和"JVM 堆"这两笔账彻底分开算,解释为什么 2 GB 的容器里配 1.5 GB 堆一定会被杀,以及一个连老手都容易忽略的坑:容器里availableProcessors()返回的是宿主机核数。
一、现象:重启,但没有 OOM 异常
排查起点是一个很矛盾的组合:
| 观察项 | 现象 |
|---|---|
| Pod 状态 | 每天重启 2~5 次,RESTARTS 持续增长 |
| 终止原因 | Last State: Terminated, Reason: OOMKilled, Exit Code: 137 |
| JVM 堆监控 | 峰值 60%~65%,远未到上限 |
| 应用日志 | 没有任何 OutOfMemoryError |
| GC 日志 | 正常,Full GC 后能回收 |
| 容器内存限制 | 2 GiB,JVM 参数 -Xmx1536m |
两个关键判断:
- 没有
OutOfMemoryError,说明不是堆溢出。 堆溢出是 JVM 自己抛异常,进程可能还活着;而 137 是被外部杀掉,JVM 根本来不及写日志。 OOMKilled是内核 cgroup 判定的,不是 JVM 判定的。判据是整个容器的内存用量超过了 cgroup 限制,而不是堆用量。
所以问题的本质是:除了堆,容器里还有别的东西在吃内存,加起来超过了 2 GiB。
二、先分清两种 OOM
这两个概念混在一起排查,方向会完全错掉:
| 类型 | 谁判定 | 表现 | 排查方向 |
|---|---|---|---|
| JVM 堆 OOM | JVM | 抛 java.lang.OutOfMemoryError: Java heap space |
堆大小、内存泄漏、对象膨胀 |
| 容器 OOMKilled | 内核 cgroup | 进程被 SIGKILL,退出码 137,无 JVM 日志 | 堆 + 非堆 + 堆外的总量 |
看到 OutOfMemoryError 去查堆;看到 137 且无异常日志,去查容器总内存。
三、JVM 眼里的内存:容器支持的历史包袱
JVM 早期版本读的是 /proc/meminfo,那是宿主机的内存,不是容器的限制。这会导致一个经典事故:
宿主机 64 GB,容器限制 2 GB
老版本 JVM 看到 64 GB → 默认堆取 1/4 = 16 GB
容器一启动就申请 16 GB → 立即 OOMKilled,甚至起不来
现在主流 JDK 都默认开启容器感知(-XX:+UseContainerSupport 默认开启),并且:
-XX:MaxRAMPercentage默认 25%-XX:InitialRAMPercentage默认 1.56%
25% 这个默认值是很多团队困惑的来源:2 GiB 的容器,默认堆上限只有 512 MB。有些团队发现堆不够用,就直接把 -Xmx 拉到 1536m 甚至 1800m,结果反而更容易被 OOMKilled——因为剩下的空间装不下非堆部分。
验证 JVM 到底看到了多少内存,这一条必须实际跑一次,别猜:
# 在容器内执行,看 MaxHeapSize 是否与预期一致
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -Ei 'MaxHeapSize|MaxRAMPercentage'
四、容器内存的完整账本
把 2 GiB 拆开看,堆只是其中一块:
| 组成 | 典型占用 | 说明 |
|---|---|---|
| Java 堆 | 由 -Xmx 决定 |
业务对象 |
| Metaspace | 100~300 MB | 默认无上限,类越多越大 |
| Code Cache | 50~240 MB | JIT 编译产物 |
| 线程栈 | 线程数 × 1 MB | -Xss 默认约 1 MB |
| Direct Buffer | 可达堆大小 | NIO/Netty 直接内存,默认上限等于 -Xmx |
| GC 自身结构 | 堆的 5%~15% | 卡表、标记位图等 |
| JVM 自身 + 本地库 | 50~150 MB | 解释器、符号表、JNI |
| glibc malloc arena | 数十 MB | 多线程下可能膨胀 |
结论:堆最多只能占到容器限制的 60%~75%。 2 GiB 容器配 1536m 堆(75%)已经是上限边缘,再叠上一个 300 MB 的 Metaspace 和 Netty 的直接内存,越界是必然的。
五、三个最常见的真凶
真凶 1:Metaspace 默认无上限
-XX:MaxMetaspaceSize 默认是不限制(受限于本地内存)。这在容器里非常危险:类加载持续增长时,Metaspace 会一直吃内存,直到容器被判定超限。
什么场景会持续增长?动态代理(CGLIB)、脚本引擎反复生成类、热部署、大量反射。Spring 项目 + 动态代理 + 频繁发版,Metaspace 涨到几百 MB 很常见。
真凶 2:Direct Buffer 悄悄占满堆外
NIO 的直接内存(ByteBuffer.allocateDirect)在堆外,JVM 堆监控完全看不到。而它的默认上限是 -XX:MaxDirectMemorySize,不显式指定时约等于 -Xmx。
微服务里用 Netty、gRPC、或者自己做文件/网络传输,直接内存很容易吃掉几百 MB 甚至上 GB。堆看起来很闲,容器却快满了,这是最典型的一种。
真凶 3:容器里 availableProcessors() 返回宿主机核数
这个坑非常隐蔽,而且会连锁引爆内存:
宿主机 64 核,容器 CPU limit = 2 核
旧版 JVM:availableProcessors() = 64
线程池按 64 核配置 → 核心线程数几百个
每个线程栈约 1 MB → 光线程栈就吃掉几百 MB
更糟的是,JVM 的 GC 线程数、ForkJoinPool 并行度也都按这个数字算,进一步放大内存和 CPU 开销。
验证方法:
// 打印 JVM 认为的核数
System.out.println(Runtime.getRuntime().availableProcessors());
如果这个数字远大于容器的 CPU limit,就要显式指定:
-XX:ActiveProcessorCount=2
六、排查:从 cgroup 读到 JVM 内部
1. 确认是不是 cgroup 杀的
# 容器内查看 OOM 计数,非 0 说明被 cgroup 杀过
cat /sys/fs/cgroup/memory.events # cgroup v2
# oom 3
# oom_kill 3 ← 就是它
cat /sys/fs/cgroup/memory/memory.failcnt # cgroup v1
同时看当前用量与上限:
# cgroup v2
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.max
# cgroup v1
cat /sys/fs/cgroup/memory/memory.usage_in_bytes
cat /sys/fs/cgroup/memory/memory.limit_in_bytes
注意:memory.current 包含页缓存(page cache)。容器大量读文件时,这个数字会接近上限,但页缓存是可回收的,不一定导致 OOM。所以判断时要看 memory.stat 里的 anon(匿名页,真正不可回收的部分):
grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat
anon 逼近 memory.max,才是真正的危险信号。
2. 打开 NMT,看 JVM 内部的内存分布
这是定位"堆外到底吃了多少"的最直接手段:
-XX:NativeMemoryTracking=summary
jcmd 1 VM.native_memory summary
输出会按 Java Heap / Class / Thread / Code / GC / Internal / Other 分类列出。哪一项超预期,问题就在哪。 生产环境建议常开 summary 级别(开销约 5%),detail 级别开销较大,只在排查时临时开。
3. 用 jcmd 快速看各区域
jcmd 1 GC.heap_info # 堆使用情况
jcmd 1 VM.metaspace # Metaspace 明细
jcmd 1 Thread.print | grep -c '"' # 线程总数
线程数乘以 1 MB 就是线程栈的粗略占用。如果线程数上千,光这一项就是 1 GB。
七、修复:把账算平
1. 堆按 60%~70% 配,而不是 75%+
2 GiB 容器,推荐 1200m~1300m 堆,留 700~800 MB 给非堆。
-XX:MaxRAMPercentage=60
-XX:InitialRAMPercentage=60
用百分比而不是 -Xmx 绝对值,好处是改容器规格时堆自动跟着变,不用同时改两处。
2. 显式限制每一块非堆内存
# Metaspace 上限,按实际用量留 30% 余量
-XX:MaxMetaspaceSize=256m
# 直接内存上限,按实际使用设,不要留默认(等于堆大小)
-XX:MaxDirectMemorySize=256m
# 线程栈,按业务并发下调
-Xss512k
# 容器内核数对齐 CPU limit
-XX:ActiveProcessorCount=2
3. 用 G1 并显式设好 GC 相关
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
HeapDumpOnOutOfMemoryError 在容器场景下尤其重要:如果哪天真的堆溢出,能在 Pod 被杀前留下现场。注意 dump 目录要挂到持久卷,否则 Pod 重启后 dump 就没了。
4. 资源限制要给得合理
resources:
requests:
memory: 2Gi
cpu: "2"
limits:
memory: 2Gi
cpu: "2"
两个要点:
requests与limits的 memory 设成相同值。如果 requests 远小于 limits,调度器会把内存需求小的 Pod 堆到同一节点,节点实际内存吃紧,触发节点级驱逐,问题更复杂。- CPU 的 limits 不要设成 requests 的很多倍,容器内 JVM 按 CPU limit 算 GC/线程数,但实际 CPU 被节流(throttling)时会表现为"内存正常但响应极慢"。
八、效果
| 指标 | 修复前 | 修复后 |
|---|---|---|
| Pod 重启次数 | 2~5 次/天 | 0 |
oom_kill 计数 |
持续增长 | 不再增长 |
| 堆峰值 | 60%~65%(但容器超限) | 55%~60% |
| Metaspace | 无上限,峰值 480 MB | 上限 256 MB,实际 190 MB |
| 线程数 | 约 1800(按 64 核配置) | 约 220 |
anon 内存 |
贴近 2 GiB | 约 1.3 GiB |
改动全部在启动参数和资源声明里,业务代码零改动。
九、小结
四句话:
OOMKilled+ 退出码 137 + 无OutOfMemoryError,说明是容器超限,不是堆溢出,别再去查堆泄漏。- 堆最多只能占容器内存的 60%~70%,剩下的要留给 Metaspace、直接内存、线程栈和 GC 结构。
availableProcessors()在容器里可能返回宿主机核数,会连锁放大线程数、线程栈和 GC 线程,必须用-XX:ActiveProcessorCount对齐。- 看 cgroup 的
anon而不是memory.current,后者包含可回收的页缓存,会误导判断。
如果只记一句:容器里算内存,永远是"堆 + 非堆 + 堆外"三笔账一起算,只盯堆一定出事。
- 点赞
- 收藏
- 关注作者
评论(0)