本文摘要:一个 Spring Boot 文件服务用 docker run -m 2g --memory-swap 2g 部署,JVM 参数写的是 -Xmx2g。
一、问题与结论
压测持续到堆用量打印 1450 MB,进程被 SIGKILL,退出码 137;登录宿主机执行 free -h,available 列显示 48 GB。第一反应是"宿主机有内存,Docker 却杀了进程",排查方向就此被带偏。
结论是两套计数口径不同:-m 写入的是 cgroup 硬限制,计数覆盖匿名页与 page cache;宿主机 free 把 page cache 归入 buff/cache,视为可回收内存。-Xmx 只约束堆上限,与 -m 等值时 Metaspace、线程栈、Code cache 没有任何余量,几乎必然提前触发 cgroup 层的 OOM。
二、排查与选择依据
先判断 OOM 发生在哪一层。docker inspect 返回 OOMKilled: true、dmesg 出现 Memory cgroup out of memory,说明触发点在 cgroup 层,堆内的 OutOfMemoryError 没有机会抛出,进程直接被 SIGKILL。Java 堆 OOM 的特征是退出码非 137、日志出现 java.lang.OutOfMemoryError,两者不要混淆。cgroup v2 下进一步看计数构成:
bash
cat /sys/fs/cgroup/memory.events
# oom_kill 计数
cat /sys/fs/cgroup/memory.stat | grep -E '^(anon|file)'
# anon 与 file 两类计数
file 非零说明 page cache 已经计入限额。监控指标应改为采集 memory.current(cgroup v1 对应 memory.usage_in_bytes),宿主机 free 的 available 不反映容器剩余额度。
再看 -Xmx 与 -XX:MaxRAMPercentage 的口径差异。-Xmx 是绝对字节数,只控制堆上限,非堆部分依旧占用同一批 cgroup 额度;-XX:MaxRAMPercentage 读取容器可见内存(cgroup memory.max)按百分比折算堆上限,JDK 11+ 的容器感知会自动读取并折算,无需把限额写进启动参数。两者二选一,不要叠加。
替代方案与取舍
| 方案 | 选择条件 | 代价 | 不该用的场景 |
|---|---|---|---|
-XX:MaxRAMPercentage=75 |
通用后端,可接受堆缩 25% | 同限额下堆上限变小 | 堆值已按业务精确固定,必须绝对字节 |
放开 --memory-swap |
批处理、非延迟敏感 | 换页抖动、尾延迟恶化 | 在线交易、SLA 严格 |
手动改 cgroup memory.high |
cgroup v2,可直接挂载 sysfs | 脱离 Docker 参数,运维复杂 | 托管编排平台,无 cgroup 挂载权限 |
若堆值已按业务精确固定,直接下调 -Xmx 给非堆留空间,不要用 MaxRAMPercentage 覆盖既有 -Xmx。
三、关键原理
cgroup v2 的 memory.current 统计该 cgroup 下所有进程的匿名页、文件页缓存以及部分内核内存;宿主机 free 的 available 把可回收的 page cache 算作可用。同一时刻两个计数器可以分别显示 2 GB 与 48 GB,这不是统计错误,而是统计范围不同------前者是容器视角,后者是全局视角。

JVM 进程对 memory.current 的贡献可拆为:
text
cgroup 计数 ≈ 堆(-Xmx) + Metaspace + 线程栈 + Code cache
+ Direct buffers + GC 工作区 + page cache
按 -Xmx2g 常见非堆合计 200 到 400 MB,文件读写再叠 page cache,总量即超 2 GB。-XX:MaxRAMPercentage=75 把堆上限定在约 1.5 GB,为非堆与缓存预留 500 MB,堆满时走 OutOfMemoryError 退出路径而非 SIGKILL,便于从日志定位问题。
四、可运行示例
环境:Linux 5.15+、Docker 24+、cgroup v2(stat -fc %T /sys/fs/cgroup/ 输出 cgroup2fs)。镜像基于 eclipse-temurin:17-jdk,入口命令为 java。
Dockerfile:
dockerfile
FROM eclipse-temurin:17-jdk
WORKDIR /app
COPY OomTest.java /app/
RUN javac OomTest.java
ENTRYPOINT ["java"]
OomTest.java:
java
import java.util.ArrayList;
import java.util.List;
public class OomTest {
public static void main(String[] a) throws Exception {
List<byte[]> l = new ArrayList<>();
for (int i = 0; ; i++) {
l.add(new byte[50 * 1024 * 1024]);
System.out.printf("heap=%d MB, cgroup=%d MB%n", (i + 1) * 50, read());
Thread.sleep(200);
}
}
static long read() {
for (String p : new String[]{"/sys/fs/cgroup/memory.current",
"/sys/fs/cgroup/memory/memory.usage_in_bytes"}) {
try {
return Long.parseLong(
java.nio.file.Files.readString(java.nio.file.Paths.get(p)).trim()) / 1048576;
} catch (Exception ignored) {}
}
return -1;
}
}
失败配置(不加 --rm,便于事后检查退出状态):
bash
docker build -t oom-test .
docker run --name oom-run -m 2g --memory-swap 2g oom-test -Xmx2g OomTest
预期输出 :heap 打印到约 1.4 GB 量级时 cgroup= 已逼近 2 GB(差值来自 Metaspace、线程栈、Code cache 与 page cache,具体数值随 JDK 发行版而异),随后进程被 SIGKILL,退出码 137。
实际输出 :在宿主机执行 docker inspect --format '{``{.State.OOMKilled}} {``{.State.ExitCode}}' oom-run,输出 true 137 即确认 cgroup OOM;再执行 dmesg | grep -i oom 可见 Memory cgroup out of memory。若看到的是 java.lang.OutOfMemoryError 且退出码非 137,说明限额未按预期生效,检查 docker info 中 Cgroup Driver 与内核版本是否匹配。
常见失败:镜像内 JDK 早于 8u191,JVM 按宿主机物理内存设默认堆,heap= 打印远超 2 GB 仍未被杀。修复方法是改用 JDK 11+,或在 -Xmx / MaxRAMPercentage 中二选一。
清理与修正配置:
bash
docker rm oom-run
docker run --rm -m 2g --memory-swap 2g oom-test -XX:MaxRAMPercentage=75 OomTest
此时堆上限约 1.5 GB,堆满时抛 OutOfMemoryError,退出码非 137。
五、验证结果与边界
按 MaxRAMPercentage=75 留余量适合多数无状态后端:堆缩 25% 换来可诊断的退出路径,运维能从日志区分 Java 堆 OOM 与 cgroup OOM。三条边界需要单独考虑:堆内数据本身贴近 2 GB 时应上调 -m 而不是调参;大文件 IO 服务的 page cache 占比高,即便堆有余量也易超限,需用 O_DIRECT 或减小读取块降低缓存压力;延迟敏感业务不要用 swap 兜底。参数折算的具体数值受 JDK 发行版影响,实施前用 java -XX:+PrintFlagsFinal -version | grep MaxHeapSize 核对实际堆上限。