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 内存资源文档 核对过。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。