承接的问题
构造场景:libraryd-conc 的 Pod 设了 limits.memory: 256Mi。开发同学为了「避免直接内存不足」,在启动参数里加了 -XX:MaxDirectMemorySize=1024m,堆则按习惯写了 -Xmx128m。上线后每隔几小时重启一次,kubectl describe pod 显示 Last State: Terminated, Reason: OOMKilled, Exit Code: 137,应用日志里既没有 OutOfMemoryError 也没有堆转储,只有进程戛然而止。
这个场景里没有任何一个数字是「写错了」的:128 MiB 堆小于 256 MiB 限制,直接内存上限 1 GiB 是显式给的,JVM 也没报错。问题是这些数字不在同一个账本上记账。-Xmx 只约束 Java 堆,直接内存、Metaspace、线程栈、JIT 代码缓存全在堆外,而 cgroup 的 memory.max 约束的是所有这些加起来的已提交物理页。账本对不上,内核就会在某一刻直接杀掉进程,轮不到 JVM 抛异常。
这一节把三本账拆开:堆怎么从容器 limit 算出来、线程栈到底占多少、以及五种长得完全不同的 OOM 分别对应什么根因。
堆上限是怎么算出来的
没有写 -Xmx 时,JVM 按「可用内存 × MaxRAMPercentage」算最大堆。JDK 25 的默认值是 25%,另有 InitialRAMPercentage=1.5625%(初始堆)和 MinRAMPercentage=50%(只在可用内存很小时生效)。
「可用内存」在有容器感知时是 cgroup 的 memory.max。本机 colima 容器(cgroup v2,VM 7.7 GiB)实测:
容器 --memory | memory.max | 默认最大堆(25%) | 初始堆(1.5625%) |
|---|---|---|---|
| 512m | 536870912 | 128 MiB | 8 MiB |
| 1g | 1073741824 | 256 MiB | 16 MiB |
| 2g | 2147483648 | 512 MiB | 32 MiB |
| 4g | 4294967296 | 1024 MiB | 64 MiB |
改成 -XX:MaxRAMPercentage=70 之后,同样的 2 GiB 限制给出 1503657984 字节(约 1434 MiB),4 GiB 给出 3007315968 字节(约 2868 MiB)。-Xmx 的优先级更高:--memory=512m -XX:MaxRAMPercentage=70 -Xmx256m 的结果是 256 MiB,命令行显式值覆盖百分比。
宿主上没有 cgroup,可以用 -XX:MaxRAM= 模拟「JVM 以为的内存上限」,这只影响推导,不产生任何实际限制:
-XX:MaxRAM=512m -XX:MaxRAMPercentage=75 -> MaxHeapSize=384 MiB
-XX:MaxRAM=1g -XX:MaxRAMPercentage=75 -> MaxHeapSize=768 MiB
-XX:MaxRAM=4g -XX:MaxRAMPercentage=75 -> MaxHeapSize=3 GiB
-XX:MaxRAMPercentage=50(宿主 32 GiB) -> MaxHeapSize=16 GiB
MaxRAM 的默认值在本机打印为 128 GiB,但它不是「物理内存」;真正的堆推导用的是操作系统或 cgroup 报告的可用内存。看到 MaxRAM=137438953472 不要以为机器有 128 GiB。
堆之外还要留出这些,才能和 memory.max 对上账:
| 项目 | 典型占用 | 是否受 -Xmx 约束 |
|---|---|---|
| Java 堆 | MaxRAMPercentage 决定 | 是 |
| Metaspace + 压缩类空间 | 60–120 MiB,随依赖数量增长 | 否,受 MaxMetaspaceSize |
| JIT 代码缓存 | 实际 50–100 MiB(保留上限默认 240 MiB) | 否 |
| 平台线程栈 | 线程数 × -Xss 的已触碰部分 | 否 |
| 直接内存 / mmap 文件 | 由 MaxDirectMemorySize 与业务决定 | 否 |
| GC 自身结构(卡表、标记位图) | 堆的百分之几 | 否 |
| JVM 本机内存、符号表、日志缓冲 | 数十 MiB | 否 |
所以容器里 MaxRAMPercentage=70 是一个稳妥的起点:给堆留 70%,剩下 30% 覆盖上表。堆占比超过 80% 时,只要 Metaspace 或直接内存稍微长一点,就会撞上 memory.max。
线程栈:-Xss 到底占多少
-Xss 设置的是每个平台线程的栈大小,默认值随平台变化,不能凭印象写 512k 或 1M。本机实测:
宿主 macOS aarch64:ThreadStackSize = 2048 KB(默认),最小可运行 208k
容器 Linux aarch64:ThreadStackSize = 2040 KB(默认),最小可运行 168k
java -Xss200k -version 在宿主上直接拒绝启动:
The Java thread stack size specified is too small. Specify at least 208k
Error: Could not create the Java Virtual Machine.
同一个递归方法在不同 -Xss 下的栈深(宿主实测,方法体是 depth++; recurse();):
-Xss | 栈深 |
|---|---|
| 208k | 967 |
| 256k | 1479 |
| 512k | 4646 |
| 1024k | 18630 |
| 2048k(默认) | 43435 |
| 默认不写 | 44747 |
栈深大致随 -Xss 线性增长,但 208k 到 256k 只多了 24k,栈深却多了 53%——小栈时元数据与保留页占比高,不能按比例外推。工程上的用法是:先用默认值,只有在明确知道调用深度时才调小。把 -Xss 从 1M 调到 256k,1000 个线程节省的虚拟地址空间约 750 MiB,但已提交的物理页远小于这个数,对 memory.max 的帮助没有想象中大。
这里必须分清两个量:虚拟地址空间和已提交物理页。本机在 macOS 上创建 4000 个平台线程(默认 2 MB 栈)只用了 1.93 秒,没有失败;把 -Xss 加到 1g、创建 300 个线程也没有失败。Darwin 对线程栈几乎不做预留,虚拟地址不当回事。cgroup 的 memory.max 计的是已触碰的页,所以「线程栈把容器撑爆」通常不是因为 -Xss 大,而是因为线程真的把栈用深了,或者线程数撞上了 pids.max。
虚拟线程的栈不在 -Xss 里。 虚拟线程的栈帧是堆上的对象,按需分块增长,不受 -Xss 约束;-Xss 仍然约束载体线程(平台线程)。所以 10 万个虚拟线程不会产生 10 万 × 1 MB 的栈预留,它们的成本在堆里,跟着 -Xmx 走。这一点在 6.1 已经用百万虚拟线程验证过,这里只强调排查时的推论:看到 unable to create native thread 时,先查线程数上限(pids.max、RLIMIT_NPROC),不要去调 -Xss。
五种 OOM,五种根因
OutOfMemoryError 只是一个大类,getMessage() 才是判据。把本机与容器里真实跑出来的几种放在一起:
| 消息 | 触发点 | 根因 | 第一动作 |
|---|---|---|---|
Java heap space | 堆分配失败 | 存活对象超过 -Xmx,或泄漏 | 堆转储 + 支配树 |
Metaspace | 类元数据分配失败 | 类加载器泄漏、动态生成类 | 看类加载器数量与 jcmd VM.classloader_stats |
unable to create native thread | pthread_create 失败 | 线程数上限、内存/pid 配额 | 查 pids.max、线程数与 -Xss |
Direct buffer memory | ByteBuffer.allocateDirect 超限 | 直接内存超过 MaxDirectMemorySize | 查 Netty/NIO 缓冲区与 -XX:MaxDirectMemorySize |
GC overhead limit exceeded | GC 时间占比过高且回收极少 | 堆太小或存活率过高 | 加堆或修分配;不要只调 GC |
还有一类根本不是 Java 异常:进程被内核 OOM killer 杀掉,退出码 137,日志里什么都没有。它和上面五种的排查路径完全不同。
Java heap space:先释放再打印
import java.util.ArrayList;
import java.util.List;
public class HeapOom2 {
public static void main(String[] args) {
List<byte[]> keep = new ArrayList<>();
int mib = 0;
try {
while (true) {
keep.add(new byte[1024 * 1024]);
mib++;
}
} catch (OutOfMemoryError first) {
keep.clear(); // 先释放,给错误处理留出空间
System.out.println("allocatedMiB=" + mib + " message=" + first.getMessage());
}
}
}
-Xmx32m -> allocatedMiB=15 message=Java heap space
-Xmx64m -> allocatedMiB=31 message=Java heap space
容器 --memory=512m -Xmx512m -> allocatedMiB=386 message=Java heap space
容器 --memory=512m 默认25% -> allocatedMiB=118 message=Java heap space
keep.clear() 那一行不是可有可无。第一次写这个实验时没有清空,结果 catch 里的字符串拼接又分配失败,抛出了第二个 OOM,栈顶指向 System.out.println,把真正的分配点盖掉了。OOM 处理器本身也要内存,这是很多现场「日志只看到半行」的原因。
注意容器里 -Xmx512m 只分配到 386 MiB 就抛了 Java heap space,进程没有被杀。这说明堆提交失败和 cgroup OOM kill 是两条路径:JVM 能在提交阶段发现失败时,会抛 Java 异常;发现不了时,内核直接杀。
Metaspace:类加载器泄漏的形状
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
/** 固定字节码模板,只改类名,反复 defineClass,直到 Metaspace 耗尽。 */
public class MetaOom2 {
private static final int NAME_OFF = 13;
private static final int NAME_LEN = 10;
static byte[] template() {
byte[] n = "Gen0000000".getBytes(StandardCharsets.US_ASCII);
byte[] sup = "java/lang/Object".getBytes(StandardCharsets.US_ASCII);
ByteArrayOutputStream out = new ByteArrayOutputStream(64);
out.write(0xCA); out.write(0xFE); out.write(0xBA); out.write(0xBE);
out.write(0); out.write(0); out.write(0); out.write(69); // Java 25 -> major 69
out.write(0); out.write(5); // cp_count
out.write(1); out.write(0); out.write(n.length); out.writeBytes(n);
out.write(7); out.write(0); out.write(1);
out.write(1); out.write(0); out.write(sup.length); out.writeBytes(sup);
out.write(7); out.write(0); out.write(3);
out.write(0); out.write(0x21); // public super
out.write(0); out.write(2); out.write(0); out.write(4);
out.write(0); out.write(0); out.write(0); out.write(0);
out.write(0); out.write(0); out.write(0); out.write(0);
return out.toByteArray();
}
static final class GenLoader extends ClassLoader {
Class<?> define(String name, byte[] bytes) {
return defineClass(name, bytes, 0, bytes.length);
}
}
public static void main(String[] args) {
byte[] base = template();
byte[] nb = new byte[NAME_LEN];
nb[0] = 'G'; nb[1] = 'e'; nb[2] = 'n';
GenLoader loader = new GenLoader();
int n = 0;
try {
for (; n < 500_000; n++) {
int v = n;
for (int p = NAME_LEN - 1; p >= 3; p--) {
nb[p] = (byte) ('0' + v % 10);
v /= 10;
}
byte[] bytes = base.clone();
System.arraycopy(nb, 0, bytes, NAME_OFF, NAME_LEN);
loader.define(new String(nb, StandardCharsets.US_ASCII), bytes);
}
} catch (OutOfMemoryError e) {
System.out.print("defined="); System.out.print(n);
System.out.print(" message="); System.out.println(e.getMessage());
}
}
}
-XX:MaxMetaspaceSize=32m -> defined=46745 message=Metaspace
不加限制,加载 25264 个 JDK 类 -> Metaspace used=125MiB, Compressed Class Space used=16MiB
第二行说明这个 OOM 的量级:两万多个类才 125 MiB。线上 Metaspace 泄漏通常是应用类加载器不停创建(热部署、脚本引擎、动态代理、重复的 URLClassLoader),每个加载器都带着自己的一批类,直到 Metaspace 打满。message 也可能是 Compressed class space,取决于先耗尽的是哪块。
unable to create native thread:这次真的复现了
在 macOS 宿主上,这个错误构造不出来:ulimit -u 700 下仍然创建了 2000 个线程,-Xss 加到 1g、300 个线程也照常成功。Darwin 对线程栈几乎不预留,RLIMIT_NPROC 约束的是 fork 出来的进程,不是 pthread_create。把同样的程序放进带 pids.max 的容器,一次就中:
--pids-limit=200, cap 1000 threads
created=100
[warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN)
[warning][os,thread] Failed to start the native thread for java.lang.Thread "burn-182"
FAILED_AT created=182
type=java.lang.OutOfMemoryError
message=unable to create native thread: possibly out of memory or process/resource limits reached
created=182 而不是 200,是因为 JVM 自己已经占了十几个线程(GC、JIT、信号、引用处理),pids.max 是整个 cgroup 的线程数上限。报错文本可以在本机 libjvm.dylib 里 strings 到,但本机没有触发;这条输出是容器里实跑的。
排查顺序:先 cat /sys/fs/cgroup/pids.max 和 pids.current,再看线程转储里有多少线程、有多少是业务池没回收的,最后才看 -Xss。把 -Xss 调小只能延缓,不能解决「线程数没有上限」这个根因。
OOMKilled:没有 Java 异常的那一种
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
public class DirectOom {
public static void main(String[] args) {
List<ByteBuffer> keep = new ArrayList<>();
int mib = 0;
try {
while (true) {
ByteBuffer buf = ByteBuffer.allocateDirect(1024 * 1024);
for (int i = 0; i < buf.capacity(); i += 4096) {
buf.put(i, (byte) 1); // 触碰每一页,强制提交物理内存
}
keep.add(buf);
mib++;
}
} catch (OutOfMemoryError e) {
System.out.print("directMiB="); System.out.print(mib);
System.out.print(" message="); System.out.println(e.getMessage());
}
}
}
--memory=256m --memory-swap=256m, -Xmx128m -XX:MaxDirectMemorySize=1024m
Killed
java_exit=137
docker inspect: ExitCode=0 OOMKilled=true Status=exited
docker inspect 的 ExitCode 是 0,因为外面包了一层 sh -c,sh 自己正常退出;真正的 137 来自被杀的 java 进程。排查 OOMKilled 要看 State.OOMKilled,不能只看退出码,否则会被中间层脚本骗过去。
GC overhead limit exceeded:本节没有复现
这一种要如实说明:本机没有跑出来。-Xmx16m/32m 的 G1 下先抛的是 Java heap space;换成 SerialGC、常驻 56 MiB 再高频分配,进程进入长时间 Full GC 抖动,120 秒没有结束,被手动终止。所以上表这一行只有判据、没有本机输出。
判据是:连续多次 Full GC 花掉超过 98% 的时间、却回收不到 2% 的堆,JVM 认定「再跑下去也没有意义」而抛出。它本质上是堆太小或存活率过高的次生症状,处置是加堆、减少常驻对象,而不是换 GC 或调 GCTimeLimit。
参数表
容器里这几个参数要一起给,单给一个都会留下缺口:
| 参数 | 建议值 | 作用 | 不设的后果 |
|---|---|---|---|
-XX:MaxRAMPercentage | 70 | 按 cgroup 内存算最大堆 | 默认 25%,堆偏小但安全 |
-Xmx | 与百分比二选一 | 显式堆上限,优先级最高 | 与百分比同时写时容易互相打架 |
-Xss | 不设(平台默认)或 512k–1m | 平台线程栈 | 线程多时虚拟地址变大,但 RSS 影响有限 |
-XX:MaxMetaspaceSize | 256m(按依赖数调) | 限制类元数据 | 泄漏时先吃满容器再被 OOMKill |
-XX:MaxDirectMemorySize | ≤ 堆的 50%,且计入容器预算 | 限制 NIO 直接内存 | 默认等于最大堆,堆外再吃一份 |
-XX:ActiveProcessorCount | 与 limits.cpu 一致 | 固定 JVM 认为的核数 | requests-only 时按节点核数开线程 |
-XX:+ExitOnOutOfMemoryError | 开启 | OOM 后立即退出,交给编排重启 | 进程半死不活,探针还可能通过 |
-XX:+HeapDumpOnOutOfMemoryError | 开启 | 堆 OOM 时转储 | 事后没有现场 |
MaxDirectMemorySize 这一行是本节开头事故的直接原因:不设时它默认等于最大堆,设成 1 GiB 就相当于告诉 JVM「堆外可以再花 1 GiB」,而容器只有 256 MiB。任何堆外上限都必须小于「容器 limit − 堆 − 其他堆外」,否则就是一个定时炸弹。
在 libraryd-conc 里落地
把「容器预算」做成启动时的一次核算,不通过就拒绝启动,比上线后被 OOMKill 强:
package com.plume.conc.ops;
/** 容器内存预算:堆 + 线程栈 + 堆外固定开销 必须落在 limit 内。 */
public record ContainerMemoryPlan(long limitBytes, double heapRatio,
int platformThreads, int xssKb, long offHeapMiB) {
public ContainerMemoryPlan {
if (limitBytes <= 0 || heapRatio <= 0 || heapRatio >= 1) {
throw new IllegalArgumentException("limitBytes > 0 且 0 < heapRatio < 1");
}
if (platformThreads < 0 || xssKb <= 0) {
throw new IllegalArgumentException("线程数不能为负,xssKb 必须为正");
}
}
public long heapBytes() {
return (long) (limitBytes * heapRatio);
}
/** 只算已提交部分:每线程约 64 KiB 栈页 + 16 KiB 内核结构,是保守估计。 */
public long threadBytes() {
return (long) platformThreads * 80 * 1024;
}
public long offHeapBytes() {
return offHeapMiB * 1024 * 1024;
}
public long headroomBytes() {
return limitBytes - heapBytes() - threadBytes() - offHeapBytes();
}
public boolean plausible() {
return headroomBytes() > 128L * 1024 * 1024; // 至少留 128 MiB 给 Metaspace/代码缓存
}
public static void main(String[] args) {
long limit = 512L * 1024 * 1024;
ContainerMemoryPlan plan = new ContainerMemoryPlan(limit, 0.70, 200, 1024, 64);
System.out.printf("heap=%dMiB threads=%dMiB offHeap=%dMiB headroom=%dMiB plausible=%b%n",
plan.heapBytes() >> 20, plan.threadBytes() >> 20,
plan.offHeapBytes() >> 20, plan.headroomBytes() >> 20, plan.plausible());
}
}
这段 main 的输出是 heap=358MiB threads=15MiB offHeap=64MiB headroom=73MiB plausible=false:70% 的堆在 512 MiB 容器里只剩 73 MiB 余量,连 Metaspace 都不一定够。把 heapRatio 降到 0.6,余量回到 125 MiB 左右,仍然偏紧,正确做法是加内存或减少堆外占用。plausible() 返回 false 时不要上线,这类问题在压测里未必暴露,只有长时间运行、类加载和直接内存慢慢长起来才会触发。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
只设 -Xmx,不管容器 limit | 堆外内存把容器撑爆,OOMKilled 137 | 用 MaxRAMPercentage,并核算堆外 |
-Xmx 等于容器 limit | 堆还没满进程就被杀 | 堆 ≤ limit 的 70–75% |
MaxDirectMemorySize 大于容器余量 | 直接内存无异常增长,内核杀进程 | 堆外上限必须计入容器预算 |
用 -Xss 解决 unable to create native thread | 只是延后,线程数仍然无上限 | 查 pids.max 与线程泄漏 |
认为虚拟线程也吃 -Xss | 按 10 万 × 1 MB 估算,得出「不可用」的错误结论 | 虚拟线程栈在堆里,看 -Xmx |
| 只看退出码判断 OOMKilled | 中间层脚本把 137 吞掉 | 看 State.OOMKilled |
| OOM 处理器里继续大量分配 | 第二个 OOM 覆盖第一个的栈 | 先释放引用,再打印 |
什么时候不要调 -Xss:线程数在几百以内、调用深度正常时,默认值就是最稳的;调小会换来 StackOverflowError 的风险,而它对 RSS 的改善远小于直觉。真正需要关注的是线程总数和每个线程实际用到的栈深,而不是那个上限数字。StackOverflowError 和 unable to create native thread 是两回事:前者是单个线程的栈用完了,后者是线程根本没建起来。
小结
容器里的内存是一个账本:memory.max 管总页数,-Xmx 只管堆,-Xss 只管平台线程栈的虚拟上限,虚拟线程的栈在堆里,直接内存与 Metaspace 各有自己的开关。默认 MaxRAMPercentage=25 给出的是保守值,70 是更常用的起点,但前提是把堆外也算进去。
四种结局的本机实测分别是:-Xmx32m 下 allocatedMiB=15 Java heap space;-XX:MaxMetaspaceSize=32m 下 defined=46745 Metaspace;--pids-limit=200 下 created=182 unable to create native thread;--memory=256m 加 1 GiB 直接内存上限时 Killed / 137 / OOMKilled=true。第五种 GC overhead limit exceeded 本节没有复现,只给了判据。
下一节把这些参数放进 K8s:requests 与 limits 怎么分工、线程池怎么跟着配额走、探针怎么给 JVM 的启动时间留够预算。
阅读导航:上一节 14.1 availableProcessors 与 CPU 配额 | 下一节 14.3 K8s 下的并发参数基线
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。