cgroup v2 · memory.max · cpu.max · UseContainerSupport · MaxRAMPercentage · MinRAMPercentage · ActiveProcessorCount · availableProcessors · SerialGC vs G1 · MaxDirectMemorySize · NMT · OOMKilled · 137 · CFS 配额节流 · Kubernetes requests / limits

容器里的 JVM:内核按整个进程记账,JVM 只管得住堆。所以先让 JVM 读到限额,再给堆外每一块留出余量、设上限

容器的内存上限管的是 整个 java 进程:堆、元空间、代码缓存、线程栈、直接内存、GC 自己的数据结构加在一起。超了,内核直接 SIGKILL,没有异常、没有转储,退出码 137。JVM 启动时从 cgroup 文件里读到上限和 CPU 配额,据此定下默认堆(只拿 25%)、选哪个 GC、开几个线程;读错或者没读,后面全错。精华就一句:堆按比例切一刀,堆外逐块封顶,让“不够用”发生在 JVM 里面,而不是被内核替你决定。

全景:一个对象从 new 到被回收 · 第 10 站地图:J03 · 容器里的 JVM:内存和 CPU 限制

①为什么需要:订单服务进了 K8s,三个数要重新定

在全景第 10 站,前九站说的都是堆,可容器不看堆,只看整个进程。交易靶场的订单服务要上 Kubernetes,Pod 规格是 limits: memory 1Gi, cpu 1。这一步要解决的是:在这个盒子里,堆给多大、GC 和 JIT 开几个线程、哪一块会先把盒子撑破。上一页排查 OOMKilled 时说“先查堆外和容器限额”,这一页把那句话拆开讲。

orders Pod resources: limits: memory: 1Gi cpu: "1" requests: memory: 1Gi cpu: 500m JDK 17 · 交易靶场订单服务 容器 = 一个 cgroup:上限 1024 MB · 1 核 java 进程(pid 1) 堆 = ? 上限 元空间 代码缓存 线程栈 直接内存 GC等 堆外:GC 管不着 容器只看这一整条加起来过没过线,不看堆 堆给多大? 不设只有 25%;设满又没给堆外留地方 写死 -Xmx,还是按比例? CPU 算几个? limits 1 核还是 requests 0.5 核? 它决定 GC 选谁、开几个线程 超了会怎样? 没有 OutOfMemoryError、没有转储 进程直接没了,退出码 137
容器的限额从 Pod 声明一路写进 cgroup 文件,JVM 启动时读它。橙色几块都不归 GC 管,所以“堆给多大”其实是在问“堆外要留多少”。

②没有它会怎样:三种常见配法,三种出事方式

“它”指两件事:JVM 读到容器限额,以及我们按限额给堆外留余量。下面三行都在本机用真实的 cgroup 跑过(systemd-run 建的临时 cgroup,和 Docker、K8s 用的是同一套内核机制,⑤有命令)。每行的竖线是容器上限,条按各自的上限缩放。

容器上限 JVM 没读容器限额 老 JDK,或 -XX:-UseContainerSupport 容器 2 GB · 宿主 12 GB 堆上限 2984 MB = 宿主 12 GB × 25% 堆还没长满,进程就先被杀了 · 实测 maxMemory 2984 MB 什么都不设 容器 512 MB · 0.5 核 堆 128 堆外 两百多 MB 闲着 堆只拿 25%,还被选成 SerialGC · 堆小 → GC 频繁 · 实测 -Xmx 设成上限 容器 1024 MB · -Xmx1024m 堆 1024 堆外 堆一长满,整个进程过线 → SIGKILL,退出码 137,日志里什么都没有
第一行最隐蔽:JVM 以为自己在一台 12 GB 的机器上。第二行不出事故,但白白浪费、GC 被拖慢。第三行是线上最常见的 OOMKilled,⑤里用直接内存复现了一次。

不懂精华时的典型误用:Pod 被 OOMKilled,就把 -Xmx 调大,或者把 limit 和 -Xmx 一起调大。前者让堆外更没地方,被杀得更勤;后者只是把同一个比例放大,堆外一涨还是过线。病根不是“内存不够”,是“没给堆外留账”。

③相邻方案:堆怎么定、CPU 怎么告诉 JVM

问题已经清楚:JVM 要知道盒子多大,人要替堆外留账。几种做法的差别在于“换一个规格的容器时,谁来改数”。

堆的定法512 MB 容器里的堆堆外余量容器规格一变结论
什么都不设128 MB(25%)384 MB,大多闲着自动跟着变只适合试跑
-Xmx 写死你写多少是多少要自己算每个规格改一遍;忘了改,缩容时被杀、扩容时浪费规格固定、预算算过时可以
-XX:MaxRAMPercentage=60~75307~384 MB25%~40%自动跟着变默认推荐,再给堆外各块封顶
JVM 没读限额(老 JDK、-XX:-UseContainerSupport)按宿主机算可能是负的—不要
CPU 的告诉法JVM 看到几核代价
设 limits.cpu,JVM 自己读配额 ÷ 周期,向上取整(1.5 核 → 2)超了配额会被节流,见 ④-3
-XX:ActiveProcessorCount=N就是 N和配额对不上时,要么线程多了抢配额,要么少了用不满
不设 limits,只设 requests整台宿主机的核数(JDK 17.0.5、11.0.17 起不再看 requests)不节流,但线程池、GC 线程按整台机器开

选择规则:用 JDK 8u191 以上(cgroup v2 要 8u372 / 11.0.16 / 15+),让 JVM 自己读限额;堆用百分比;直接内存、元空间各设上限;CPU 给 ≥ 2 核,或用 ActiveProcessorCount 明说。本页学的是第三行加这套封顶规则,以及它背后 JVM 和内核各自做了什么。

④原理:限额写在哪、JVM 读到后定了什么、内核怎么执行

先知道这些
  • cgroup(control group):Linux 内核给“一组进程”记账和限额的机制。Docker、Kubernetes 的内存和 CPU 限制,最后都是写进这组进程所在 cgroup 的几个文件。容器不是虚拟机,free、/proc/meminfo、nproc 看到的仍是整台宿主机。
  • 页被写到才算数:malloc、mmap 只是预留地址;第一次写某一页时,内核才分配物理页,并记到所在 cgroup 的账上。所以 -Xss1m 的线程不会一出生就占 1 MB。
  • 堆外有哪些:元空间、代码缓存、线程栈、直接内存、GC 和 JIT 的本地数据结构。每块是什么、谁会撑大它,见 JVM 内存结构。
  • 进程被信号杀死时,shell 和容器运行时报的退出码是 128 + 信号号:SIGKILL 是 9,所以是 137。

④-1 限额写在 cgroup 的文件里,JVM 启动时去读

你写的 limits.memory: 1Gi limits.cpu: 1 requests.cpu: 500m docker run --memory=1g --cpus=1 是同一回事 cgroup v2 文件(容器运行时写) memory.max = 1073741824 cpu.max = 100000 100000 每 100 ms 周期,最多用 100 ms CPU cpu.weight = 20 只是抢不过来时的相对权重 cgroup v1:memory.limit_in_bytes、 cpu.cfs_quota_us / cpu.cfs_period_us、cpu.shares JVM 启动时 读 /proc/self/cgroup,找到自己的目录 “物理内存” = 1024 MB CPU 数 = ⌈100000 ÷ 100000⌉ = 1 1.5 核写成 150000 100000 → 2 cpu.weight:17.0.5、11.0.17 起不用 (JDK 8 仍按 shares 折算) 之后所有“按机器定”的默认值 都按这两个数算 → ④-2
这一套叫 容器支持(-XX:+UseContainerSupport,JDK 10 加入、8u191 移植,默认开)。⑤里用 -Xlog:os+container=trace 能看到 JVM 逐个读这些文件的日志。

④-2 读到以后:堆、GC、线程数全按它定

HotSpot 有一套“按机器定默认值”的规则,叫 ergonomics。容器支持做的事很简单:把它眼里的“机器”换成 cgroup。下表全部是本机实测(JDK 17.0.20,-XX:+PrintFlagsFinal),公式来自 JDK 17 源码。

