Docker 中运行 Maven 构建时,如何配置 JVM 内存以避免 OOM?
应分别为 Maven 主 JVM 和 Surefire 测试 JVM 分配堆预算,限制并发,并为所有进程的堆外内存和容器开销留出余量。即使两个 JVM 都设置了 -XX:MaxRAMPercentage=40,也只能约束各自的最大堆,无法保证整个容器的内存用量低于 6 GB。
Maven 和 Surefire 官方文档分别说明了配置入口,但不能由此推导出适用于所有构建的安全内存比例。具体数值需要根据项目构建和测试时的峰值确定。
为什么两个 40% 仍可能超限?
在 JVM 正确识别容器内存限制、且没有显式 -Xmx 等设置覆盖堆大小的前提下,每个 JVM 都会根据可见内存独立计算最大堆,不会先扣除同一容器内其他 JVM 的预算。MaxRAMPercentage 的含义见 Java 启动参数文档。
最大堆容量也不会在启动时自动全部变成物理内存占用。“两个 90%”意味着堆上限合计可能达到容器容量的 180%,并不表示启动时已经使用了这么多内存。
规划容量时,可以参考以下预算关系:
Maven 堆预算
+ 同时运行的测试 JVM 数量 × 每个测试 JVM 的堆预算
+ 所有 JVM 的堆外内存预算
+ 其他进程、文件缓存和内核开销预算
+ 峰值余量
<= 容器内存限制
这是容量规划方法,不能直接把 NMT 的各项数字代入,作为容器实际内存用量的精确等式。
你观察到的约 600 MB Metaspace 和 250 MB GC 开销说明,堆外开销足以吃掉两个 40% 堆预算之外的空间。如果两个 JVM 都有类似开销,仍可能超过容器限制。
分别设置 Maven 和测试 JVM
先确认参数从哪里进入 JVM。MaxRAMPercentage 不会自动将 Maven 主 JVM 的设置传播给测试 JVM。
MAVEN_OPTS或.mvn/jvm.config用于启动 Maven 主 JVM。- Surefire 的
argLine用于配置 fork 出来的测试 JVM。 - 如果参数放在被子进程继承的
JAVA_TOOL_OPTIONS或JDK_JAVA_OPTIONS中,就可能影响多个 JVM。
这些配置的作用范围分别见 Maven 配置说明、Surefire fork 配置说明、JAVA_TOOL_OPTIONS 说明和 JDK_JAVA_OPTIONS 说明。
原始记录中的 --XX:MaxRAMPercentage=90 多了一个连字符,标准写法是 -XX:MaxRAMPercentage=90。
对于固定为 6 GB 的构建容器,可以先用明确的 -Xmx 建立可测量的配置。例如,给 Maven 主 JVM 分配 1 GiB 堆,给测试 JVM 分配 2 GiB 堆:
export MAVEN_OPTS='-Xms256m -Xmx1g'
mvn -B test
在现有 pom.xml 的 Surefire 配置中合并以下设置,沿用项目已经管理的插件版本:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<forkCount>1</forkCount>
<reuseForks>true</reuseForks>
<argLine>-Xms256m -Xmx2g</argLine>
</configuration>
</plugin>
这组数值只是排查的起点,尚未在该项目中验证。剩余约 3 GiB 仍需承担两个 JVM 的堆外内存和其他开销;如果主构建或测试需要更大的堆,就要重新分配预算、拆分任务或增加容器容量。
如果已有 JaCoCo 等工具维护 argLine,应按其集成方式合并参数,避免覆盖已有的 agent 配置。同时清理相互冲突的全局堆设置,核对最终生效的参数。
把并发数量算进预算
Surefire 默认的 forkCount=1、reuseForks=true 表示每个模块通常使用一个可复用的测试 JVM。如果多模块构建启用了 Maven -T,不同模块仍可能同时运行各自的 Surefire。forkCount 使用 C 后缀时,还会随可用 CPU 数量放大。Surefire 官方文档说明了这种并发关系。
排查时,建议先关闭 Maven 模块并行,将 forkCount 固定为 1,同时关闭测试框架自身的并行执行。稳定后再逐步增加并发,并重新观察容器峰值。
还要检查构建是否启动了其他子进程,例如 fork 出来的编译器、集成测试 JVM 或测试依赖服务。它们也会占用同一容器的内存预算。
先区分 Java OOM 和内核 OOM kill
出现 java.lang.OutOfMemoryError: Java heap space,说明对应 JVM 的堆分配失败;出现 Metaspace 或无法创建 native thread,则需要检查相应的堆外资源。
如果进程被内核直接杀死,可能没有 Java 异常,也来不及生成 heap dump。可以在 Docker 宿主机检查:
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container>
退出码 137 只能说明进程收到了 SIGKILL,不能单凭它确认 OOM。如果被杀的是容器内的测试子进程,而主进程继续运行,也不能只依赖容器最终状态判断。
在使用 cgroup v2 的环境中,可在容器所属的实际 cgroup 目录读取:
cat memory.max
cat memory.current
cat memory.events
cat memory.stat
如果内核提供 memory.peak,也可以读取该文件,查看记录的峰值。关注构建前后 memory.events 中 oom、oom_kill 的变化,并结合宿主机内核日志确认。各字段定义见 Linux cgroup v2 文档。这些文件属于 cgroup v2,不能直接套用到 cgroup v1 的路径和字段上。
在 --memory=6g --memory-swap=6g 中,--memory-swap 表示内存与 swap 的总额度。两者相等意味着容器没有额外的 swap 额度,具体依赖宿主机支持。Docker 资源限制文档说明了这两个参数的关系。
用 NMT 分别观察两个 JVM
对于支持 NMT 的 HotSpot JVM,需要在启动时加入:
-XX:NativeMemoryTracking=summary
将它分别加到 Maven 主 JVM 的配置和 Surefire 的 argLine 后,在对应进程存活期间执行:
jcmd <PID> VM.command_line
jcmd <PID> VM.flags
jcmd <PID> VM.native_memory summary scale=MB
jcmd <PID> VM.native_memory baseline
进入内存较高的构建或测试阶段后,再查看变化:
jcmd <PID> VM.native_memory summary.diff scale=MB
读取结果时要区分 reserved 和 committed。保留的虚拟地址空间不等于实际驻留的物理内存,committed 也不能直接当作进程 RSS。
NMT 主要跟踪 HotSpot 内部的内存分配,不能覆盖所有第三方 native 分配,也不能完整解释容器占用。应结合进程 RSS、线程数量和 cgroup 指标观察,见 NMT 官方说明。
不要仅因 Metaspace 达到 600 MB 就直接调低 MaxMetaspaceSize。先确认这是稳定的类加载开销,还是随构建持续增长;限制过低只会让问题变成 OutOfMemoryError: Metaspace。
docker stats 高于 top,能否忽略?
不能直接把差值视为会自动消失的“幽灵内存”。需要先确认比较的是 top 的哪一列、哪个进程,以及两个工具的采样时间。
单个 Java 进程的 RES 与整个容器的统计范围不同。容器还可能包含其他进程、文件缓存及内核内存;VIRT 更不能用于比较物理内存用量。
Linux 上的 docker stats CLI 已经从总用量中扣除了特定缓存指标:cgroup v1 使用 total_inactive_file,cgroup v2 使用 inactive_file;Docker API 的原始统计则不自动进行该扣除。具体见 docker stats 文档。
部分文件缓存确实可以在压力下回收,但不能据此认定整个差值都安全。应以 cgroup 的内存构成、峰值和 OOM 事件为依据。
Maven 官方资料能提供什么?
Maven 官方资料说明如何配置主 JVM,Surefire 官方资料说明如何设置测试 JVM 参数和控制并发。这些文档没有给出适用于所有 Docker 构建的内存分配比例。
针对这个场景,可以先配置一个主 JVM、一个测试 JVM,分别设置 -Xmx,覆盖实际会运行的构建阶段并记录峰值,再调整堆大小和并发。若留出必要余量后,项目所需内存仍超过 6 GB,就需要减少同时运行的工作量、拆分构建或提高容器限制。
备注:内容仅供参考。
