-m 2g 反而更早 OOM:Docker 内存计数与宿主机空闲口径差异

本文摘要:一个 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 核对实际堆上限。

参考资料

相关推荐
驭渊的小故事5 分钟前
Spring AOP 入门:从 AOP 概念到第一个耗时统计切面
java·后端·spring
进击的荆棘22 分钟前
Linux系统——线程概念与控制之线程封装
linux·运维·服务器·线程
天天被压力23 分钟前
【A股分钟级高频数据完全手册 #09】A股历史分时交易数据接口:普通分时怎么批量下
java·人工智能·python
叼烟扛炮25 分钟前
[LangChain] 学习笔记(05):提示词模板(Prompt)与少样本注入(Few-Shot)实战
linux·人工智能·笔记·学习·langchain·prompt
CodeYp201928 分钟前
第5讲:Socket 编程 TCP
linux·socket·tcp
天天被压力31 分钟前
【A股分钟级高频数据完全手册 #10】股票实时行情数据接口Python:公开源实时交易取数
java·人工智能·python
咔_37 分钟前
有IPv6地址,为什么外面还是连不进来?让同一台服务分别卡在四个位置
linux·计算机网络
apcipot_rain1 小时前
C++转Java日记(05)
java·开发语言·c++
CodeYp20191 小时前
第3讲:网络命令
linux·网络·命令
CoderYanger6 小时前
Java EE 进阶:8.MyBatis 操作数据库(入门)(第二弹)
java·数据库·spring boot·spring·面试·java-ee·mybatis