大家好,我是小富。
《十万个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 |
| Metaspace | 64MB |
| 线程栈(200 个线程 × 1MB) | 200MB |
| Code Cache | 48MB |
| Direct Memory | 32MB |
| 其他 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"]
几个关键点:
用
MaxRAMPercentage而不是-Xmx固定值:这样堆大小会自动适配容器的内存限制。给不同规格的容器部署同一个镜像时不用改参数。百分比不要超过 75%:剩下的 25% 给 Metaspace、线程栈、Direct Memory 等非堆内存使用。
显式限制 Metaspace 和 Direct Memory:防止它们无限增长把容器内存吃光。
容器内存限制建议留 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 版本、用百分比设置堆大小、显式限制非堆内存,把这三件事做好,基本就不会踩这个坑了。
我是小富,下期见。
