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 里面,而不是被内核替你决定。
①为什么需要:订单服务进了 K8s,三个数要重新定
在全景第 10 站,前九站说的都是堆,可容器不看堆,只看整个进程。交易靶场的订单服务要上 Kubernetes,Pod 规格是 limits: memory 1Gi, cpu 1。这一步要解决的是:在这个盒子里,堆给多大、GC 和 JIT 开几个线程、哪一块会先把盒子撑破。上一页排查 OOMKilled 时说“先查堆外和容器限额”,这一页把那句话拆开讲。
②没有它会怎样:三种常见配法,三种出事方式
“它”指两件事:JVM 读到容器限额,以及我们按限额给堆外留余量。下面三行都在本机用真实的 cgroup 跑过(systemd-run 建的临时 cgroup,和 Docker、K8s 用的是同一套内核机制,⑤有命令)。每行的竖线是容器上限,条按各自的上限缩放。
不懂精华时的典型误用:Pod 被 OOMKilled,就把 -Xmx 调大,或者把 limit 和 -Xmx 一起调大。前者让堆外更没地方,被杀得更勤;后者只是把同一个比例放大,堆外一涨还是过线。病根不是“内存不够”,是“没给堆外留账”。
③相邻方案:堆怎么定、CPU 怎么告诉 JVM
问题已经清楚:JVM 要知道盒子多大,人要替堆外留账。几种做法的差别在于“换一个规格的容器时,谁来改数”。
| 堆的定法 | 512 MB 容器里的堆 | 堆外余量 | 容器规格一变 | 结论 |
|---|---|---|---|---|
| 什么都不设 | 128 MB(25%) | 384 MB,大多闲着 | 自动跟着变 | 只适合试跑 |
-Xmx 写死 | 你写多少是多少 | 要自己算 | 每个规格改一遍;忘了改,缩容时被杀、扩容时浪费 | 规格固定、预算算过时可以 |
-XX:MaxRAMPercentage=60~75 | 307~384 MB | 25%~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 启动时去读
-XX:+UseContainerSupport,JDK 10 加入、8u191 移植,默认开)。⑤里用 -Xlog:os+container=trace 能看到 JVM 逐个读这些文件的日志。④-2 读到以后:堆、GC、线程数全按它定
HotSpot 有一套“按机器定默认值”的规则,叫 ergonomics。容器支持做的事很简单:把它眼里的“机器”换成 cgroup。下表全部是本机实测(JDK 17.0.20,-XX:+PrintFlagsFinal),公式来自 JDK 17 源码。
| 容器 | CPU 数 | 最大堆 | GC | ParallelGCThreads |
|---|---|---|---|---|
| 200 MB · 2 核 | 2 | 100 MB(50%) | Serial | — |
| 512 MB · 0.5 核 | 1 | 128 MB(25%) | Serial | — |
| 512 MB · 2 核 | 2 | 128 MB | Serial(内存不够) | — |
| 1 GB · 2 核 | 2 | 256 MB | Serial(内存不够) | — |
| 2 GB · 2 核 | 2 | 512 MB | G1 | 2 |
2 GB · 2 核 · MaxRAMPercentage=75 | 2 | 1536 MB | G1 | 2 |
2 GB · -XX:-UseContainerSupport | 2(宿主也是 2 核) | 2984 MB | G1 | 2 |
最大堆
没设 -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 用完配额就停
④-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 折算。
最少的参数
# 先看 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 看到了什么
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 步:堆外越长越多,被内核杀掉
// 堆里先放 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)。
⑥细节:坑、配法、版本差异
注意事项
- 现象
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 故障排查 ③ 左列。
- 现象
- 本机把 ⑤ 的
-Xmx450m换成-XX:MaxRAMPercentage=60(最大堆 297 MB),照样在直接内存 288 MB 时被杀,退出码 137。 - 根源
- 没设
MaxDirectMemorySize时,直接内存上限就等于最大堆。堆 60% + 直接内存最多 60% = 120%,JVM 的闸门比容器的墙更远。 - 做法
- 堆外每块都显式封顶,让它们加起来加上堆,小于限额:直接内存、元空间、代码缓存;线程数靠线程池上限管住。
-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 不一定更差,但要是你选的,不是它替你选的。
- 现象
- 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一眼就能核对。
- 现象
- 平均 CPU 看着不高,延迟毛刺和 GC、流量高峰同时出现。
cpu.stat里nr_throttled一直在涨。 - 根源
- 配额按每个 100 ms 周期扣“CPU 时间”。GC 并行线程、几百个业务线程同时跑,几十毫秒就把这个周期的配额用完,剩下的时间整组线程都停着。平均值把这些停顿抹平了。
- 做法
- CPU limit 至少给 2 核;线程池大小按配额而不是按宿主机核数定;监控
container_cpu_cfs_throttled_periods_total。延迟敏感的服务,不少团队只设 requests 不设 CPU limit(这时要用ActiveProcessorCount管住线程数,见坑 6)。
- 现象
- requests 0.5 核的 Pod 在 64 核节点上,
availableProcessors()返回 64:GC 线程 43 个、commonPool63 个、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 到底看到了什么,事后可查 |
容器内存预算台:先猜,再看会不会被杀
选一个预设,先猜:堆长满、业务高峰时,这个进程会怎样?
堆的默认值、GC 和线程数的推法和 ④-2 表里的实测一致(同样的规则在 node 里跑过)。堆外几项是估算:代码缓存按 48 MB、线程栈按每个实际用到 0.5 MB、GC 和 JVM 自身按 40 MB + 堆的 1%(Serial)或 4%(G1)。真实服务用 NMT 实测替换。试试把 D 的 MaxDirectMemorySize 拖到 256:业务还是要 300 MB,结果从“稳”变成 JVM 自己报错,比被杀好查得多。
版本差异和更深一层
| 能力 | OpenJDK 8u | 11 | 17 | 21 |
|---|---|---|---|---|
读 cgroup v1 限额(UseContainerSupport,JDK-8146115) | 8u191 | 自带(10 加入) | 自带 | 自带 |
Max/Min/InitialRAMPercentage(JDK-8186248) | 8u191 | 自带 | 自带 | 自带 |
| 读 cgroup v2(JDK-8230305) | 8u372 | 11.0.16 | 自带(15 加入) | 自带 |
| CPU 数不再看 shares / requests(JDK-8281181) | 没有移植,仍看 | 11.0.17 | 17.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 碎片也算在内)。
下一圈
- glibc malloc arena 和
MALLOC_ARENA_MAX:NMT 之外的那部分 RSS 是怎么长出来的 - Kubernetes QoS(Guaranteed / Burstable / BestEffort)和
oom_score_adj:节点内存紧张时先杀谁 - CPU 节流的观测和治理:
cpu.stat、CFS burst、要不要设 CPU limit 的争论 - 容器里的启动速度:AppCDS、CRaC、Leyden(地图 J14“Java 在容器里”那一项)
- 让 GC 按内存压力自动定堆:OpenJDK 有相关讨论和提案,本页没有核对进展
↺回到⑤:带着⑥改一遍
把 ⑤ 第 2 步的 -Xmx450m 换成 -XX:MaxRAMPercentage=60 重跑。先猜会不会被杀,再对照坑 2。然后只加 -XX:MaxDirectMemorySize=128m,看退出码变成几。
用 -p CPUQuota=150% 跑 WhatJvmSees,availableProcessors 是 1 还是 2?再加 -XX:ActiveProcessorCount=4 呢?GC 会跟着变吗(内存给 2 GB)?
把 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 日志全都用不上。