跳至主要內容

程序员小富大约 4 分钟

大家好,我是小富。

《十万个why》系列持续更新中

很多 Java 开发者在第一次接触容器化部署时,都会遇到一个让人抓狂的问题:同样的 Java 应用,在物理机或虚拟机上跑得好好的,一扔进 Docker 容器里就频繁 OOM 被 Kill。

明明代码没改,JVM 参数也是一样的,为什么到了容器里就炸了?

根本原因:JVM 不认识 cgroup 的内存限制

Docker 通过 Linux 的 cgroup(Control Groups)来限制容器的资源使用。比如你用 docker run -m 512m 启动一个容器,意思是这个容器最多只能用 512MB 内存。

但问题是,早期版本的 JVM(Java 8u131 之前)根本不知道 cgroup 的存在。

JVM 启动时会去读取系统的总内存来决定堆的默认大小。它读的是 /proc/meminfo,而这个文件在容器里反映的是宿主机的总内存,不是容器的内存限制。

假设宿主机有 32GB 内存,你给容器限制了 512MB:

容器看到的 /proc/meminfo:
MemTotal:       32768000 kB   ← 这是宿主机的 32GB!

Docker 通过 cgroup 设置的限制:
/sys/fs/cgroup/memory/memory.limit_in_bytes: 536870912  ← 512MB

JVM 读了 /proc/meminfo,以为自己有 32GB 可用,按照默认策略(物理内存的 1/4)设置了 8GB 的最大堆。但实际上容器只有 512MB,JVM 实际使用超过 512MB 时,直接被 Linux 的 OOM Killer 干掉。

被 Kill 的方式还特别隐蔽

JVM 被 OOM Killer 杀死时,不会抛 OutOfMemoryError。因为这不是 JVM 层面的内存不足,而是操作系统层面直接发了 SIGKILL 信号。

你在容器日志里看到的可能只是进程突然消失了,或者一个 exit code 137(128 + 9,9 就是 SIGKILL)。如果不知道去看 dmesg 或者宿主机的 /var/log/messages,你可能根本不知道是 OOM Killer 干的。

# 查看是否被 OOM Kill
dmesg | grep -i "oom\|killed process"

# 输出类似:
# Out of memory: Kill process 12345 (java) score 800 or sacrifice child

不只是堆内存的问题

很多人说"我设了 -Xmx256m,容器给了 512MB,够用了吧"。然而 JVM 占用的内存远不止堆:

JVM 总内存 ≈ 堆内存(Heap)
             + 元空间(Metaspace)
             + 线程栈(Thread Stack × 线程数)
             + JIT 编译缓存(Code Cache)
             + 直接内存(Direct Memory / NIO)
             + GC 自身开销
             + Native 内存

假设你设了 -Xmx256m,但实际情况可能是:

内存区域占用
Heap(-Xmx)256MB
Metaspace64MB
线程栈(200 个线程 × 1MB)200MB
Code Cache48MB
Direct Memory32MB
其他 Native若干 MB
合计~600MB+

512MB 的容器根本装不下。

Java 8 和 Java 11+ 的差异

Java 8u131 ~ 8u191:部分支持

从 Java 8u131 开始,JVM 加入了实验性的容器感知功能,需要手动开启:

java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -jar app.jar

但这个参数只是让 JVM 读取 cgroup 的内存限制来替代 /proc/meminfo,默认堆大小的计算(1/4 物理内存)还是基于这个值。如果容器限制 512MB,默认堆就是 128MB,可能又太小了。

Java 8u191+:正式支持

从 Java 8u191 开始,容器感知默认开启(-XX:+UseContainerSupport,默认 true),不需要额外参数。同时新增了两个更精确的控制参数:

# 堆内存占容器内存限制的百分比
-XX:MaxRAMPercentage=75.0

# 替代了旧的 -XX:MaxRAMFraction

Java 11+:开箱即用

Java 11 及以上版本完全原生支持容器环境,容器感知默认开启,基本不需要额外配置。

生产环境的推荐配置

FROM eclipse-temurin:17-jre-alpine

# 设置 JVM 参数
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
               -XX:InitialRAMPercentage=50.0 \
               -XX:+UseG1GC \
               -XX:MaxMetaspaceSize=128m \
               -XX:MaxDirectMemorySize=64m"

COPY app.jar /app.jar
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"]

几个关键点:

  1. MaxRAMPercentage 而不是 -Xmx 固定值:这样堆大小会自动适配容器的内存限制。给不同规格的容器部署同一个镜像时不用改参数。

  2. 百分比不要超过 75%:剩下的 25% 给 Metaspace、线程栈、Direct Memory 等非堆内存使用。

  3. 显式限制 Metaspace 和 Direct Memory:防止它们无限增长把容器内存吃光。

  4. 容器内存限制建议留 buffer:如果你算出来 JVM 总共需要 512MB,容器至少给 640MB ~ 768MB。

一个排查清单

如果你的 Java 应用在容器里 OOM,按这个顺序排查:

1. 确认 JDK 版本:Java 8u191 以下?赶紧升级
2. 确认容器内存限制:docker inspect 看 Memory 字段
3. 确认 JVM 看到的内存:容器内执行 java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
4. 计算非堆内存开销:线程数 × 栈大小 + Metaspace + DirectMemory + CodeCache
5. 查看是否被 OOM Kill:dmesg | grep -i oom
6. NMT(Native Memory Tracking):-XX:NativeMemoryTracking=detail,然后 jcmd <pid> VM.native_memory

总结

Java 应用在容器里 OOM 的本质原因是:JVM 对内存的感知和容器对内存的限制之间存在信息差。 JVM 以为自己能用的内存比容器实际允许的要多,当实际使用超过容器限制时,不是 JVM 优雅地抛 OutOfMemoryError,而是被 Linux 的 OOM Killer 直接 Kill 掉。

升级 JDK 版本、用百分比设置堆大小、显式限制非堆内存,把这三件事做好,基本就不会踩这个坑了。


我是小富,下期见。

上次编辑于: