返回首页

JVM 容器 OOM 排障指南 — 堆外内存视角

📅 创建于 2026-05-29 🔄 更新于 2026-05-29 📝 622 字

来源:蹦跶的小羊 | 发布日期:2026-05-21

JVM 容器 OOM 排障指南 — 堆外内存视角

JVM 堆只用了 70%,容器却被 OOMKilled?核心原因是容器(cgroup)看到的是进程所有 RSS,而监控面板通常只展示 JVM 堆内存。堆外内存(DirectByteBuffer、Metaspace、线程栈等)悄悄吃满额度才是元凶。

核心问题:两个内存视角的不对称

Java 服务在容器中频繁 OOMKilled,但 JVM 堆内存利用率仅 70% 左右、看似充裕,这是最常见的「容器 OOM 但堆没问题」故障模型。

JVM 眼里的内存

JVM 进程内存
 ├── Heap(堆)              ← -Xmx 控制,GC 管理
 │     ├── Young Generation
 │     └── Old Generation
 ├── Metaspace               ← 类元数据,-XX:MaxMetaspaceSize 控制
 ├── Code Cache              ← JIT 编译后的本地代码
 ├── Thread Stacks           ← 每个线程一份,-Xss 控制单线程大小
 ├── Direct Memory           ← NIO 的 DirectByteBuffer,-XX:MaxDirectMemorySize 控制
 ├── GC 自身的元数据         ← 卡表、记忆集、forwarding table 等
 ├── JNI / Native 分配       ← 调用 native 库时的分配
 └── Symbol Table / String Table 等

监控面板上看到的「JVM 堆 70%」只是 Heap 这一格。其余部分默认不展示,但都实实在在地占着进程内存。

容器(cgroup)眼里的内存

cgroup 统计的是整个 cgroup 下所有进程的所有内存映射,它不知道什么叫堆、什么叫 Metaspace:

cgroup.memory.current = RSS(常驻物理内存)
                      + Page Cache(文件读写缓存,会计入!)
                      + Kernel Memory(slab、socket buffer 等)

两视角叠加

容器 limits: 2Gi
└── cgroup 看到的 RSS
    └── JVM 进程 RSS = 1.4G(堆,70% 利用率)
                    + 300M(Metaspace + CodeCache)
                    + 400M(DirectByteBuffer)
                    + 100M(线程栈,500 线程 × 256K)
                    + 其他杂项
                    ──────────────
                    ≈ 2.2G  ← 超过 2Gi,OOMKilled

堆外内存四大「暗坑」

坑一:DirectByteBuffer(NIO 直接内存)

出现场景: Netty、gRPC、Kafka client、RocketMQ client、任何基于 NIO 的网络框架。

释放机制陷阱: DirectByteBuffer 的物理内存释放依赖于对应 Java 对象被 GC 回收时触发 Cleaner 去执行 free。隐含两个前提:

  1. 必须有足够的 GC 压力 — 堆大且 Full GC 少触发时,已用完的 DirectByteBuffer 对象长期挂在老年代,堆外内存就不释放
  2. Full GC 不能太慢 — 即使触发了,从 Cleaner 入队到真正执行 free 之间有时间窗口

排查命令:

# 查看 DirectBuffer 当前占用量
jcmd <PID> VM.native_memory summary | grep "Internal"
# 或通过 JMX
jconsole 或 JMC → Memory → BufferPool -> DirectBuffer

建议方案:

  • 显式设置 -XX:MaxDirectMemorySize(默认等于 -Xmx,非常危险)
  • 应用层 ByteBuffer 池化(Netty 的 PooledByteBufAllocator 就是为此设计)
  • 监控 DirectBuffer 总量并设告警

坑二:Metaspace 持续膨胀

出现场景: 动态类加载(CGLIB 代理、反射生成、Groovy 脚本、GraalVM 多语言)、热部署、多租户框架。

特点: Metaspace 的增长通常是单向的——JVM 几乎不主动回收已加载但不再使用的类元数据。

排查命令:

# 查看 Metaspace 使用量
jstat -gc <PID> | awk '{print $1, $9, $10}'
# 或 NMT
jcmd <PID> VM.native_memory summary | grep "Metaspace"