容器CPU 数最大堆GCParallelGCThreads
200 MB · 2 核2100 MB(50%)Serial—
512 MB · 0.5 核1128 MB(25%)Serial—
512 MB · 2 核2128 MBSerial(内存不够)—
1 GB · 2 核2256 MBSerial(内存不够)—
2 GB · 2 核2512 MBG12
2 GB · 2 核 · MaxRAMPercentage=7521536 MBG12
2 GB · -XX:-UseContainerSupport2(宿主也是 2 核)2984 MBG12

最大堆

没设 -Xmx 时 = 限额 × MaxRAMPercentage(25%)。限额小于约 250 MB 时改用 MinRAMPercentage(50%),中间一段不低于约 126 MB(默认 MaxHeapSize 96 MB × 1.3)

选哪个 GC

CPU ≥ 2 且 内存 ≥ 1792 MB 才算“服务器级机器”,默认 G1;否则 SerialGC:单线程、整堆停顿

开几个线程

ParallelGCThreads ≤ 8 核时等于核数;G1 并发标记线程 = (P+2)/4;JIT 编译线程按核数取对数;commonPool = 核数 − 1

直接内存上限

没设 MaxDirectMemorySize 时 = 最大堆(JDK 17 jdk.internal.misc.VM)。堆 75% + 直接内存 75% = 150%

框架也跟着 Runtime.availableProcessors() 走:Netty 默认开 2 × CPU 个 EventLoop,并行流和 CompletableFuture 默认用 commonPool。CPU 数读错,整串线程数一起错。

④-3 内核怎么执行:内存到线就杀,CPU 用完配额就停

内存:记账到线 → 杀 第一次写某一页 → 分配物理页,记到 cgroup 账上 memory.current 涨 到 memory.max:先回收这个 cgroup 的页缓存 文件读写的缓存可以丢,匿名页(堆、栈、直接内存)不行 回收不动 → cgroup 内的 OOM killer → SIGKILL SIGKILL 捕获不了:JVM 来不及抛异常、写转储 容器状态 OOMKilled · 退出码 137 证据只在 dmesg 和容器事件里:Memory cgroup out of memory CPU:配额用完 → 停到下个周期(示意) 限额 2 核 = cpu.max 200000 100000:每 100 ms 能用 200 ms CPU 0 100 ms 200 ms 8 线程 ×25 ms throttled 所有线程停 75 ms throttled 一个 30 ms 的请求,105 ms 才完成 用的是“时间”而不是“核”:线程越多,配额烧得越快。 cpu.stat 里的 nr_throttled、throttled_usec 记着被停了几次、多久。
两种限额的执行方式完全不同:内存是硬墙,撞上就死;CPU 是计时器,用完了就等。所以内存出事是“进程没了”,CPU 出事是“延迟一阵阵抖”,CPU 使用率看着却不到 100%。

④-4 为什么这样设计:JVM 只对堆负责,堆外靠封顶

JVM 盯着整个进程,快满了自己收缩被放弃的做法

  • 堆外不在 GC 手里:线程栈跟着线程数走,直接内存跟着业务走,JNI 库(压缩、加密、RocksDB)自己 malloc,JVM 看得见一部分,还不回去
  • 内核到线就杀,不会先打招呼;等 JVM 发现快满了,往往只剩一次分配的距离
  • 为了腾地方去收缩堆,等于拿更多的 GC 换内存,延迟变差,而真正在涨的那块堆外照样在涨

启动时按比例切一刀,堆外逐块封顶实际的做法

  • 堆是唯一能精确控制、又能靠 GC 回收的一块,JVM 只对它的上限负责:读到限额,按百分比定 MaxHeapSize
  • 其余各块给上限:MaxDirectMemorySize、MaxMetaspaceSize、ReservedCodeCacheSize、-Xss × 线程数。超了在 JVM 里抛 OutOfMemoryError,能记日志、能转储、能被 ExitOnOutOfMemoryError 体面地退出
  • 剩下的余量由人用 NMT 实测来定,⑥第三圈

