512M 内存上限的容器里不写任何 JVM 参数,Spring Boot 的最大堆只有 128M,剩下的内存基本闲着。把 -Xmx 写得比容器上限还大,进程不抛 OutOfMemoryError,直接被内核杀掉,退出码 137。下面用 Spring Boot 4.1.1 和 JDK 21 在 Docker 里把这三种情况各跑了一遍,输出原样贴出。
环境和项目
项目从 start.spring.io 生成,Spring Boot 4.1.1,依赖只有 Spring Web 和 Actuator。镜像基于 eclipse-temurin:21-jre,容器里的 JVM 是 Temurin 21.0.12.1,Docker 26.1.5,宿主机 8 核 16G。实验只在本机 Docker 上做,没有部署到 Kubernetes 集群。
控制器有两个接口:/mem 返回 JVM 看到的最大堆和 CPU 数;/eat 按 MiB 往一个 List 里塞 byte 数组,模拟业务占用内存。
@RestController
public class MemController {
private final List<byte[]> hold = new ArrayList<>();
@GetMapping("/mem")
public Map<String, Object> mem() {
Runtime rt = Runtime.getRuntime();
return Map.of(
"maxHeapMiB", rt.maxMemory() / 1024 / 1024,
"cpus", rt.availableProcessors(),
"heldMiB", hold.size());
}
@GetMapping("/eat")
public Map<String, Object> eat(@RequestParam(defaultValue = "50") int mib) {
for (int i = 0; i < mib; i++) {
hold.add(new byte[1024 * 1024]);
}
return Map.of("heldMiB", hold.size());
}
}
Dockerfile 留了 JAVA_OPTS 用来在运行时换参数。用 exec 是为了让 java 成为 1 号进程,直接收到停止信号。
FROM eclipse-temurin:21-jre
COPY target/memdemo-0.0.1-SNAPSHOT.jar /app/app.jar
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
JVM 自己算出来的默认值
限制 512M 起一个容器,绕开 ENTRYPOINT 直接跑 java,只看最终生效的几个参数(-version 的输出走标准错误,所以版本信息也会显示出来):
$ docker run --rm -m 512m --entrypoint java memdemo:1 -XX:+PrintFlagsFinal -version | grep -E " MaxHeapSize | MaxRAMPercentage | UseContainerSupport |UseSerialGC |UseG1GC "
openjdk version "21.0.12.1" 2026-08-18 LTS
OpenJDK Runtime Environment Temurin-21.0.12.1+1 (build 21.0.12.1+1-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.12.1+1 (build 21.0.12.1+1-LTS, mixed mode, sharing)
size_t MaxHeapSize = 134217728 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseContainerSupport = true {product} {default}
bool UseG1GC = false {product} {default}
bool UseSerialGC = true {product} {ergonomic}
UseContainerSupport 默认开启,JVM 识别到了容器的 512M 上限。MaxRAMPercentage 默认是 25,最大堆就是 512M 的四分之一,134217728 字节,即 128MiB。
GC 被选成了 Serial。按 Oracle 的 JDK 21 GC 调优文档,JVM 检测到至少 2 个处理器、物理内存不少于 1792MB 时才算服务器级机器,默认选 G1。宿主机有 8 核,但容器内存只有 512M,于是退回 Serial。这条规则在 JDK 27 变了:JEP 523 随 JDK 27 交付,命令行没指定垃圾回收器时,JVM 不再看处理器数和物理内存,一律选 G1,显式指定的回收器仍然优先。本文跑的是 JDK 21,Serial 这个结果只对 21 成立。
场景一:什么都不配
$ docker run -d --name mem-default -m 512m -p 18081:8080 -e JAVA_OPTS="" memdemo:1
$ curl -s localhost:18081/mem
{"cpus":8,"maxHeapMiB":123,"heldMiB":0}
$ curl -s -o /dev/null -w '%{http_code}' 'localhost:18081/eat?mib=150'
500
$ docker logs mem-default 2>&1 | grep -m1 OutOfMemoryError
2026-10-13T00:08:23.815Z ERROR 1 --- [memdemo] [nio-8080-exec-5] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed: java.lang.OutOfMemoryError: Java heap space] with root cause
$ docker inspect -f '{{.State.Status}} OOMKilled={{.State.OOMKilled}}' mem-default
running OOMKilled=false
接口返回的 maxHeapMiB 是 123,比 128 小,因为 Runtime.maxMemory() 不算其中一个 survivor 区。
只申请 150M,容器还有几百兆空着,堆却先满了。请求返回 500,日志里是 Java heap space,容器本身还在运行,OOMKilled 为 false。监控上容器内存曲线看着正常,应用却在报 OOM。
场景二:按比例放大堆
$ docker run -d --name mem-75 -m 512m -p 18082:8080 -e JAVA_OPTS="-XX:MaxRAMPercentage=75.0" memdemo:1
$ curl -s localhost:18082/mem
{"cpus":8,"maxHeapMiB":371,"heldMiB":0}
$ curl -s -o /dev/null -w '%{http_code}' 'localhost:18082/eat?mib=150'
200
同样 512M,最大堆变成 371MiB。512 的 75% 是 384MiB,扣掉一个 survivor 区后 Runtime.maxMemory() 报 371,和场景一 128 对 123 是同一个原因。后文用 PrintFlagsFinal 看到的 MaxHeapSize 正好是 402653184 字节,即 384MiB。150M 的分配返回 200。
$ docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' mem-75
mem-75 316.7MiB / 512MiB
整个容器用了 316.7MiB。除了堆,还有元空间、线程栈、代码缓存、直接内存这些堆外开销,所以比例不能拉满。75 只是这次实验用的值,堆外内存用得多的应用(比如 Netty 大量用直接内存)比例要更低。
堆按比例设、不写死 -Xmx 的好处是,改了容器内存上限,堆会跟着变,不用再同步改启动参数。
场景三:-Xmx 比容器上限还大
$ docker run -d --name mem-xmx -m 512m -p 18083:8080 -e JAVA_OPTS="-Xmx1g" memdemo:1
$ curl -s localhost:18083/mem
{"heldMiB":0,"cpus":8,"maxHeapMiB":989}
$ curl -s -o /dev/null -w '%{http_code}' 'localhost:18083/eat?mib=100'
200
$ curl -s -o /dev/null -w '%{http_code}' 'localhost:18083/eat?mib=100'
200
$ curl -s -o /dev/null -w '%{http_code}' 'localhost:18083/eat?mib=100'
200
$ curl -s -o /dev/null -w '%{http_code}' 'localhost:18083/eat?mib=100'
000
$ curl -s -o /dev/null -w '%{http_code}' 'localhost:18083/eat?mib=100'
000
$ docker inspect -f '{{.State.Status}} ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}' mem-xmx
exited ExitCode=137 OOMKilled=true
显式的 -Xmx 优先于 MaxRAMPercentage,JVM 给了 989MiB 的堆。它以为堆还能涨到将近 1G,就继续扩堆。第四次分配时容器整体内存触到 cgroup 上限,被内核 OOM killer 杀掉,curl 拿到 000,连接直接断开。
这里没有 Java 异常,也没有堆栈,应用日志里看不到任何痕迹,只能从 ExitCode=137 和 OOMKilled=true 判断。按 Kubernetes 文档,同样的情况在 Pod 里表现为容器 lastState 的 reason 是 OOMKilled,随后按重启策略重启。
所以排查时可以这样分:日志里有 OutOfMemoryError: Java heap space、容器还活着,是堆给小了;容器退出码 137、OOMKilled 为 true、应用日志干干净净,是堆加堆外内存超过了容器上限。
对应到 Kubernetes 的配置
按上面的结果,Deployment 里 requests 和 limits 的内存设成一样,堆按比例给:
containers:
- name: app
image: memdemo:1
resources:
requests:
memory: "512Mi"
limits:
memory: "512Mi"
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=75.0"
这里用 JAVA_TOOL_OPTIONS,不用 JAVA_OPTS,因为它不依赖镜像的启动脚本。JVM TI 规范定义了这个变量,JVM 启动时自己读取,并在标准错误打印 Picked up JAVA_TOOL_OPTIONS。在 Docker 里验证过它确实生效,堆上限变成了 384MiB:
$ docker run --rm -m 512m -e JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0" --entrypoint java memdemo:1 -XX:+PrintFlagsFinal -version 2>&1 | grep -E "Picked up| MaxHeapSize "
Picked up JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=75.0
size_t MaxHeapSize = 402653184 {product} {ergonomic}
参考来源
实验思路参考了 Baeldung 的 How To Configure Java Heap Size Inside a Docker Container,参数和默认值按 Java SE 21 java 命令文档、GC 调优指南 Ergonomics 章节(JDK 21、JDK 27)、JEP 523、JVM TI 规范、Docker 资源限制文档 和 Kubernetes 内存资源文档 核对过。
一起交流
分享你的思考,让讨论更进一步。