建议方案:

  • 显式设置 -XX:MaxMetaspaceSize(推荐 256M-512M)
  • 排查类加载泄漏:-XX:+TraceClassLoading -XX:+TraceClassUnloading

坑三:线程栈累积

出现场景: 线程池过大、线程泄漏(未捕获异常导致线程重创但旧线程未释放)、阻塞 I/O 导致线程堆积。

占用计算: 每线程栈默认 1M(Linux x64)或 256K(容器内用 -Xss256k)。500 个线程 × 256K = 128M。如果没用 -Xss 缩小,500 × 1M = 500M。

排查命令:

# 查看线程数
jstack <PID> | wc -l
top -H -p <PID> | wc -l
# 缩小线程栈后必须额外设置内核限制
ulimit -s 256

坑四:GC 元数据 + Native 分配

包括: GC 的卡表(Card Table)、记忆集(Remembered Set)、GC forwarding table;以及 glibc 分配器内部碎片(jemalloc 可改善)。

排查命令:

# NMT 大类
jcmd <PID> VM.native_memory summary scale=MB
# 重点关注 Internal + Other 类别

排障工具箱

NMT(Native Memory Tracking)

Java 8u40+ / Java 9+ 内置,最核心的堆外内存诊断工具:

# 启动时开启(会产生 ~5% 性能开销)
-XX:NativeMemoryTracking=summary  # 或 detail

# 运行时查看
jcmd <PID> VM.native_memory summary
jcmd <PID> VM.native_memory summary scale=MB

# 做 baseline 对比(定位泄漏)
jcmd <PID> VM.native_memory baseline
# 过一段时间后
jcmd <PID> VM.native_memory summary.diff

cgroup 内存统计

# cgroup v2(主流)
cat /sys/fs/cgroup/memory.current        # 当前用量
cat /sys/fs/cgroup/memory.max           # 上限
cat /sys/fs/cgroup/memory.stat          # 详细分类(page cache 等)

# 查看进程实际 RSS
cat /proc/1/status | grep VmRSS

其他辅助

# 查看系统 OOM 记录
dmesg | grep -i "out of memory" | tail -10

# 查看容器 Exit Code
kubectl describe pod <pod> | grep -E "State|Exit|OOM"

最佳实践:参数配置清单

# 推荐的 Java 容器启动参数(Java 10+)
JAVA_OPTS="
  -Xmx <容器 limit 的 60-70%>     # 比传统 75-80% 更保守,留空间给堆外
  -XX:MaxMetaspaceSize=256m
  -XX:MaxDirectMemorySize=256m
  -Xss256k                         # 减少线程栈占用
  -XX:+UseContainerSupport         # Java 10+ 默认开启,确认即可
  -XX:NativeMemoryTracking=summary # 生产可开启,开销 ~5%
  -XX:+PrintClassHistogram         # 可选
"

堆外总预算估算公式:

堆外预算 = 容器 memory.limit - Xmx
         - 安全余量(建议 10-15% 的 limit)

典型分布(2Gi 容器, -Xmx1200m):
  堆外预算 ≈ 800M
    ├─ Metaspace:          ∼200M
    ├─ Direct Memory:      ∼200M
    ├─ Thread Stacks:      ∼100M (400 线程 × 256K)
    ├─ Code Cache + GC:    ∼100M
    └─ glibc 碎片 + 余量:  ∼200M

关联页面

页面关联点
docker-production-pitfallsDocker 容器 OOMKilled 排查(坑四)
k8s-resource-limits-configurationK8s 资源限制与 QoS / oom_score_adj 机制
linux-memory-management-deep-diveLinux 内存管理(OOM Killer 三级回收)
server-performance-four-dimensions服务器性能排查四维法(含 Java 内存提示)
linux-api-performance-tuning-case-studyJVM 容器 cgroup 内存调优案例
k8s-capacity-planning-qos-cost-optimizationK8s 容量规划方法论与成本优化 — 从流量到资源预算的完整框架,含 QoS 策略、弹性伸缩协同、落
k8s-java-memory-tuning-production-guideKubernetes 下 Java 内存调优完整指南 — 内存预算模型、生产参数配置、四层诊断流程、
resource-rbac-scheduling-troubleshootingK8s 资源配额/OOMKilled 排障