决定胜负的约束:内核按整个进程记账、到线直接 SIGKILL,而 JVM 能收放自如的只有堆。所以 JVM 不去追整个进程,而是把“不够用”尽量挪进它自己能报错的地方。默认只拿 25%,也是这个约束逼出来的:JVM 不知道你的堆外有多大,只好往保守了猜。

CPU 为什么只认 limits,不认 requests:cpu.weight(v1 叫 cpu.shares)是抢不过来时的相对权重,机器空闲时一个 requests 0.5 核的 Pod 可以用满 16 核;把权重折算成“几个核”只是 Docker 早年的约定(1024 份 = 1 核),算出来的数经常偏小。JDK-8281181 在 19 里改掉了,并移植到 17.0.5 和 11.0.17;JDK 8 至今仍按 shares 折算。

最少的参数

参数JDK 17取值怎么算、各版本差异放到 ⑥
# 先看 JVM 看到了什么
java -XshowSettings:system -version            # 打印 cgroup 版本、CPU 配额、内存上限
java -Xlog:os+container=trace -version         # 逐个读了哪些文件
# 堆:按比例
-XX:MaxRAMPercentage=70
# 堆外:逐块封顶
-XX:MaxDirectMemorySize=256m -XX:MaxMetaspaceSize=256m -XX:ReservedCodeCacheSize=128m
# CPU:和配额对不上时明说
-XX:ActiveProcessorCount=2
# 出事时留证据、体面退出
-XX:NativeMemoryTracking=summary -XX:+ExitOnOutOfMemoryError

⑤亲眼看到:在 512 MB 的 cgroup 里,看 JVM 读到什么、怎么被杀

本机没有 Docker 守护进程,用 systemd-run --scope 临时建一个 cgroup,效果和 docker run --memory=512m --cpus=0.5 一样:同一套内核文件、同一个 OOM killer。两个类,JDK 8 写法,JDK 17 跑。完整代码 →

第 1 步:JVM 看到了什么

WhatJvmSees.java核心几行
Runtime rt = Runtime.getRuntime();
print(cg("memory.max"));       // /sys/fs/cgroup/<自己>/memory.max
print(cg("cpu.max"));
print(rt.availableProcessors());
print(rt.maxMemory() / MB);    // 最大堆
print(ForkJoinPool.commonPool().getParallelism());
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans())
    print(gc.getName());
$ sudo systemd-run --scope -q -p MemoryMax=512M -p CPUQuota=50% \
    java -cp target/classes labs.jvm.jvm_container.WhatJvmSees
cgroup memory.max      = 536870912
cgroup cpu.max         = 50000 100000
availableProcessors    = 1
maxMemory (heap)       = 123 MB     # 128 MB 减一个 Survivor
commonPool parallelism = 1
GC                     = Copy / MarkSweepCompact   # SerialGC

$ … -p MemoryMax=2G -p CPUQuota=200% …
availableProcessors = 2 · maxMemory = 512 MB · GC = G1

$ … -p MemoryMax=2G … java -XX:-UseContainerSupport …
maxMemory (heap)       = 2984 MB    # 宿主 12 GB 的 25%,比容器还大
$ sudo systemd-run --scope -q -p MemoryMax=512M -p CPUQuota=50% java -Xlog:os+container=trace -version
[os,container] Detected cgroups v2 unified hierarchy
[os,container] Path to /cpu.max is /sys/fs/cgroup/system.slice/run-r56d….scope/cpu.max
[os,container] CPU Quota count based on quota/period: 1
[os,container] Path to /memory.max is /sys/fs/cgroup/system.slice/run-r56d….scope/memory.max
[os,container] Memory Limit is: 536870912

第 2 步:堆外越长越多,被内核杀掉

