这个博客里有两条线:《Ubuntu LTS 中安装 Docker 和 Docker Compose》站在容器外面看机器,《JVM 内存与 GC 定位实战》站在 JVM 里面看内存。
这篇是它们的交界处,回答一个很朴素的问题:同一个 jar,在宿主机上和在容器里,JVM 看到的”机器”是不是同一台?
不是同一台。默认堆大小、可用核数、甚至 GC 的选择都会变,而且这些变化是静默的——代码不报错,只是行为和你在本机测出来的不一样。下面每个数字都对应 JDK 25 与 Docker 的真实输出。

实验环境

1
2
3
4
5
6
7
8
9
10
$ java -version
java 25 2025-09-16 LTS
Java(TM) SE Runtime Environment (build 25+37-LTS-3491)
Java HotSpot(TM) 64-Bit Server VM (build 25+37-LTS-3491, mixed mode, sharing)

$ docker version --format '{{.Server.Version}}'
29.4.0

$ docker inspect eclipse-temurin:25-jre-alpine --format '{{.Architecture}}/{{.Os}}'
arm64/linux

宿主机是 M1 Pro(10 核),Docker Desktop 虚拟机分配了 12.5GB 内存。所有容器实验都使用 arm64 原生镜像:早先一次测量不小心用了 amd64 镜像跑模拟,同样的代码多花了几倍时间,跨架构的数字不能混在一张表里比。

探测程序只用 java.base,不需要任何额外依赖:

1
2
3
4
5
6
7
8
9
10
11
12
import java.lang.management.*;

public class ContProbe {
public static void main(String[] args) {
Runtime r = Runtime.getRuntime();
var os = (com.sun.management.OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
System.out.printf("最大堆 = %4d MB%n", r.maxMemory() / (1024*1024));
System.out.printf("可用核数 = %4d%n", r.availableProcessors());
System.out.printf("容器内存上限 = %4d MB (OperatingSystemMXBean.getTotalMemorySize)%n", os.getTotalMemorySize() / (1024*1024));
System.out.printf("物理内存 = %4d MB (getTotalPhysicalMemorySize)%n", os.getTotalPhysicalMemorySize() / (1024*1024));
}
}

一、同一份代码,容器里是另一台机器

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

两件事:

  1. 堆是按容器限额算的,不是按宿主机内存算的。512MB 的容器只拿到 123MB 堆。
  2. 核数按 cgroup 配额算,而且 --cpus 2.5 得到的是 3 而不是 2。

这两条来自 JDK 的容器感知(UseContainerSupport),默认就开着:

1
2
3
4
5
6
7
8
9
10
$ docker run --rm --memory 512m --cpus 1 -v /tmp/exp/out:/app:ro \
eclipse-temurin:25-jre-alpine java -XX:+PrintFlagsFinal -version 2>/dev/null | \
grep -E 'MaxHeapSize|MaxRAMPercentage|UseContainerSupport|ActiveProcessorCount|MaxDirectMemorySize|InitialRAMPercentage'
int ActiveProcessorCount = -1 {product} {default}
double InitialRAMPercentage = 1.562500 {product} {default}
uint64_t MaxDirectMemorySize = 0 {product} {default}
size_t MaxHeapSize = 134217728 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
size_t SoftMaxHeapSize = 134217728 {manageable} {ergonomic}
bool UseContainerSupport = true {product} {default}

注意 MaxHeapSize134217728 字节,正好 128MB;而上面程序打印的 Runtime.maxMemory() 是 123MB。差出来的 5MB 不是被谁偷走了MaxHeapSize 是堆的总大小,Runtime.maxMemory() 是”最多能用到的量”,其中要扣掉一个 survivor 空间之类的保留区域。排查堆到底是多是少,以 -XX:+PrintFlagsFinal 里的 MaxHeapSize 为准。

二、堆大小:默认只给限额的 25%

MaxRAMPercentage 默认值是 25,这一个数字决定了容器里 Java 服务的第一印象。

1
2
3
4
5
6
7
8
# 512MB 的容器,默认 25%:堆大约 128MB
$ docker run --rm --memory 512m -v /tmp/exp/out:/app:ro eclipse-temurin:25-jre-alpine java -cp /app ContProbe | head -1
最大堆 = 123 MB

# 显式给到 75%:留 25% 给堆外
$ docker run --rm --memory 512m -v /tmp/exp/out:/app:ro eclipse-temurin:25-jre-alpine \
java -XX:MaxRAMPercentage=75 -cp /app ContProbe | head -1
最大堆 = 371 MB

三个百分比旗标各管一段:

旗标 默认值(实测) 含义
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
2
3
4
5
$ docker run --rm --memory 512m -v /tmp/exp/out:/app:ro eclipse-temurin:25-jre-alpine \
java -XX:-UseContainerSupport -cp /app ContProbe
最大堆 = 3206 MB
可用核数 = 10
容器内存上限 = 12819 MB

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
2
3
4
5
# 实测:容器配额 1 核,JVM 认为自己有 4 核
$ docker run --rm --cpus 1 --memory 512m -v /tmp/exp/out:/app:ro eclipse-temurin:25-jre-alpine \
java -XX:ActiveProcessorCount=4 -cp /app ContProbe | head -2
最大堆 = 123 MB
可用核数 = 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
2
3
$ docker run --rm --cpus 1 --memory 512m -v /tmp/exp/out:/app:ro eclipse-temurin:25-jre-alpine \
java -Xlog:gc -cp /app Cpu 2>&1 | grep '\[gc\]' | head -1
[0.045s][info][gc] Using Serial

规律很干脆:可用核数为 1 时选 Serial,超过 1 就用 G1(内存给到 8GB 也一样)。Serial 是单线程 GC,小堆上停顿很短、开销最低;但如果你在 8 核机器上开发、在 1 核容器里部署,两边的 GC 行为完全是两套东西。上线前先看一眼这一行,比事后猜快得多。

五、两种死法:exit 1 与 exit 137

“容器里的 Java 挂了”有两类完全不同的原因,退出码把它们分开了。

第一类:堆不够用,JVM 自己抛错,进程正常退出:

1
2
3
4
5
$ docker run --rm --memory 256m -v /tmp/exp/out:/app:ro eclipse-temurin:25-jre-alpine \
java -Xmx25m -cp /app OomDemo heap
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at OomDemo.main(OomDemo.java:7)
# docker run 退出码 = 1

第二类:堆外用超了,cgroup 限额被击穿,内核直接杀进程——没有堆栈、没有日志、只有退出码:

1
2
3
4
5
6
7
$ docker run --name oom-native --memory 256m -v /tmp/exp/out:/app:ro eclipse-temurin:25-jre-alpine \
java -Xmx25m -XX:MaxDirectMemorySize=2000m -cp /app OomDemo direct
# (没有任何输出)
# docker run 退出码 = 137

$ docker inspect -f 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' oom-native
OOMKilled=true ExitCode=137

对照表:

症状 堆内耗尽 堆外/整体超限
输出 OutOfMemoryError: Java heap space 通常什么都没有
退出码 1 137(128+9,SIGKILL)
docker inspectOOMKilled false true
排查方向 堆 dump、对象引用 堆外:直接内存、Metaspace、线程栈、code cache

第二类里最坑的是:-XX:MaxDirectMemorySize 默认等于堆上限,所以你给堆 128MB、容器 512MB,直接内存也有 128MB 的额度,加上 Metaspace 与线程栈就可能超。要让 JVM 把堆外也交代清楚:

1
2
3
java -XX:NativeMemoryTracking=summary -XX:MaxRAMPercentage=75 -jar app.jar
# 运行中查看
jcmd <pid> VM.native_memory summary

经验规则--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
2
3
jlink --add-modules java.base,java.logging,java.naming,java.sql,java.xml,java.management,jdk.crypto.ec \
--no-header-files --no-man-pages --compress=zip-6 \
--output /opt/jre

体积之外还有两个好处:模块集固定,运行时不可能加载到你没声明的模块;--compress=zip-6lib/modules 变小,代价是启动时多一点点解压开销。

6.1 两个实测踩到的坑

坑一:官方 Temurin 镜像里没有 jmods,jlink 跑不了。

1
2
$ docker run --rm eclipse-temurin:25-jdk-alpine sh -c 'ls $JAVA_HOME/jmods | wc -l'
0

Temurin 的 Docker 镜像为了瘦身去掉了 jmods 目录,而 jlink 恰恰需要它。要 jlink 就得换一个带 jmods 的基底:

1
2
$ docker run --rm amazoncorretto:25-alpine sh -c 'ls $JAVA_HOME/jmods | wc -l'
69

(或者在构建阶段下载完整 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.timejava.util、正则,这些都在 java.base 里。模块漏了不会在构建时报错,只会在运行到那行代码时抛 NoClassDefFoundError,所以 jlink 的模块列表必须按真实依赖补全,稳妥做法是先在完整 JDK 上跑一遍 jdeps --print-module-deps app.jar

6.2 多阶段 Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 阶段一:用带 jmods 的镜像生成目标运行时
FROM amazoncorretto:25-alpine AS jre
RUN jlink \
--add-modules java.base,java.logging,java.naming,java.sql,java.xml,java.management,jdk.crypto.ec \
--no-header-files --no-man-pages --compress=zip-6 \
--output /opt/jre

# 阶段二:只带运行时
FROM alpine:3.22
RUN apk add --no-cache tzdata && adduser -D -u 10001 app
COPY --from=jre /opt/jre /opt/jre
WORKDIR /app
COPY app.jar /app/
USER app
ENTRYPOINT ["/opt/jre/bin/java", "-XX:MaxRAMPercentage=75", "-cp", "/app/app.jar", "com.example.Main"]

七、启动提速:AOT 缓存

JDK 24 引入了 AOT 类加载与链接(JEP 483Release 24),JDK 25 把它变成一条命令的事(JEP 514JEP 515,均为 Release 25)。原理是:把一次真实运行里”类加载 + 链接”的结果存成缓存文件,下次启动直接映射,省掉这部分工作。对短生命周期的容器(Serverless、Job、健康检查频繁的服务)收益最直接。

用法是两步:

1
2
3
4
5
6
7
8
9
# 1) 用一次真实运行生成缓存
$ java -XX:AOTCacheOutput=/app/app.aot -cp /app/startup.jar Startup
Temporary AOTConfiguration recorded: /app/app.aot.config
Launching child process /opt/java/openjdk/bin/java to assemble AOT cache /app/app.aot using configuration /app/app.aot.config
Reading AOTConfiguration /app/app.aot.config and writing AOTCache /app/app.aot
AOTCache creation is complete: /app/app.aot 12451840 bytes

# 2) 之后每次启动带上缓存
$ java -XX:AOTCache=/app/app.aot -cp /app/startup.jar Startup

在容器里交替跑 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
2
3
4
$ java -XX:AOTCacheOutput=/app/app.aot -cp /app/classes Startup
[error][aot] Error: non-empty directory '/app/aot-classes'
Error occurred during CDS dumping
Cannot have non-empty directory in paths

把类打成 jar 之后一次通过。所以要在 Dockerfile 里生成缓存的话,应用的打包方式(jar 还是目录)会直接决定这一步能不能做。

jlink 出来的精简运行时也能生成并使用 AOT 缓存(我用 48.6MB 的 7 模块运行时实测通过,缓存 11.6MB):

1
2
$ /opt/jre/bin/java -XX:AOTCacheOutput=/app/app.aot -cp /app/startup.jar Startup
AOTCache creation is complete: /app/app.aot 12124160 bytes

也就是说,瘦身和提速可以同时拿到,不需要为了 AOT 保留完整 JDK。把这一步放进多阶段构建的第三阶段即可:

1
2
3
4
5
# 阶段三:用精简运行时生成 AOT 缓存(需要先 COPY 进 jar)
FROM alpine:3.22 AS aot
COPY --from=jre /opt/jre /opt/jre
COPY app.jar /app/app.jar
RUN /opt/jre/bin/java -XX:AOTCacheOutput=/app/app.aot -cp /app/app.jar com.example.Main > /dev/null

构建时那一次运行会真的执行业务代码——如果 main 里有写库、发消息之类的副作用,就需要一个只做类加载的专用入口。

八、一份可以直接抄的运行参数

1
2
3
4
5
6
7
8
docker run -d --name java-app \
--memory 512m \
--cpus 2 \
-e JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError" \
--read-only --tmpfs /tmp \
--security-opt no-new-privileges \
-p 127.0.0.1:8080:8080 \
java-app:1.0

逐条对应本文的实测结论:

  • --memory 512mMaxRAMPercentage=75 配对使用:堆 371MB,留 141MB 给堆外,且堆的大小随 --memory 一起调整,不会出现”改了限额忘了改 -Xmx
  • --cpus 2 而不是 2.5:避免向上取整把 JVM 的核数认知带偏
  • -XX:+ExitOnOutOfMemoryError:堆 OOM 时立刻退出,让编排层的重启策略接管(否则可能出现”半死不活但进程还在”的状态)
  • --read-only--tmpfs /tmpno-new-privileges:来自《Docker 安装篇》里资源与权限收敛那一节
  • JAVA_TOOL_OPTIONS 而不是改 ENTRYPOINT:运维临时调整 JVM 参数不用重新构建镜像

总结

  • 容器里 JVM 看到的”机器”由 cgroup 配额决定:堆 = MaxRAMPercentage(默认 25%)× 内存限额,512MB 的容器默认只有 128MB 堆
  • MaxHeapSizeRuntime.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--cpusMaxRAMPercentage 三件套一起定下来,比事后调 GC 参数有效得多

参考资料

系列索引:Java 系列,语言特性与运行时的长文集