本文摘要:堆上限只占容器六成,用量不足六成时服务仍被 OOM 杀掉。对账 NMT 与 cgroup 数据,可拆出元数据、线程栈与页缓存三处缺口。
一、问题与结论
线上一套 Spring Boot 服务跑在 Kubernetes 上,JDK 17.0.x、G1、cgroup v2,resources.limits.memory是2.5Gi,JVM 参数为-Xmx1.5g -XX:MaxMetaspaceSize=256m -XX:ReservedCodeCacheSize=128m。运行约两小时后 Pod 变成OOMKilled,kubectl describe pod只给出Reason: OOMKilled,应用日志里没有OutOfMemoryError,GC 日志显示堆峰值 900MB,不到-Xmx1.5g的六成。
-Xmx只给 Java 堆封顶,cgroup 按整个进程组占用的物理页计费,两者量的不是同一批内存。差额落在三个缺口:
- 元数据与代码:Metaspace、Code Cache 不受
-Xmx约束,CGLIB 代理与多层注解处理会把 Metaspace 推到上百 MB。 - 线程栈:每条平台线程默认 1MB 栈,280 条线程即 280MB,这笔账堆报表看不到。
- 页缓存与碎片:fat jar 解压、日志写入产生 page cache,glibc 多 arena 的碎片不归还 OS,两者都进
memory.current,却不出现在 JVM 的内存报表里。
账目恒等式:memory.current ≈ 堆 committed + Metaspace + Code Cache + 线程数×栈深 + GC 元数据 + file 页缓存 + sock/shmem。
二、排查与选择依据
先让 JVM 自报(NMT),再读 cgroup 计费,把差额归到具体缺口,才知道该调参数还是调限额。容器内对账命令(<pid>为 JVM 进程号):
# 启动时开启 NMT,summary 模式常驻开销较小java-XX:NativeMemoryTracking=summary-Xmx1.5g-XX:MaxMetaspaceSize=256m-XX:ReservedCodeCacheSize=128m MyApp jcmd<pid>VM.native_memory summary jcmd<pid>VM.native_memory baselinecat/sys/fs/cgroup/memory.currentgrep-E'^(anon|file|shmem|sock) '/sys/fs/cgroup/memory.stat那次故障的jcmd输出(committed):Java Heap 1500MB、Class 256MB、Code 110MB、Thread 290MB、GC 85MB、Internal 48MB,合计约 2400MB;memory.current却是 2500MB,差额约 100MB 正对memory.stat的file。堆再往 1.5G 增长,2.5Gi 就守不住了。
判断依据:NMT 中 Class + Thread + GC 三项之和超过限额 40% 时,问题在 JVM 侧,优先收元数据、降线程;NMT 合计远低于memory.current时,问题在页缓存与碎片,优先改 IO 行为或提限额。
三、替代方案与取舍
| 做法 | 选择条件 | 代价 | 边界 |
|---|---|---|---|
逐区设上限(-Xmx+MaxMetaspaceSize+ReservedCodeCacheSize+-Xss) | 参数可控、线程数稳定 | 堆变小、GC 更频繁;改线程池要重算预算 | 堆需求刚性、对 GC 敏感的重计算服务不该用 |
直接提高limits.memory | 节点资源充裕、已知真实 RSS | 调度密度下降、成本上升 | 内存泄漏或页缓存无界增长时只是推迟被杀 |
降线程 +-Xss512k | IO 密集、调用链浅 | 并发下降,深递归可能StackOverflowError | 递归解析类老代码不适用 |
| JDK 21 虚拟线程或 GraalVM Native Image | 可升级运行时或可 AOT 构建 | 升级与构建成本,synchronized pinning、反射受限 | 依赖运行时字节码生成的应用不适用 |
四、关键原理
JDK 17 各区域独立计费,-Xmx只覆盖第一行:
| 区域 | JDK 17 默认 | 参数 |
|---|---|---|
| Java 堆 | 由-Xmx或MaxRAMPercentage决定 | -Xmx/-XX:MaxRAMPercentage |
| Metaspace | 无上限,随类加载增长(JEP 386 起空闲可归还 OS) | -XX:MaxMetaspaceSize |
| Code Cache | 有默认上限 | -XX:ReservedCodeCacheSize |
| 线程栈 | 每线程约 1MB(64 位 Linux) | -Xss |
| GC 元数据 | G1 的 remembered set、card table 随 Region 数增长 | 随 GC 算法变化 |
-XX:MaxRAMPercentage默认 25,只作用于堆,显式写了-Xmx就不再生效,两者不要混用;UseContainerSupport在 JDK 17 默认开启,cgroup v2 下读的是memory.max(limit),requests.memory只影响调度。memory.current = anon + file + sock + shmem + kernel,HeapMemoryUsage只是anon里堆那一段,页缓存永远不在 JVM 报表中。
五、可运行示例
环境:Docker(cgroup v2),镜像eclipse-temurin:17-jdk-jammy,容器内存 256MB,堆上限 150MB(约六成)。MemoryGapsDemo.java分别用重复defineClass、60 条线程、临时文件读写制造三个缺口。关键是KEEP列表持有ClassLoader引用:类不被回收,Metaspace 才会持续累积,否则 JDK 16 起空闲元数据会归还 OS,读数不动。
importjava.lang.management.*;importjava.nio.file.*;importjava.util.*;publicclassMemoryGapsDemo{// 持有引用,防止类被回收导致 Metaspace 不增长staticfinalList<ClassLoader>KEEP=newArrayList<>();publicstaticvoidmain(String[]args)throwsException{longend=System.currentTimeMillis()+60_000;// 缺口一:反复 defineClass 自身字节码,模拟 CGLIB/反射的 Metaspace 增长List<byte[]>defs=newArrayList<>();try(vards=Files.list(Path.of("/app"))){ds.filter(p->p.toString().endsWith(".class")).forEach(p->{try{defs.add(Files.readAllBytes(p));}catch(Exceptionignored){}});}newThread(()->{for(inti=0;i<300&&System.currentTimeMillis()<end;i++){try{KEEP.add(newClassLoader(null){{for(byte[]b:defs)defineClass(null,b,0,b.length);}});}catch(Throwableignored){}try{Thread.sleep(100);}catch(InterruptedExceptionignored){}}}).start();// 缺口二:60 条平台线程,默认每条约 1MB 栈for(inti=0;i<60;i++){newThread(()->{try{Thread.sleep(60_000);}catch(InterruptedExceptionignored){}}).start();}// 缺口三:256KB 临时文件反复读写,制造 page cachebyte[]buf=newbyte[256*1024];newThread(()->{while(System.currentTimeMillis()<end){try{Pathp=Files.createTempFile("gap",".dat");Files.write(p,buf);Files.readAllBytes(p);Files.deleteIfExists(p);}catch(Exceptionignored){}}}).start();while(System.currentTimeMillis()<end){report();Thread.sleep(5_000);}System.exit(0);// 非 daemon 线程仍在运行,需显式退出}staticvoidreport(){try{MemoryUsageh=ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();longmeta=ManagementFactory.getMemoryPoolMXBeans().stream().filter(p->p.getName().equals("Metaspace")).findFirst().map(p->p.getUsage().getCommitted()).orElse(-1L);longcur=Long.parseLong(Files.readString(Path.of("/sys/fs/cgroup/memory.current")).trim());System.out.printf("heapUsed=%dMB heapCommitted=%dMB metaspace=%dMB threads=%d cgroup=%dMB%n",h.getUsed()>>20,h.getCommitted()>>20,meta>>20,Thread.activeCount(),cur>>20);}catch(Exceptione){System.out.println("report 失败: "+e);}}}FROM eclipse-temurin:17-jdk-jammy WORKDIR /app COPY MemoryGapsDemo.java . RUN javac MemoryGapsDemo.java ENTRYPOINT ["java","-Xmx150m","-Xms150m","-XX:NativeMemoryTracking=summary","MemoryGapsDemo"]dockerbuild-tgap-demo.dockerrun-d--namegap-demo--memory256m --memory-swap 256m gap-demodockerlogs-fgap-demodockerexecgap-demo jcmd1VM.native_memory summarydockerinspect-f'{{.State.OOMKilled}}'gap-demo预期输出(未验证,典型量级):report()每 5 秒一行,heapUsed始终低于 150MB 的六成,cgroup持续逼近 256MB。
| 时点 | heapUsed | metaspace | threads | cgroup |
|---|---|---|---|---|
| 5s | ~30MB | ~18MB | ~12 | ~110MB |
| 30s | ~85MB | ~30MB | ~68 | ~230MB |
| 60s | ~110MB | ~38MB | ~68 | 超 256MB 后被杀 |
实际输出的验证方法:docker inspect -f '{{.State.OOMKilled}}' gap-demo返回true;docker logs gap-demo | tail -5中heapUsed低于 150MB;jcmd 1 VM.native_memory summary的Thread约 70MB、Class约 35MB,加上memory.stat的file即可解释memory.current。
常见失败与修复:cgroup v1 节点上报NoSuchFileException: /sys/fs/cgroup/memory.current,改读/sys/fs/cgroup/memory/memory.usage_in_bytes,页缓存看memory.stat的cache;jcmd提示Native memory tracking is not enabled,补-XX:NativeMemoryTracking=summary并重启;只给--memory不给--memory-swap时 swap 会推迟 OOM 并让读数抖动,两者应设同值。
六、验证结果与边界
三组配置在同一 demo(容器 256MB)下的对比,实测数据未验证,需按方法自测:
| 组 | JVM 参数 | 预期 RSS | 预期 GC 表现 | 实测 |
|---|---|---|---|---|
| A | -Xmx150m | 堆 150MB,RSS 逼近限额 | Young GC 次数少、单次长 | 未验证 |
| B | -XX:MaxRAMPercentage=60(不设-Xmx) | 与 A 基本一致 | 与 A 一致 | 未验证 |
| C | -XX:MaxRAMPercentage=45 -Xss512k -XX:MaxMetaspaceSize=48m | 最低,约 200MB 内 | 堆更小、Young GC 更频繁,Full GC 风险上升 | 未验证 |
测量方法:docker stats --no-stream gap-demo取 RSS,-Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags统计Pause Young的次数与耗时。B 组随limits.memory自动缩放,适合一个镜像多套限额;A 组适合限额固定、堆需求明确的服务。
K8s 边界:requests.memory与limits.memory要么相等(Guaranteed)要么固定比例,否则 JVM 读 limit、调度看 request,压测结论会漂移;页缓存随副本数放大,HPA 扩容前按单副本实测 RSS 乘副本数预留节点内存。不适用的场景:堆需求刚性的大堆批处理与搜索类服务、ZGC/Shenandoah(GC 元数据口径不同,ZGC 多重映射需重新对账)、不允许改启动参数的托管运行时。
参考资料
- JDK 17 jcmd 参考(VM.native_memory)
- JDK 17 java 启动参数参考
- JEP 386: Elastic Metaspace
- Kubernetes 文档:为容器管理计算资源