OffHeapCreep.java核心几行
// 堆里先放 200 MB 活对象,每块都写过
for (int i = 0; i < 200; i++) heap.add(new byte[MB]);
// 每 200 ms 申请 16 MB 直接内存(像 Netty 缓冲越积越多)
try {
    for (int i = 1; ; i++) {
        direct.add(ByteBuffer.allocateDirect(16 * MB));
        System.out.printf("direct %4d MB, RSS=%s%n", i * 16, rss());
        Thread.sleep(200);
    }
} catch (OutOfMemoryError e) {
    System.out.println("JVM 自己报错了:" + e);
    System.exit(3);
}
$ sudo systemd-run --scope -q -p MemoryMax=512M -p MemorySwapMax=0 \
    java -Xmx450m -cp target/classes labs.jvm.jvm_container.OffHeapCreep; echo $?
heap ballast 200 MB, -Xmx=435 MB, RSS=242300 kB
direct   16 MB, RSS=259424 kB
…
direct  272 MB, RSS=522324 kB
direct  288 MB, RSS=539324 kB
Killed
137
$ sudo dmesg | grep 'Killed process'
Memory cgroup out of memory: Killed process 2005783 (java)
  total-vm:3186920kB, anon-rss:522496kB, file-rss:19848kB

看三处:直接内存要到 435 MB(= 最大堆)才会被 JVM 拦住,进程在 288 MB 时就没了;程序里的 catch (OutOfMemoryError) 一次都没执行;dmesg 里被记账的是 anon-rss 约 510 MB,VmRSS 还多算了不进 cgroup 账的共享文件页。

第 3 步:给直接内存封顶,“不够用”回到 JVM 里

$ sudo systemd-run --scope -q -p MemoryMax=512M -p MemorySwapMax=0 \
    java -Xmx450m -XX:MaxDirectMemorySize=64m -cp target/classes labs.jvm.jvm_container.OffHeapCreep; echo $?
direct   48 MB, RSS=292724 kB
JVM 自己报错了:java.lang.OutOfMemoryError: Cannot reserve 16777216 bytes of direct buffer memory (allocated: 50339840, limit: 67108864)
3

到这里已经能交差了。会看 JVM 读到的限额(-XshowSettings:system),能复现并认出 OOMKilled(137 + dmesg),知道用封顶把它换成能被捕获、能记日志的 OutOfMemoryError。

已知还没处理的细节:堆该切多少、堆外各块怎么封顶(⑥ 第二圈);不设 -Xmx 时被悄悄选成 SerialGC(⑥ 坑 3);CPU 配额让延迟一阵阵抖(⑥ 坑 5);JDK 版本太老时读不到 cgroup v2(⑥ 坑 4)。

⑥细节:坑、配法、版本差异

第一圈

注意事项

