☰
Xmx 只用 60% 仍被 OOMKilled:JDK 17 容器内存的三个缺口
2026/10/14 9:45:21 网站建设 项目流程

本文摘要:堆上限只占容器六成,用量不足六成时服务仍被 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 按整个进程组占用的物理页计费,两者量的不是同一批内存。差额落在三个缺口:

  1. 元数据与代码:Metaspace、Code Cache 不受-Xmx约束,CGLIB 代理与多层注解处理会把 Metaspace 推到上百 MB。
  2. 线程栈:每条平台线程默认 1MB 栈,280 条线程即 280MB,这笔账堆报表看不到。
  3. 页缓存与碎片: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调度密度下降、成本上升内存泄漏或页缓存无界增长时只是推迟被杀
降线程 +-Xss512kIO 密集、调用链浅并发下降,深递归可能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。

时点heapUsedmetaspacethreadscgroup
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 文档:为容器管理计算资源

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询