Java——容器里的 JVM:内存、CPU 与镜像
这个博客里有两条线:《Ubuntu LTS 中安装 Docker 和 Docker Compose》站在容器外面看机器,《JVM 内存与 GC 定位实战》站在 JVM 里面看内存。
这篇是它们的交界处,回答一个很朴素的问题:同一个 jar,在宿主机上和在容器里,JVM 看到的”机器”是不是同一台?
不是同一台。默认堆大小、可用核数、甚至 GC 的选择都会变,而且这些变化是静默的——代码不报错,只是行为和你在本机测出来的不一样。下面每个数字都对应 JDK 25 与 Docker 的真实输出。
实验环境
1 | $ java -version |
宿主机是 M1 Pro(10 核),Docker Desktop 虚拟机分配了 12.5GB 内存。所有容器实验都使用 arm64 原生镜像:早先一次测量不小心用了 amd64 镜像跑模拟,同样的代码多花了几倍时间,跨架构的数字不能混在一张表里比。
探测程序只用 java.base,不需要任何额外依赖:
1 | import java.lang.management.*; |
一、同一份代码,容器里是另一台机器
1 | docker run --rm -v /tmp/exp/out:/app:ro eclipse-temurin:25-jre-alpine java -cp /app ContProbe |
把四种环境下的输出放在一起看:
| 运行方式 | 最大堆 | 可用核数 | JVM 看到的”内存上限” |
|---|---|---|---|
| 宿主机(无容器) | 3206 MB | 10 | 12819 MB |
--memory 512m |
123 MB | 10 | 512 MB |
--memory 512m --cpus 1 |
123 MB | 1 | 512 MB |
--cpus 2.5(不限制内存) |
3206 MB | 3 | 12819 MB |
两件事:
- 堆是按容器限额算的,不是按宿主机内存算的。512MB 的容器只拿到 123MB 堆。
- 核数按 cgroup 配额算,而且
--cpus 2.5得到的是 3 而不是 2。
这两条来自 JDK 的容器感知(UseContainerSupport),默认就开着:
1 | $ docker run --rm --memory 512m --cpus 1 -v /tmp/exp/out:/app:ro \ |
注意 MaxHeapSize 是 134217728 字节,正好 128MB;而上面程序打印的 Runtime.maxMemory() 是 123MB。差出来的 5MB 不是被谁偷走了:MaxHeapSize 是堆的总大小,Runtime.maxMemory() 是”最多能用到的量”,其中要扣掉一个 survivor 空间之类的保留区域。排查堆到底是多是少,以 -XX:+PrintFlagsFinal 里的 MaxHeapSize 为准。
二、堆大小:默认只给限额的 25%
MaxRAMPercentage 默认值是 25,这一个数字决定了容器里 Java 服务的第一印象。
1 | # 512MB 的容器,默认 25%:堆大约 128MB |
三个百分比旗标各管一段:
| 旗标 | 默认值(实测) | 含义 |
|---|---|---|
InitialRAMPercentage |
1.5625 | 初始堆占限额的比例 |
MaxRAMPercentage |
25 | 最大堆占限额的比例 |
MinRAMPercentage |
50 | 小内存机器(低于约 200MB)时的最大堆比例 |
容器里该给多少?看你的服务在堆外花多少。堆外至少要放:直接内存(Netty、NIO)、Metaspace、线程栈(每个平台线程约 1MB 虚拟地址,虚拟线程另有载体线程栈)、JIT 的 code cache、AOT/CDS 映射区。给 75% 是一个常见起点,剩下的 25% 用来兜住这些;如果服务里 Netty 很重,就该降到 60%~70%,或者干脆用 -Xmx 写死一个绝对值,别让堆跟着 --memory 漂。
2.1 MaxDirectMemorySize=0 是什么意思
MaxDirectMemorySize=0 不是”禁止直接内存”,而是”没有显式设置”。此时它的默认值等于最大堆大小(-Xmx):堆给 128MB,直接内存默认上限也是 128MB,两者加起来 256MB,而容器只有 512MB——听起来够用,但别忘了还有 Metaspace 和线程栈。
2.2 关掉容器支持会怎样
1 | $ docker run --rm --memory 512m -v /tmp/exp/out:/app:ro eclipse-temurin:25-jre-alpine \ |
JVM 把宿主机的内存当成了自己的上限,申请 3GB 堆,而 cgroup 只给 512MB——结果就是进程在第一次大 GC 之前就被内核杀掉。这是 -XX:-UseContainerSupport 唯一值得记住的用法:别用它(它的存在是为了兼容极老的 cgroup 环境)。
三、核数:向上取整,不是向下
--cpus 的语义是”配额”,可以是小数。JVM 拿到的可用核数是这样取整的:
--cpus |
availableProcessors() |
|---|---|
| 1 | 1 |
| 1.1 | 2 |
| 1.5 | 2 |
| 2.1 | 3 |
| 2.5 | 3 |
| 3.9 | 4 |
| 10(不限制) | 10 |
规律是向上取整:只要配额超过整数,就多给你一个核。为什么在意这个?因为下面这些都跟着 availableProcessors() 走:
ForkJoinPool.commonPool()的并行度(默认核数 - 1)CompletableFuture默认使用的就是上面这个池parallelStream()的并行度- GC 的并行/并发线程数
- 不少框架(Netty、Spring 的调度器)的默认线程数
也就是说 --cpus 2.1 这样”顺手写”的配额,会让 JVM 认为有 3 个核。要精确控制就用官方旗标覆盖:
1 | # 实测:容器配额 1 核,JVM 认为自己有 4 核 |
ActiveProcessorCount 默认是 -1(表示”按容器感知自动算”);显式设置后,JVM 拿它当唯一的核数依据。
四、GC 会悄悄换掉
这一条最容易在排查性能问题时把人带偏。同一份代码,容器配额不同,JVM 选择的垃圾回收器也不同:
| 容器配额 | 选中的 GC(-Xlog:gc 第一行) |
|---|---|
--cpus 1 --memory 512m |
Using Serial |
--cpus 1 --memory 2g |
Using Serial |
--cpus 1 --memory 4g |
Using Serial |
--cpus 1 --memory 8g |
Using Serial |
--cpus 2 --memory 1792m |
Using G1 |
--cpus 4 --memory 2g |
Using G1 |
| 不限制(10 核) | Using G1 |
1 | $ docker run --rm --cpus 1 --memory 512m -v /tmp/exp/out:/app:ro eclipse-temurin:25-jre-alpine \ |
规律很干脆:可用核数为 1 时选 Serial,超过 1 就用 G1(内存给到 8GB 也一样)。Serial 是单线程 GC,小堆上停顿很短、开销最低;但如果你在 8 核机器上开发、在 1 核容器里部署,两边的 GC 行为完全是两套东西。上线前先看一眼这一行,比事后猜快得多。
五、两种死法:exit 1 与 exit 137
“容器里的 Java 挂了”有两类完全不同的原因,退出码把它们分开了。
第一类:堆不够用,JVM 自己抛错,进程正常退出:
1 | $ docker run --rm --memory 256m -v /tmp/exp/out:/app:ro eclipse-temurin:25-jre-alpine \ |
第二类:堆外用超了,cgroup 限额被击穿,内核直接杀进程——没有堆栈、没有日志、只有退出码:
1 | $ docker run --name oom-native --memory 256m -v /tmp/exp/out:/app:ro eclipse-temurin:25-jre-alpine \ |
对照表:
| 症状 | 堆内耗尽 | 堆外/整体超限 |
|---|---|---|
| 输出 | OutOfMemoryError: Java heap space |
通常什么都没有 |
| 退出码 | 1 | 137(128+9,SIGKILL) |
docker inspect 的 OOMKilled |
false | true |
| 排查方向 | 堆 dump、对象引用 | 堆外:直接内存、Metaspace、线程栈、code cache |
第二类里最坑的是:-XX:MaxDirectMemorySize 默认等于堆上限,所以你给堆 128MB、容器 512MB,直接内存也有 128MB 的额度,加上 Metaspace 与线程栈就可能超。要让 JVM 把堆外也交代清楚:
1 | java -XX:NativeMemoryTracking=summary -XX:MaxRAMPercentage=75 -jar app.jar |
经验规则:--memory 是给整个进程的,MaxRAMPercentage 是给堆的,两者之间必须有明确的留白;把 MaxRAMPercentage 拉到 90+ 的配置,就是把 137 的悬念留给未来。
六、镜像:从 439MB 到 42MB
运行时镜像的体积取决于你装了什么。同样跑一个 Hello 级别的应用,实测:
| 镜像 / 运行时 | 体积 |
|---|---|
eclipse-temurin:25-jdk(Ubuntu 基底) |
439 MB |
eclipse-temurin:25-jdk-alpine |
307 MB |
amazoncorretto:25-alpine |
372 MB |
eclipse-temurin:25-jre-alpine |
225 MB |
jlink 生成,只含 java.base |
42.4 MB |
jlink 生成,java.base + 6 个常用模块 |
48.6 MB |
jlink 只把需要的模块打成运行时:
1 | jlink --add-modules java.base,java.logging,java.naming,java.sql,java.xml,java.management,jdk.crypto.ec \ |
体积之外还有两个好处:模块集固定,运行时不可能加载到你没声明的模块;--compress=zip-6 让 lib/modules 变小,代价是启动时多一点点解压开销。
6.1 两个实测踩到的坑
坑一:官方 Temurin 镜像里没有 jmods,jlink 跑不了。
1 | $ docker run --rm eclipse-temurin:25-jdk-alpine sh -c 'ls $JAVA_HOME/jmods | wc -l' |
Temurin 的 Docker 镜像为了瘦身去掉了 jmods 目录,而 jlink 恰恰需要它。要 jlink 就得换一个带 jmods 的基底:
1 | $ docker run --rm amazoncorretto:25-alpine sh -c 'ls $JAVA_HOME/jmods | wc -l' |
(或者在构建阶段下载完整 JDK tarball,再删掉。)
坑二:--strip-debug 在 Alpine 上直接失败。
1 | Error: java.io.IOException: Cannot run program "objcopy": Exec failed, error: 2 (No such file or directory) |
jlink --strip-debug 需要 binutils 的 objcopy 来剥符号表,Alpine 基底默认没有。要么 apk add --no-cache binutils,要么去掉这个参数(上面表格里的体积就是没加 --strip-debug 的结果)。
只含 java.base 的 42MB 运行时,跑本文的示例应用完全没问题——因为它只用到 java.time、java.util、正则,这些都在 java.base 里。模块漏了不会在构建时报错,只会在运行到那行代码时抛 NoClassDefFoundError,所以 jlink 的模块列表必须按真实依赖补全,稳妥做法是先在完整 JDK 上跑一遍 jdeps --print-module-deps app.jar。
6.2 多阶段 Dockerfile
1 | # 阶段一:用带 jmods 的镜像生成目标运行时 |
七、启动提速:AOT 缓存
JDK 24 引入了 AOT 类加载与链接(JEP 483,Release 24),JDK 25 把它变成一条命令的事(JEP 514、JEP 515,均为 Release 25)。原理是:把一次真实运行里”类加载 + 链接”的结果存成缓存文件,下次启动直接映射,省掉这部分工作。对短生命周期的容器(Serverless、Job、健康检查频繁的服务)收益最直接。
用法是两步:
1 | # 1) 用一次真实运行生成缓存 |
在容器里交替跑 15 轮(每轮先跑不带缓存、再跑带缓存,避免磁盘缓存偏袒某一侧):
| 轮次 | 无缓存 | 有缓存 |
|---|---|---|
| 1 | 49 ms | 36 ms |
| 2 | 48 ms | 28 ms |
| 3 | 46 ms | 26 ms |
| … | … | … |
| 15 | 42 ms | 24 ms |
| 平均 | 43 ms | 27 ms |
启动时间从 43ms 降到 27ms,约 −37%,代价是一个 11.9MB 的缓存文件。应用越重(类越多),省下的绝对时间越多。
实测坑:创建缓存时 classpath 里不能出现目录,必须是 jar:
1 | $ java -XX:AOTCacheOutput=/app/app.aot -cp /app/classes Startup |
把类打成 jar 之后一次通过。所以要在 Dockerfile 里生成缓存的话,应用的打包方式(jar 还是目录)会直接决定这一步能不能做。
jlink 出来的精简运行时也能生成并使用 AOT 缓存(我用 48.6MB 的 7 模块运行时实测通过,缓存 11.6MB):
1 | $ /opt/jre/bin/java -XX:AOTCacheOutput=/app/app.aot -cp /app/startup.jar Startup |
也就是说,瘦身和提速可以同时拿到,不需要为了 AOT 保留完整 JDK。把这一步放进多阶段构建的第三阶段即可:
1 | # 阶段三:用精简运行时生成 AOT 缓存(需要先 COPY 进 jar) |
构建时那一次运行会真的执行业务代码——如果 main 里有写库、发消息之类的副作用,就需要一个只做类加载的专用入口。
八、一份可以直接抄的运行参数
1 | docker run -d --name java-app \ |
逐条对应本文的实测结论:
--memory 512m与MaxRAMPercentage=75配对使用:堆 371MB,留 141MB 给堆外,且堆的大小随--memory一起调整,不会出现”改了限额忘了改-Xmx“--cpus 2而不是2.5:避免向上取整把 JVM 的核数认知带偏-XX:+ExitOnOutOfMemoryError:堆 OOM 时立刻退出,让编排层的重启策略接管(否则可能出现”半死不活但进程还在”的状态)--read-only、--tmpfs /tmp、no-new-privileges:来自《Docker 安装篇》里资源与权限收敛那一节JAVA_TOOL_OPTIONS而不是改 ENTRYPOINT:运维临时调整 JVM 参数不用重新构建镜像
总结
- 容器里 JVM 看到的”机器”由 cgroup 配额决定:堆 =
MaxRAMPercentage(默认 25%)× 内存限额,512MB 的容器默认只有 128MB 堆 MaxHeapSize与Runtime.maxMemory()相差几 MB 是正常的(survivor 预留),排查时以旗标为准- 可用核数向上取整:
--cpus 2.5会让 JVM 认为有 3 个核,进而影响公共 ForkJoinPool、并行流和框架默认线程数 - 核数为 1 时 JVM 会选 Serial GC,不是 G1;容器配额写小了,GC 行为会静默变化
- 两种 OOM 要分开看:堆内是
OutOfMemoryError+ 退出码 1,堆外超限是无输出 + 退出码 137 +OOMKilled=true - 镜像瘦身靠 jlink,但官方 Temurin 镜像不含 jmods,需换基底;
--strip-debug在 Alpine 上要先装 binutils - JDK 24/25 的 AOT 缓存能把启动时间降三成左右,但生成缓存要求 classpath 是 jar
- 上生产时把
--memory、--cpus、MaxRAMPercentage三件套一起定下来,比事后调 GC 参数有效得多
参考资料
- JEP 483: Ahead-of-Time Class Loading & Linking
- JEP 514: Ahead-of-Time Command-Line Ergonomics
- JEP 515: Ahead-of-Time Method Profiling
- Docker:容器资源限制
- Docker:jlink 与多阶段构建
- Oracle:容器中的 Java(White Paper)
系列索引:Java 系列,语言特性与运行时的长文集