坑 1Pod 隔几天重启一次,应用日志戛然而止,什么异常都没有← ④-3 到线就杀⑤ 实测
现象
kubectl describe pod 里 Last State: Terminated, Reason: OOMKilled, Exit Code: 137;没有 OutOfMemoryError,也没有堆转储。
根源
cgroup 的 OOM killer 发的是 SIGKILL,进程没机会做任何事。⑤里 catch 一次都没执行。
做法
先看退出码和容器事件,确认是内核杀的,再查堆外:开 -XX:NativeMemoryTracking=summary,jcmd 1 VM.native_memory summary 隔一段时间拍两次做差。排查路线见 JVM 故障排查 ③ 左列。
坑 2堆已经调到 60% 了,还是被杀← ④-2 直接内存上限 = 最大堆实测
现象
本机把 ⑤ 的 -Xmx450m 换成 -XX:MaxRAMPercentage=60(最大堆 297 MB),照样在直接内存 288 MB 时被杀,退出码 137。
根源
没设 MaxDirectMemorySize 时,直接内存上限就等于最大堆。堆 60% + 直接内存最多 60% = 120%,JVM 的闸门比容器的墙更远。
做法
堆外每块都显式封顶,让它们加起来加上堆,小于限额:直接内存、元空间、代码缓存;线程数靠线程池上限管住。
坑 3没写 -Xmx,堆只有 1/4,而且用的是 SerialGC← ④-2 服务器级机器实测
现象
1 GB · 2 核的容器,最大堆 256 MB,UseSerialGC = true;每次 GC 都是单线程停住整个应用,压测时停顿比预期长。
根源
默认 25% 是 JVM 不知道堆外多大时的保守猜测;内存不到 1792 MB 或 CPU 不到 2 个,就不算“服务器级机器”,默认 GC 退回 Serial。
做法
一律显式写堆参数和 GC:-XX:MaxRAMPercentage=… 加 -XX:+UseG1GC。小堆、单核时 Serial 不一定更差,但要是你选的,不是它替你选的。
坑 4节点升级到 cgroup v2 之后,同一个镜像的堆突然变得很大← ④-1 读的是哪套文件实测 + 发行说明
现象
Ubuntu 22.04、RHEL 9 等新系统默认是 cgroup v2(K8s 1.25 起正式支持)。老的 JDK 只会读 v1 的文件,读不到就当没有容器,堆按整台宿主机算(本机用 -XX:-UseContainerSupport 模拟:2 GB 容器里堆上限 2984 MB)。
根源
cgroup v2 的文件名、目录结构都和 v1 不同(memory.max 对 memory.limit_in_bytes),要专门支持:JDK 15 加入,移植到 11.0.16 和 OpenJDK 8u372。
做法
镜像的 JDK 至少到 17,或 11.0.16 / 8u372 以上;容器启动时打一行 -XshowSettings:system 进日志,Provider: cgroupv2 和 Memory Limit 一眼就能核对。
坑 5CPU 使用率不到 60%,p99 却一阵阵飙到几百毫秒← ④-3 配额按时间扣按文档
现象
平均 CPU 看着不高,延迟毛刺和 GC、流量高峰同时出现。cpu.stat 里 nr_throttled 一直在涨。
根源
配额按每个 100 ms 周期扣“CPU 时间”。GC 并行线程、几百个业务线程同时跑,几十毫秒就把这个周期的配额用完,剩下的时间整组线程都停着。平均值把这些停顿抹平了。
做法
CPU limit 至少给 2 核;线程池大小按配额而不是按宿主机核数定;监控 container_cpu_cfs_throttled_periods_total。延迟敏感的服务,不少团队只设 requests 不设 CPU limit(这时要用 ActiveProcessorCount 管住线程数,见坑 6)。
坑 6只设了 CPU requests,JVM 按整台机器开了一堆线程← ④-4 只认 limits实测 + 源码
现象
requests 0.5 核的 Pod 在 64 核节点上,availableProcessors() 返回 64:GC 线程 43 个、commonPool 63 个、Netty 128 个 EventLoop。本机用 CPUWeight=20(相当于 requests 500m)跑,JVM 仍然看到 2 核,也就是全部。
根源
JDK 17.0.5、11.0.17 起只看 cpu.max 和 cpuset,不再把 shares 折算成核数;没有 CPU limit 就等于“整台机器”。JDK 8 则相反,仍按 shares 折算,requests 0.5 核会被当成 1 核。
做法
不设 CPU limit 时,用 -XX:ActiveProcessorCount=N 告诉 JVM 打算用几个核;升级 JDK 前后核对一次 availableProcessors(),因为同一份配置在 8 和 17 上算出来不一样。
第二圈

使用规范

规范防的是什么
内存 requests = limits节点内存紧张时,用量超出 requests 的 Pod 最先被驱逐;两者相等,Pod 就不会因为“比申请的用得多”被挑中
堆用 MaxRAMPercentage,取 60~75;小于 1 GB 的容器往 60 靠堆外大小大体固定(类、线程、代码缓存),容器越小,它占的比例越大
直接内存、元空间、代码缓存都显式封顶坑 2:让“不够用”变成 JVM 里的 OutOfMemoryError,而不是 137
预算:堆 + 直接内存上限 + 元空间上限 + 代码缓存 + 线程数 × 实测栈 + 50~100 MB ≤ 限额按上限算而不是按平时用量算,高峰时才不会过线
显式写 GC:-XX:+UseG1GC(或 ZGC)坑 3:不同规格的容器默默用了不同的 GC
CPU limit ≥ 2 核;不设 limit 就写 ActiveProcessorCount坑 5、坑 6:线程数和配额对不上
开 -XX:NativeMemoryTracking=summary、-XX:+ExitOnOutOfMemoryError,转储写到挂载卷NMT 开销很小,被杀之前能拍到堆外的账;OOM 时体面退出,让 K8s 拉新实例
启动日志里打印 -XshowSettings:system坑 4:JVM 到底看到了什么,事后可查

