主题
JVM 跑在 Docker/K8s 里:内存被 OOMKilled 的那些坑
问题
一个 4G 内存 limit 的容器,JVM 堆配了 3G,Metaspace 256M,DirectMemory 设了 512M,加起来也没超过 4G,结果容器还是被 OOMKilled 了。
这种场景在 K8s 生产环境几乎天天有人遇到。问题出在哪?JVM 进程占用的内存远不止你设的那几个参数。堆、Metaspace、DirectMemory、线程栈、JVM 自身代码、Native 内存、JIT 编解码缓存、GC 日志 buffer……这些都是 JVM 进程要的。更关键的是,JDK 8u131 之前,JVM 根本不知道自己在容器里跑,它以为宿主机有多大内存就能用多大。
分析
容器感知分水岭:JDK 8u131 和 8u191
JDK 对容器的支持分三个阶段,知道你在哪个版本决定了你会不会踩坑。
| 阶段 | 版本 | 行为 | 风险 |
|---|---|---|---|
| 盲区 | ≤ JDK 8u121 | 读 /proc/meminfo 看到宿主机内存,-Xmx 默认堆 = 1/4 宿主内存 | 大宿主上堆可以配到 8G+,容器 limit 才 2G,直接 OOMKilled |
| 初步支持 | JDK 8u131+ | 引入 -XX:+UseCGroupMemoryLimitForHeap,手动开启后 JVM 读 cgroup 内存限制 | 不是默认开启,很多人不知道 |
| 默认开启 | JDK 8u191+ | -XX:+UseContainerSupport 默认开,-XX:MaxRAMPercentage 替代 -Xmx 百分比用法 | 堆按容器 limit 的 25% 算,偏低,需要手动调 |
JDK 8u191 之后,JVM 能正确识别容器内存限制,但它给的默认堆大小(容器 limit 的 25%)对在线服务来说太保守,必须手动调。JDK 11+ 行为一致,只是参数名更规范。
内存余量公式:为什么堆配了 3G 还是被杀了
容器内 JVM 进程的总内存占用可以拆成:
总内存 = 堆 (Heap) + 元空间 (Metaspace) + 直接内存 (Direct Memory)
+ 线程栈 (Thread Stacks) + JVM 自身 + Native 内存 + 其他JVM 自身和 Native 内存这部分常被忽略,它不是 JVM 参数能控制的,包括:
- JIT 编解码缓存(CodeCache):默认 240M,-XX:ReservedCodeCacheSize 可调
- GC 相关:G1 的 Card Table、记忆集、Region 元数据,ZGC 的着色指针映射表
- JNI 和 Native 库:NIO 中 epoll 的 fd 关联内存、压缩指针基地址映射
- 线程栈:-Xss1M(默认),200 个线程就是 200M
- JVM 自身:libjvm.so 加载的代码段、类加载相关的 Native 内存
一个经验公式(来自多处生产验证):
堆 ≈ 容器 limit 的 70-75%超出 80% 后,即便堆没打满,Native 内存的波动也可能触发 OOMKilled。容器内存 limit 不是只有堆,要给 JVM 自身和 Native 留够余量。
实战例子:4G limit 容器
堆 (Heap): 2.8G (70%)
Metaspace: 256M
DirectMemory: 256M
线程栈 (200线程): 200M
CodeCache: 240M
JVM 自身 + Native: ~200M
──────────────────────────
合计: ~3.95G → 刚好在 limit 边缘如果把堆配到 3.2G(80%),总内存 ≈ 4.35G,必然被 K8s cgroup OOM Killer 杀掉。
CPU limit 的隐藏坑:GC 线程数不减
JDK 对容器 CPU limit 的感知比内存更晚。JDK 8u191 之后 -XX:+UseContainerSupport 默认开启后,JVM 能正确识别容器 CPU limit,但 ParallelGCThreads 和 CICompilerCount 的默认行为在不同版本有差异。
假如容器 CPU limit = 2 核,但 JVM 拿到的 Runtime.availableProcessors() 是 8(宿主核数),那:
ParallelGCThreads默认 = 8 × 5/8 ≈ 5(G1 的并行线程数)CICompilerCount默认 = 8 线程 2 线程 = 2(JDK 10+ 有自适应)
CPU 争抢的直接后果:GC 线程和业务线程抢 CPU,GC 停顿时间比预期长,业务 RT 恶化。同时 CICompiler 线程数不对,JIT 编译效率下降,方法解释执行时间变长,总体吞吐量掉 10-20%。
验证方法:容器内执行
bash
java -XX:+PrintFlagsFinal -version 2>&1 | grep -E "ActiveProcessorCount|ParallelGCThreads|CICompilerCount"如果 ActiveProcessorCount 不等于容器 limit,说明容器感知有问题。
容器内 jmap/jstack 的 ptrace 权限
K8s 默认的 securityContext 不允许 ptrace(2) 系统调用。这意味着 jmap、jstack、jcmd、Arthas 这些工具在容器内全都跑不了,报 Unable to open socket file 或 Permission denied。
解决方法:
yaml
securityContext:
capabilities:
add: ["SYS_PTRACE"]或者更安全的做法:用 CAP_SYS_PTRACE 的 sidecar 容器做诊断,业务容器不放这个权限。
K8s 探针误杀与 GC 停顿
JVM 在做 Full GC 时,STW 时间可能超过 10 秒。如果此时 readinessProbe 的 initialDelaySeconds 和 periodSeconds 设置不当,K8s 会把容器标记为不健康,摘掉流量甚至重启。
yaml
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30 # 给 JVM 启动足够的预热时间
periodSeconds: 15 # 15 秒一次,避开短 GC 停顿
timeoutSeconds: 5
failureThreshold: 3 # 连续 3 次失败才重启,容忍一次 Full GC一个常见踩坑:failureThreshold: 1 + timeoutSeconds: 2,Full GC 停 6 秒,探针连续超时 2 次就被重启了——重启后堆重新分配,对象又从头开始,GC 更频繁,陷入重启循环。
最佳实践
正确的参数配置模板
bash
# 容器 limit = 4G, CPU = 2
java -Xmx2.8g -Xms2.8g \
-XX:MaxMetaspaceSize=256m \
-XX:MaxDirectMemorySize=256m \
-XX:+UseContainerSupport \
-XX:ActiveProcessorCount=2 \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:+ExitOnOutOfMemoryError \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/dump/heap.bin \
-jar app.jar关键点:
-Xmx按容器 limit 的 70% 配,不是 80% 更不是 90%。4G limit 配 2.8G,8G 配 5.6G,留出 Native 内存余量。ActiveProcessorCount显式设成容器 CPU limit,避免ParallelGCThreads和CICompilerCount默认按宿主核数算。ExitOnOutOfMemoryError遇到 OOM 直接退出进程,不要让 JVM 在 OOM 后继续半死不活地运行,K8s 会自动拉起新 Pod。HeapDumpOnOutOfMemoryError配一个持久化路径的 dump 目录,挂载 PVC 或 hostPath,OOM 后分析用。
JDK 版本选择建议
- JDK 8:必须 ≥ 8u191,否则容器感知缺失。优先 8u202+。
- JDK 11:容器支持默认开,但
MaxRAMPercentage默认 25%,记得调。 - JDK 17+:推荐,容器支持最完善,ZGC 可选,G1 默认。
排查三板斧
- 看是不是内存超了:
kubectl describe pod看Last State: Terminated下的Exit Code: 137(SIGKILL,OOMKilled)。如果 Exit Code 是 143(SIGTERM),是优雅关闭,不是 OOM。 - 看堆占比:容器内
jcmd <pid> VM.native_memory summary(需要开启-XX:NativeMemoryTracking=summary),看 Heap 和 Non-Heap 的分布。 - 看 CPU 感知:
java -XX:+PrintFlagsFinal 2>&1 | grep ActiveProcessorCount,确认等于容器 CPU limit。
总结
JVM 在容器里的坑,根因只有一个:JVM 进程不是只有堆。堆外内存(Metaspace、DirectMemory、线程栈、CodeCache、JVM 自身)加起来轻松超过 1G,在容器 limit 卡死的情况下,堆配多了就会被 OOMKilled,堆配少了又浪费。
核心原则:
- 堆 ≈ 容器 limit 的 70%,别贪
- JDK 8 必须 ≥ 8u191
- ActiveProcessorCount 显式设定
- 容器探针别太死,容忍一次 Full GC 停顿
- 遇到 OOM 先看 Exit Code 137,不是 143