容器化 Java:镜像瘦身与 JVM 容器感知(cgroup)

容器化 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)。简化后的行为是:

  1. 若检测到 cgroup,读取内存限制(cgroup v1 是 memory.limit_in_bytes,cgroup v2 是 memory.max);
  2. 若限制存在且小于物理内存,则以该限制作为"可用内存";
  3. 用这个值乘以 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() 的取值本身就是一道坑。


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 拆成独立层。

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 的预期。这是最省事的回归手段。


五、真实可查的权威来源


六、什么时候别用 / 别踩的坑

  1. 别把 -Xmx 设成等于容器 limit。堆外内存没算进去,OOM Killer 会先于 Java 异常到来。给堆外留出明确预算。
  2. 别假设容器感知一定生效 。在容器内读 /sys/fs/cgroup/memory.max 验证,读不到限制时显式传 -Xmx。
  3. 别在 distroless 上做排障 。没有 shell、没有 jcmd 之外的工具时,jstack 都难跑;生产用 distroless 要配套 sidecar 或保留调试镜像。
  4. 别为瘦身把 JDK 裁到缺模块 。jdeps 分析漏掉反射加载的模块(如 java.sql 驱动、JNDI)时,运行期才报 ClassNotFoundException,回归要覆盖启动路径。
  5. 别只看镜像大小不看启动时间 。jlink 裁剪 + --compress 可能影响类加载与启动,需实测权衡。
  6. 别忽略 CPU limit 对线程池默认值的影响 。availableProcessors() 在容器里可能不等于你分配的核数配额,线程池默认大小要显式设定。

系列预告 :下一篇会从运行时视角切到"可观测性"------当 JVM 在容器里被 OOM Kill、日志只剩 Killed 时,如何用 jcmd、Native Memory Tracking(-XX:NativeMemoryTracking=summary)和 cgroup 事件把内存账算清楚。如果你在做容器化 Java 的落地,欢迎关注本系列,后续会继续把"看起来是运维问题、其实是 JVM 源码问题"的坑一个个拆开。

相关推荐
Thomas.Sir1 小时前
第2课:SpringBoot4.x 项目构建:Maven/Gradle配置详解,父工程与依赖管理
java·spring boot
dyxal1 小时前
A2A 协议 v1.0 完全指南:方法名、Agent Card、三种绑定与 FastAPI 实战
开发语言·qt·fastapi
譕痕1 小时前
Rabbit 实例
java·开发语言·java-rabbitmq
weixin_448771721 小时前
使用Aspose给PDF添加水印
java·pdf
\光辉岁月/1 小时前
7.system类
java·system类
综合简报1 小时前
黑五海外发稿投给谁?三类海外媒体发稿渠道的分工
开发语言·php·媒体
SL_staff1 小时前
BI落地失败的3个技术断点:从数据接入、自助分析到服务闭环的工程实践
java·数据分析·数据可视化
大风起兮云飞扬丶1 小时前
Java——线程池
java·开发语言
我命由我123452 小时前
Python 开发 - Python 虚拟环境
运维·开发语言·后端·python·http·https·java-ee