容器内存预算台:先猜,再看会不会被杀

选一个预设,先猜:堆长满、业务高峰时,这个进程会怎样?

0
堆(按长满算)元空间代码缓存 48线程栈 0.5/个GC 和 JVM 自身直接内存竖线 = 容器上限
先选一个预设。

堆的默认值、GC 和线程数的推法和 ④-2 表里的实测一致(同样的规则在 node 里跑过)。堆外几项是估算:代码缓存按 48 MB、线程栈按每个实际用到 0.5 MB、GC 和 JVM 自身按 40 MB + 堆的 1%(Serial)或 4%(G1)。真实服务用 NMT 实测替换。试试把 D 的 MaxDirectMemorySize 拖到 256:业务还是要 300 MB,结果从“稳”变成 JVM 自己报错,比被杀好查得多。

第三圈

版本差异和更深一层

能力OpenJDK 8u111721
读 cgroup v1 限额(UseContainerSupport,JDK-8146115)8u191自带(10 加入)自带自带
Max/Min/InitialRAMPercentage(JDK-8186248)8u191自带自带自带
读 cgroup v2(JDK-8230305)8u37211.0.16自带(15 加入)自带
CPU 数不再看 shares / requests(JDK-8281181)没有移植,仍看11.0.1717.0.5自带(19 加入)
JFR 容器资源事件(JDK-8203359)——17 加入自带
嵌套 cgroup v2 里父级的限额也算进来(JDK-8322420)各线的 Oracle 版本有移植,OpenJDK 版本以发行说明为准21.0.9

出处:bugs.openjdk.org 上各 bug 的 fixVersion 和移植记录、8u191 发行说明。8u 的版本号指 OpenJDK 8u 线,Oracle JDK 8 另算。17u、11u 的 JDK-8281181 移植改了默认行为,另加了一个默认关的 -XX:+UseContainerCpuShares 用来恢复旧算法;19 起这两个相关开关都标为废弃。

默认堆的完整规则:为什么 300 MB 和 400 MB 的容器,堆都是 126 MB

JDK 17 Arguments::set_heap_size:只在没设 -Xmx 时走这一段。先算 max = 限额 × 25%、min = 限额 × 50%;如果 min 比 MaxHeapSize 的初值(96 MB × 13/10 ≈ 126 MB)还小,就用 min,否则取 max 和 126 MB 里大的那个。所以限额小于约 250 MB 时堆是一半,250~500 MB 之间卡在 126 MB,500 MB 以上才是 25%。本机实测 200 / 240 / 300 / 400 / 512 MB 分别得到 100 / 120 / 126 / 126 / 128 MB。名字里的 Min 容易误会:它不是“最小堆”,而是“小内存机器上用的比例”。

要不要把 -Xms 设成和最大堆一样

物理机上省着用、按需扩堆是对的;容器里这份内存反正已经划给你了,别人用不了。初始堆设满(-XX:InitialRAMPercentage 和 Max 一样),可以免掉启动后一轮轮扩堆引发的 GC。再加 -XX:+AlwaysPreTouch,JVM 启动时就把堆的每一页都写一遍,记到 cgroup 账上:预算算错了,Pod 在启动时就被杀,而不是半夜高峰时。代价是启动慢一些(大堆几秒)。

堆外的账怎么拍:NMT

