《Java 并发编程·性能诊断与事故》14.2 内存限制、线程栈与容器 OOM

容器 limit 256 MiB、堆只开了 128 MiB,进程却被 Killed,日志里一行 Java 异常都没有。本节拆开 MaxRAMPercentage 的默认 25%、-Xss 的平台差异和虚拟线程的栈归属,实跑 heap、Metaspace、native thread、OOMKilled 四种结局,并给出容器内存的参数表与核算方法。

承接的问题

构造场景: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)实测:

容器 --memorymemory.max默认最大堆(25%)初始堆(1.5625%)
512m536870912128 MiB8 MiB
1g1073741824256 MiB16 MiB
2g2147483648512 MiB32 MiB
4g42949672961024 MiB64 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栈深
208k967
256k1479
512k4646
1024k18630
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 threadpthread_create 失败线程数上限、内存/pid 配额查 pids.max、线程数与 -Xss
Direct buffer memoryByteBuffer.allocateDirect 超限直接内存超过 MaxDirectMemorySize查 Netty/NIO 缓冲区与 -XX:MaxDirectMemorySize
GC overhead limit exceededGC 时间占比过高且回收极少堆太小或存活率过高加堆或修分配;不要只调 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:MaxRAMPercentage70按 cgroup 内存算最大堆默认 25%,堆偏小但安全
-Xmx与百分比二选一显式堆上限,优先级最高与百分比同时写时容易互相打架
-Xss不设(平台默认)或 512k–1m平台线程栈线程多时虚拟地址变大,但 RSS 影响有限
-XX:MaxMetaspaceSize256m(按依赖数调)限制类元数据泄漏时先吃满容器再被 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 下的并发参数基线

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收