一个服务在 K8s 里跑得好好的,突然被重启,看 Pod 事件只有一行OOMKilled。我第一反应是"堆不够了",加了-Xmx之后反而重启更频繁。后来才明白,我搞混了两种 OOM。
两个 OOM 不是一回事
JVM 的堆 OOM(java.lang.OutOfMemoryError: Java heap space)是 JVM 自己抛的,进程还在,可以触发 dump、留日志。而 K8s 的 OOMKilled 是 Linux 内核的 cgroup 内存上限触发的,直接发 SIGKILL,进程瞬间没了,连 GC 日志都来不及写。我们的服务就是后者——容器总内存超了 limit,被内核杀掉,跟堆大小其实没直接关系。
容器里 JVM 看不见真实内存
旧版 JDK 在容器里默认读的是宿主机的物理内存,而不是 cgroup 的 limit。比如宿主机 64G,容器 limit 是 2G,JVM 按 64G 去算堆,直接把容器撑爆。JDK 8u191+ 和 JDK 11+ 才默认开启 UseContainerSupport,认 cgroup。我们确认过:
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -i containersupport
# bool UseContainerSupport = true
用 MaxRAMPercentage 代替写死 Xmx
以前我们写死 -Xmx1536m,容器 limit 一改就得跟着改,经常忘。换成按百分比:
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=50.0
-XX:MinRAMPercentage=50.0
含义是:堆最多用容器 limit 的 75%。limit 2G 时堆约 1.5G,剩下 512M 留给元空间、线程栈、直接内存和 OS。注意 MaxRAMPercentage 只控制堆,非堆部分(尤其是堆外直接内存 -XX:MaxDirectMemorySize 和线程栈)不在它之内,这部分超了照样 OOMKilled。
一次真实的配比
| 项 | 分配 | 说明 |
|---|---|---|
| 容器 limit | 2Gi | K8s requests/limits |
| 堆 MaxRAMPercentage=75% | 约 1.5Gi | 年轻代+老年代 |
| 元空间 MaxMetaspaceSize | 256m | 防类加载泄漏 |
| 直接内存 MaxDirectMemorySize | 128m | NIO/Netty 用 |
| 线程栈 ThreadStackSize×线程数 | 约 120m | 虚拟线程后大幅降 |
加起来约 2G,留了 0 缓冲——后来我们把 MaxRAMPercentage 降到 70%,给 OS 和页缓存腾出 100M 余量,OOMKilled 消失。
怎么确认是哪种 OOM
# 看 Pod 事件
kubectl describe pod xxx | grep -A3 -i oom
# 看是否被 cgroup 杀
dmesg | grep -i "killed process"
# JVM 侧是否留了堆 dump
ls -lh /path/to/*hprof
有 hprof 文件的是堆 OOM;只有 OOMKilled 事件、无 dump 的,是 cgroup 层面的总内存超限。诊断方向完全不同:前者调 GC/找内存泄漏,后者调容器 limit 或非堆参数。
小结
容器里的 JVM 内存配置,核心是"让堆占比 + 非堆 + OS"不超过 limit。写死 -Xmx 容易和 limit 脱节,用 MaxRAMPercentage 更稳,但别忘了直接内存和线程栈这两块堆外大户。看到 OOMKilled 别急着加堆,先分清是内核杀的还是 JVM 抛的,方向错了越调越坏。JDK 21 上虚拟线程把线程栈开销压下来后,这层预算更好算,但直接内存该留还得留。