
容器化 Java:镜像瘦身与 JVM 容器感知(cgroup)的源码级真相
先抛一个反直觉结论:把 -Xmx 设成 2g、容器 limits.memory 也设成 2g,往往不是"刚好用满",而是让 JVM 在还没到 heap OOM 之前,先被内核 OOM Killer 干掉。 原因不是玄学,而是 JVM 对 cgroup 内存的识别、堆外内存的记账方式、以及镜像里那层基础 JDK 的体积共同作用的结果。本系列前面讲过线程池与 Spring Boot 3 的差异,这里换一个视角:当 Java 进程被塞进 cgroup 之后,JVM 到底"看见"了多少资源,镜像里又到底装了什么。
关键词标签:cgroup v2、UseContainerSupport、MaxRAMPercentage、jlink、JDK 容器感知
一、先分清两件事:镜像瘦身和 JVM 感知,是两条独立的问题线
很多团队把"镜像大"和"容器里 JVM 行为怪"混为一谈,实际上它们是两个正交的问题:
- 镜像体积 :取决于基础镜像(
openjdk/eclipse-temurin/distroless)、是否带完整 JDK、应用 fat jar 里有没有塞进一堆用不到的依赖。这是构建期问题。 - JVM 容器感知 :取决于运行时 JVM 能否读到 cgroup 的 CPU/内存限制,进而推导默认堆大小、GC 线程数、JIT 编译线程数等。这是运行期问题。
两者唯一的交集在于:你为了瘦身换了基础镜像(比如 distroless 或 jlink 裁剪后的运行时),可能顺手把 JVM 需要的某些东西也裁掉了,或者换了 JDK 版本导致容器感知行为变化。 所以正确顺序是:先确认 JVM 感知正确,再做瘦身,且瘦身之后要回归验证。
二、JVM 容器感知:UseContainerSupport 到底做了什么
2.1 默认值的历史与现状
-XX:+UseContainerSupport 随 JDK 10 (JEP 未单独编号,作为容器支持改进的一部分)引入,并在 JDK 11 起默认开启 。也就是说,只要你不是跑在 JDK 8u191 之前的老版本上,容器感知默认就是开的。这一点常被误解为"要手动加参数才生效"。
真正需要关注的是它读到了什么。JVM 判断容器内存上限的逻辑,核心在 HotSpot 的 os::Linux::available_memory() 与 OSContainer 相关实现里(src/hotspot/os/linux/osContainer_linux.cpp)。简化后的行为是:
- 若检测到 cgroup,读取内存限制(cgroup v1 是
memory.limit_in_bytes,cgroup v2 是memory.max); - 若限制存在且小于物理内存,则以该限制作为"可用内存";
- 用这个值乘以
MaxRAMPercentage(默认 25%)得到默认最大堆。
这里有个关键点:默认 MaxRAMPercentage=25,不是 75,也不是 80。 所以一个 limits.memory=2Gi 的容器,JVM 默认 -Xmx 大约是 512MiB 左右(还要减去 JVM 自身开销的估算)。很多"容器里堆怎么这么小"的疑惑就来自这里。
2.2 一个能自证的启动参数
不要凭记忆猜 JVM 读到了多少,让它自己打印出来:
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E 'MaxHeapSize|MaxRAMPercentage|UseContainerSupport'
在容器内执行,MaxHeapSize 会反映它根据 cgroup 限制推导出的值。这是最可靠的验证手段,比看任何博客都准。
2.3 为什么 -Xmx 等于容器 limit 会出事
JVM 的内存占用不止堆。以下都是堆外 或不受 -Xmx 直接约束的部分:
| 区域 | 是否受 -Xmx 约束 |
说明 |
|---|---|---|
| Java Heap | 是 | 由 -Xmx 上限 |
| Metaspace | 否(受 MaxMetaspaceSize) |
类元数据,默认无上限 |
| Code Cache | 否(受 ReservedCodeCacheSize) |
JIT 编译产物 |
| 线程栈 | 否 | 每线程 -Xss,默认约 1MiB |
| Direct ByteBuffer | 否 | -XX:MaxDirectMemorySize,默认约等于 -Xmx |
| GC 自身结构 | 部分 | 如 G1 的卡表、标记栈 |
| JNI / native lib | 否 | 比如 ONNX Runtime 的 native 内存 |
所以当 -Xmx 贴着容器 limit 设,堆一旦接近上限,加上 Metaspace、线程栈、direct buffer,RSS 就会越过 limit,内核直接 OOM Kill 整个进程 ,日志里往往只有一句 Killed,看不到 Java 的 OutOfMemoryError。这就是开头那个反直觉结论的机制。
一种常见做法是把堆控制在容器内存的 60%~75% ,并显式设置 MaxMetaspaceSize、MaxDirectMemorySize,把不确定性收窄。具体比例要按应用实际堆外占用标定,不能照抄。
三、cgroup v1 与 v2:感知逻辑的边界
3.1 版本差异
cgroup v2 在主流发行版上逐步成为默认(如较新的 systemd 发行版)。HotSpot 对 v2 的识别在 osContainer_linux.cpp 里有对应分支。边界情况在于:
- 某些运行环境(如部分托管 Kubernetes 或嵌套容器)可能只挂载了 v1 的部分控制器,或 v2 的
memory.max为max(表示无限制); - 若 JVM 读不到限制,会回退到物理内存 ,此时
-Xmx可能被推导成一个远大于容器 limit 的值------这是最危险的静默失败。
验证方法 :在容器内 cat /sys/fs/cgroup/memory.max(v2)或 /sys/fs/cgroup/memory/memory.limit_in_bytes(v1),看是否等于你设置的 limit。如果读到的是宿主机内存量级,说明感知没生效,必须显式传 -Xmx。
3.2 CPU 感知同样重要
内存之外,CPU limit 影响 GC 线程数、ForkJoinPool 并行度、Runtime.availableProcessors() 的返回值。JDK 对 CPU 的容器感知同样依赖 cgroup 的 cpu.max(v2)或 cpu.cfs_quota_us(v1)。如果 availableProcessors() 返回宿主机核数而非容器配额,线程池默认大小会被放大,这在前面讲线程池时提过参数演算的背景,这里补充的是:容器场景下 availableProcessors() 的取值本身就是一道坑。
四、镜像瘦身:从"换基础镜像"到"jlink 裁剪"
4.1 分层思路
镜像体积的大头通常是基础 JDK 层。几个可叠加的手段:
- 换运行时基础镜像 :
eclipse-temurin:21-jre比-jdk小;distroless/java更小但缺 shell,排障受限,需权衡。 - 分层构建:把依赖层和应用层分开,利用 Docker 层缓存,同时让应用层变更时不必重传依赖。
- jlink 裁剪:只保留应用实际用到的模块,生成定制运行时。
- Spring Boot 分层 jar :
spring-boot-maven-plugin的layers配置,把BOOT-INF/lib、BOOT-INF/classes拆成独立层。
4.2 jlink 裁剪的正确姿势
jlink 只能裁剪模块化 的 JDK。用 jdeps 分析应用依赖哪些模块,再 jlink 生成运行时:
# 假设已用 jdeps 分析出应用需要 java.base、java.logging、java.sql 等模块
jlink \
--add-modules java.base,java.logging,java.sql,java.naming,java.management \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=zip-6 \
--output /opt/custom-jre
注意 --compress 在 JDK 21 中的取值已改为 zip-[0-9](旧版的 --compress=2 在 JDK 21 中不再推荐,会给出警告),且 --strip-debug 会移除调试符号,排障时 jstack/jmap 仍可用,但 native 栈回溯信息会变少。这是瘦身的代价,要评估。
4.3 一个可用的多阶段 Dockerfile 骨架
# 构建阶段
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /build
COPY . .
RUN ./mvnw -q -DskipTests package
# 运行时阶段:用 jre 基础镜像
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /build/target/order-service.jar app.jar
# 显式声明容器感知相关参数,不依赖默认推导
ENV JAVA_TOOL_OPTIONS="\
-XX:MaxRAMPercentage=70 \
-XX:MaxMetaspaceSize=256m \
-XX:MaxDirectMemorySize=256m \
-XX:+ExitOnOutOfMemoryError"
ENTRYPOINT ["java", "-jar", "app.jar"]
JAVA_TOOL_OPTIONS 的好处是无需改 ENTRYPOINT 就能注入参数,且会被 JVM 在启动时打印出来(便于确认生效)。-XX:+ExitOnOutOfMemoryError 让堆 OOM 时进程快速退出,交由编排层重启,比半死不活更可控。
4.4 验证容器感知是否真的生效
瘦身换镜像后,务必回归:
public class ContainerProbe {
public static void main(String[] args) {
Runtime rt = Runtime.getRuntime();
System.out.println("availableProcessors = " + rt.availableProcessors());
System.out.println("maxMemory = " + rt.maxMemory() / 1024 / 1024 + " MiB");
System.out.println("totalMemory = " + rt.totalMemory() / 1024 / 1024 + " MiB");
}
}
在容器内跑一次,对照 limits 核对 maxMemory 是否符合 MaxRAMPercentage 的预期。这是最省事的回归手段。
五、真实可查的权威来源
- OpenJDK 容器支持相关源码:<https://github.com/openjdk/jdk/blob/master/src/hotspot/os/linux/osContainer_linux.cpp\>
jlink官方工具文档(JDK 21):<https://docs.oracle.com/en/java/javase/21/docs/specs/man/jlink.html\>java启动器与-XX选项官方文档:<https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html\>- Docker 官方 Java 指南:<https://docs.docker.com/language/java/\>
六、什么时候别用 / 别踩的坑
- 别把
-Xmx设成等于容器 limit。堆外内存没算进去,OOM Killer 会先于 Java 异常到来。给堆外留出明确预算。 - 别假设容器感知一定生效 。在容器内读
/sys/fs/cgroup/memory.max验证,读不到限制时显式传-Xmx。 - 别在 distroless 上做排障 。没有 shell、没有
jcmd之外的工具时,jstack都难跑;生产用 distroless 要配套 sidecar 或保留调试镜像。 - 别为瘦身把 JDK 裁到缺模块 。
jdeps分析漏掉反射加载的模块(如java.sql驱动、JNDI)时,运行期才报ClassNotFoundException,回归要覆盖启动路径。 - 别只看镜像大小不看启动时间 。jlink 裁剪 +
--compress可能影响类加载与启动,需实测权衡。 - 别忽略 CPU limit 对线程池默认值的影响 。
availableProcessors()在容器里可能不等于你分配的核数配额,线程池默认大小要显式设定。
系列预告 :下一篇会从运行时视角切到"可观测性"------当 JVM 在容器里被 OOM Kill、日志只剩 Killed 时,如何用 jcmd、Native Memory Tracking(-XX:NativeMemoryTracking=summary)和 cgroup 事件把内存账算清楚。如果你在做容器化 Java 的落地,欢迎关注本系列,后续会继续把"看起来是运维问题、其实是 JVM 源码问题"的坑一个个拆开。