-XX:NativeMemoryTracking=summary 启动,jcmd <pid> VM.native_memory summary 按类别列出 JVM 自己申请的本地内存:Java Heap、Class(元空间)、Thread(线程栈)、Code(代码缓存)、GC、Compiler、Internal、Other(直接内存在这里)。每项有 reserved 和 committed,和 cgroup 记账对得上的是 committed。先 VM.native_memory baseline,过一阵子再 summary.diff,涨的那一类就是嫌疑人。NMT 看不到 JNI 库自己 malloc 的内存:committed 总和明显小于容器的 memory.current 时,差额就在那里(glibc 的 malloc arena 碎片也算在内)。

下一圈

↺回到⑤:带着⑥改一遍

练习 1只调堆的比例,救得回来吗

把 ⑤ 第 2 步的 -Xmx450m 换成 -XX:MaxRAMPercentage=60 重跑。先猜会不会被杀,再对照坑 2。然后只加 -XX:MaxDirectMemorySize=128m,看退出码变成几。

练习 2CPU 配额怎么折算

用 -p CPUQuota=150% 跑 WhatJvmSees,availableProcessors 是 1 还是 2?再加 -XX:ActiveProcessorCount=4 呢?GC 会跟着变吗(内存给 2 GB)?

练习 3线程栈到底占多少

把 OffHeapCreep 的直接内存换成起 300 个睡着的线程(-Xss1m),在 512 MB 里会被杀吗?用 NMT 看 Thread 一项的 reserved 和 committed 差多少,对照“页被写到才算数”。

1. 用一句话说出这一页的精华。

容器按整个进程记账、到线直接杀,JVM 只管得住堆:所以让 JVM 读到 cgroup 限额,堆按比例切一刀(60%~75%),堆外每块封顶,让“不够用”发生在 JVM 里,变成能记日志的 OutOfMemoryError。

2. 老办法“不设参数”和“-Xmx 设成上限”各疼在哪?

不设:堆只有 25%,512 MB 容器里只有 128 MB,还被选成 SerialGC,剩下的内存闲着、GC 却很频繁。设成上限:堆一长满,加上堆外就过线,被内核 SIGKILL,退出码 137,没有任何 Java 层面的证据。

3. 为什么 OOMKilled 时既没有异常也没有转储?是哪条原理?

④-3:cgroup 的内存是硬墙,到 memory.max 且回收不动时,内核的 OOM killer 发 SIGKILL。这个信号不能被捕获,JVM 来不及做任何事。OutOfMemoryError 只在 JVM 自己的闸门(堆上限、直接内存上限、元空间上限)先到时才会出现,所以要让闸门比墙近。

4. 预测:limits 是 1500m CPU、4 GB 内存,什么参数都不写,JVM 看到几核?用什么 GC?最大堆多大?commonPool 并行度是多少?

④-1、④-2:CPU 数 = ⌈150000 ÷ 100000⌉ = 2;2 核且 4 GB ≥ 1792 MB,算服务器级机器,默认 G1,ParallelGCThreads = 2;最大堆 = 4 GB × 25% = 1 GB;commonPool = 2 − 1 = 1。可以在预算台上拖到这组数核对。

5. 预测:同一个镜像、同一份 Pod 配置(requests 500m,不设 CPU limit),从 JDK 8 升到 JDK 17,availableProcessors() 会怎么变?

④-4:JDK 8 仍把 shares 折算成核数,500m → 512 份 → 1 核;JDK 17.0.5 起不看 shares,没有 limit 就是整台节点的核数,比如 64。线程池、GC 线程、Netty EventLoop 全部跟着放大几十倍(坑 6)。

6. 放回全景:第 10 站前后各发生了什么?这一站配错了,整条链路会怎样?

前面是第 9 站:堆里的 OOM,靠堆转储找谁拉着。第 10 站把镜头拉到整个进程:前九站的堆只是其中一块,元空间(第 1 站)、代码缓存(第 2 站)、线程栈、直接内存都在同一个账上。配错了有两种结局:堆给少了,第 5~7 站的 GC 变得频繁、甚至被换成 SerialGC,停顿变长;堆给多了、堆外没封顶,整条链路还没走到第 9 站的 OOM,进程就被内核在第 10 站直接杀掉,前面埋的转储和 GC 日志全都用不上。