Skip to content

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,但 ParallelGCThreadsCICompilerCount 的默认行为在不同版本有差异。

假如容器 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) 系统调用。这意味着 jmapjstackjcmdArthas 这些工具在容器内全都跑不了,报 Unable to open socket filePermission denied

解决方法:

yaml
securityContext:
  capabilities:
    add: ["SYS_PTRACE"]

或者更安全的做法:用 CAP_SYS_PTRACE 的 sidecar 容器做诊断,业务容器不放这个权限。

K8s 探针误杀与 GC 停顿

JVM 在做 Full GC 时,STW 时间可能超过 10 秒。如果此时 readinessProbe 的 initialDelaySecondsperiodSeconds 设置不当,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,避免 ParallelGCThreadsCICompilerCount 默认按宿主核数算。
  • ExitOnOutOfMemoryError 遇到 OOM 直接退出进程,不要让 JVM 在 OOM 后继续半死不活地运行,K8s 会自动拉起新 Pod。
  • HeapDumpOnOutOfMemoryError 配一个持久化路径的 dump 目录,挂载 PVC 或 hostPath,OOM 后分析用。

JDK 版本选择建议

  • JDK 8:必须 ≥ 8u191,否则容器感知缺失。优先 8u202+。
  • JDK 11:容器支持默认开,但 MaxRAMPercentage 默认 25%,记得调。
  • JDK 17+:推荐,容器支持最完善,ZGC 可选,G1 默认。

排查三板斧

  1. 看是不是内存超了kubectl describe podLast State: Terminated 下的 Exit Code: 137(SIGKILL,OOMKilled)。如果 Exit Code 是 143(SIGTERM),是优雅关闭,不是 OOM。
  2. 看堆占比:容器内 jcmd <pid> VM.native_memory summary(需要开启 -XX:NativeMemoryTracking=summary),看 Heap 和 Non-Heap 的分布。
  3. 看 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

参考

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1