Docker 基础体系 · 第 37/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Java 应用 Docker 镜像:JLink、Class Data Sharing、内存和 JVM 参数

Java 应用镜像的体积、启动速度和内存表现,通常不是由某一个参数决定的,而是由四层因素共同决定:

  1. 构建产物:应用 JAR、依赖和资源是否固定。
  2. 运行时:是否把完整 JDK 缩减为应用所需的模块。
  3. 启动过程:类加载和 JVM 元数据是否可以复用。
  4. 资源边界:JVM 是否正确识别 Docker 设置的 Linux cgroup 限制。

因此,jlink、Class Data Sharing(CDS)和 -Xmx 并不是三个可以随意叠加的优化开关。它们分别作用于不同阶段:

源码/依赖
   │
   ▼
编译与打包 ──► 应用 JAR
   │
   ▼
jlink ───────► 定制 Java 运行时
   │
   ▼
CDS/AppCDS ──► 预先生成的类共享归档
   │
   ▼
Docker 运行 ─► cgroups、JVM 参数、应用负载

本文中的命令以现代 Docker Engine、BuildKit、Compose 规范和 Linux 容器为前提。Docker 容器在 Windows 或 macOS 上运行时,容器内的 JVM 看到的仍然是 Docker Linux 虚拟机提供的 Linux cgroup 环境,而不是宿主机原生进程环境。


1. 先区分镜像体积、进程内存和启动时间

这三个指标经常被混为一谈,但它们的因果关系不同。

1.1 镜像体积是磁盘和传输问题

Docker 镜像由分层文件系统组成。一个镜像可能包含:

  • JDK 工具;
  • 编译产物;
  • Maven 或 Gradle 缓存;
  • 应用 JAR;
  • 定制后的 Java 运行时;
  • 操作系统文件和证书。

镜像体积主要影响:

  • 拉取时间;
  • 镜像仓库存储;
  • 节点上的磁盘占用;
  • 冷启动时的文件解压和挂载成本。

它不等于 Java 进程的 RSS,也不等于 Docker 的内存限制。

1.2 进程内存是 cgroup 和内核回收问题

一个 Java 进程的内存至少包括:

RH+M+C+T×S+D+N+ER \approx H + M + C + T \times S + D + N + E

其中:

  • RR:进程实际占用的内存,常用 RSS 近似观察;
  • HH:Java heap,存放普通 Java 对象;
  • MM:Metaspace,存放类元数据;
  • CC:Code Cache,存放 JIT 编译后的机器码;
  • TT:线程数量;
  • SS:每个线程栈的保留或提交空间;
  • DD:Direct Buffer 等显式堆外内存;
  • NN:JVM 和本地库使用的其他 native memory;
  • EE:类加载、JIT、GC、动态库、临时缓冲等弹性开销。

-Xmx 只约束 HH 的上界,不能约束整个 RR

1.3 启动时间受类加载和 JIT 影响

Java 启动通常经历:

  1. 启动 JVM;
  2. 解析模块和类路径;
  3. 加载类;
  4. 验证和链接类;
  5. 初始化类;
  6. 编译热点方法;
  7. 应用开始对外提供服务。

jlink 主要减少运行时文件和模块初始化范围;CDS 主要减少一部分类加载、元数据创建和链接成本。二者都不能消除应用自身的初始化逻辑,例如:

  • 连接数据库;
  • 建立连接池;
  • 读取远程配置;
  • 扫描大量类;
  • 创建缓存;
  • 等待服务发现。

2. Docker 多阶段构建:让构建工具停留在构建阶段

多阶段构建的核心不是“写两个 FROM”,而是让最终阶段只复制运行所需的文件。

一个典型的 Java 镜像可以分成:

构建阶段:
  JDK + 编译器 + 构建工具 + 源码 + 依赖缓存
       │
       ├── app.jar
       ├── jlink 运行时
       └── CDS 归档
              │
              ▼
运行阶段:
  精简 Linux 用户空间 + jlink 运行时 + app.jar + CDS 归档

如果最终阶段使用:

COPY --from=build / /

那么构建阶段中的缓存、源码、编译器甚至整个 JDK 都可能进入最终镜像,失去多阶段构建的意义。正确做法是显式复制需要的文件。

Dockerfile 的每条指令都会形成构建缓存边界。通常应先复制依赖描述文件,再复制变化频繁的源码,以便 BuildKit 复用依赖下载层。实际 Maven 或 Gradle 项目还应使用 BuildKit cache mount 保存依赖缓存,但缓存不应被复制到最终镜像。

例如 Maven 构建阶段可以写成:

# syntax=docker/dockerfile:1

FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src

COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 \
    mvn -B -DskipTests dependency:go-offline

COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
    mvn -B -DskipTests package

这里的 --mount=type=cache 是 BuildKit 的构建缓存挂载:

  • 它可以加快重复构建;
  • 它不应被当作最终镜像内容;
  • 使用旧版、不支持 BuildKit 的构建器时,需要确认 Dockerfile 语法和构建方式。

dependency:go-offline 不能保证所有插件和动态构建依赖都被提前下载,但它通常能显著改善常规 Maven 项目的缓存命中率。真正决定构建可重复性的仍然是锁定的版本、仓库内容和基础镜像摘要,而不是 Docker cache 本身。


3. JLink:从模块化 JDK 生成应用专用运行时

3.1 JLink 到底做了什么

jlink 是 JDK 提供的工具,用于把一组 Java 模块及其依赖组合成一个自包含运行时镜像。

它生成的不是传统意义上的“完整 JRE”,而是一个根据模块图裁剪出来的 Java runtime image。例如只需要 java.base 的程序,不必携带:

  • java.sql
  • java.naming
  • java.desktop
  • jdk.compiler
  • jdk.jlink
  • 其他未使用模块。

模块依赖满足一个闭包条件:

Mruntimeclosure(Mapp)M_{\text{runtime}} \supseteq \operatorname{closure}(M_{\text{app}})

其中:

  • MappM_{\text{app}} 是应用直接需要的模块;
  • closure 表示递归加入这些模块的 requires 依赖;
  • 运行时模块集合至少要覆盖这个闭包。

如果缺少模块,应用可能在启动时失败,而不是在 jlink 阶段失败。例如:

java.lang.module.FindException
java.lang.NoClassDefFoundError
java.lang.ClassNotFoundException

3.2 一个可运行的最小示例

下面的 Dockerfile 使用 Java 21,编译一个只依赖 java.base 的程序,并生成定制运行时。

项目目录:

.
├── Dockerfile
└── Hello.java

Hello.java

public class Hello {
    public static void main(String[] args) {
        System.out.println("hello from " + System.getProperty("java.version"));
    }
}

Dockerfile:

# syntax=docker/dockerfile:1

FROM eclipse-temurin:21-jdk-jammy AS build
WORKDIR /src

COPY Hello.java .

RUN javac -d classes Hello.java \
    && jar --create --file app.jar -C classes .

# 让构建阶段中的路径与最终阶段保持一致,
# 这对后面生成和使用 CDS 归档很重要。
RUN mkdir -p /opt/app \
    && cp app.jar /opt/app/app.jar

RUN jlink \
      --add-modules java.base \
      --strip-debug \
      --no-man-pages \
      --no-header-files \
      --compress=2 \
      --output /opt/jre

FROM ubuntu:22.04 AS runtime
COPY --from=build /opt/jre /opt/jre
COPY --from=build /opt/app/app.jar /opt/app/app.jar

ENV JAVA_HOME=/opt/jre
ENV PATH=/opt/jre/bin:$PATH

ENTRYPOINT ["/opt/jre/bin/java", "-cp", "/opt/app/app.jar", "Hello"]

构建和运行:

docker build -t hello-jlink .
docker run --rm hello-jlink

预期输出类似:

hello from 21.0.x

这里使用 ubuntu:22.04 作为运行基础层,是因为构建阶段使用了 Ubuntu Jammy 系列的 JDK。JLink 生成的 Java 运行时仍然依赖 Linux 用户空间中的动态链接器和系统库;它不是一个完全脱离操作系统的静态二进制。构建阶段和运行阶段最好使用兼容的 glibc 基础环境。

3.3 JLink 不是“自动识别所有依赖”

对于普通模块化应用,可以使用:

jdeps --print-module-deps --ignore-missing-deps app.jar

它会尝试根据字节码引用推导模块依赖,然后将结果传给 jlink

MODULES="$(jdeps \
  --print-module-deps \
  --ignore-missing-deps \
  --multi-release 21 \
  app.jar)"

jlink --add-modules "$MODULES" --output /opt/jre

但这个结果不能盲信,原因包括:

  • 反射访问的类可能不出现在静态字节码引用中;
  • ServiceLoader 使用的实现可能通过服务配置发现;
  • 动态代理和框架扫描可能在运行时决定类;
  • JNI 库不等于 Java 模块;
  • 多模块或 fat JAR 的依赖分析可能需要分别分析;
  • --ignore-missing-deps 会隐藏部分缺失依赖,适合诊断,不适合无验证地生成生产运行时。

如果应用使用服务提供者,可能需要额外考虑 --bind-services。如果应用使用 TLS,还要注意:

  • jlink 只处理 Java 模块;
  • Linux 系统 CA 证书通常仍由基础镜像中的 ca-certificates 提供;
  • jdk.crypto.ec 等加密模块可能是 HTTPS、椭圆曲线证书或某些安全协议所需的模块。

因此,生成运行时后必须用真实应用启动并执行关键路径验证,而不是只检查 jlink 命令是否成功。

3.4 JLink 与 Alpine 的边界

Alpine Linux 通常使用 musl libc,而许多 JDK 构建和 Java 应用原生库更常见于 glibc 环境。使用 Alpine 并不自动带来更小或更稳定的 Java 镜像,可能遇到:

  • JNI 库缺少 glibc;
  • 字体、时区、证书或 DNS 行为差异;
  • 某些监控、压缩、数据库驱动的本地库不兼容;
  • 诊断工具和常用系统命令缺失。

如果选择 Alpine,应明确确认所用 JDK 发行版和所有 JNI 依赖支持 musl。否则,使用 Debian、Ubuntu 或其他 glibc 基础镜像通常更容易验证。


4. Class Data Sharing:共享类元数据,而不是共享整个应用进程

4.1 CDS 的基本机制

Class Data Sharing(CDS)允许 JVM 将一部分类加载结果保存为归档文件,后续启动时通过内存映射复用这些数据。

它解决的是:

每次启动:
  读取 class 文件
  解析
  验证
  创建类元数据
  链接

变成部分复用:

首次生成归档:
  加载类并记录可归档数据
  写入共享归档

后续启动:
  映射共享归档
  复用符合条件的类数据
  只处理未归档或不匹配的部分

CDS 共享的是 JVM 级别的类数据,通常通过只读、可共享的内存页减少重复工作。它不是:

  • 把 Java 堆快照恢复到另一个进程;
  • 保存数据库连接;
  • 保存线程状态;
  • 保存任意对象的运行时状态;
  • 替代 JIT 编译;
  • 让多个容器共享同一个进程。

AppCDS 是 CDS 的应用扩展,可以把应用类也加入归档。JDK 自带的基础类共享归档和应用类归档需要区分:

  • 默认 CDS:通常由 JDK 发行版提供,主要覆盖 JDK 类;
  • AppCDS:根据具体应用运行时加载的类生成,能进一步覆盖应用类;
  • 动态归档:在运行过程中通过 -XX:ArchiveClassesAtExit 生成归档,适合从真实运行路径收集类,但需要设计归档的生成、保存和部署流程。

4.2 静态 AppCDS 的完整示例

在前面的构建阶段继续加入以下步骤:

FROM eclipse-temurin:21-jdk-jammy AS build
WORKDIR /src

COPY Hello.java .

RUN javac -d classes Hello.java \
    && jar --create --file app.jar -C classes . \
    && mkdir -p /opt/app \
    && cp app.jar /opt/app/app.jar

RUN jlink \
      --add-modules java.base \
      --strip-debug \
      --no-man-pages \
      --no-header-files \
      --compress=2 \
      --output /opt/jre

# 第一步:运行应用,收集实际加载到的类
RUN /opt/jre/bin/java \
      -Xshare:off \
      -XX:DumpLoadedClassList=/opt/app/classes.lst \
      -cp /opt/app/app.jar \
      Hello

# 第二步:根据类列表生成静态共享归档
RUN /opt/jre/bin/java \
      -Xshare:dump \
      -XX:SharedClassListFile=/opt/app/classes.lst \
      -XX:SharedArchiveFile=/opt/app/app.jsa \
      -cp /opt/app/app.jar

FROM ubuntu:22.04 AS runtime
COPY --from=build /opt/jre /opt/jre
COPY --from=build /opt/app/app.jar /opt/app/app.jar
COPY --from=build /opt/app/app.jsa /opt/app/app.jsa

ENV JAVA_HOME=/opt/jre
ENV PATH=/opt/jre/bin:$PATH

ENTRYPOINT [
  "/opt/jre/bin/java",
  "-Xshare:on",
  "-XX:SharedArchiveFile=/opt/app/app.jsa",
  "-cp",
  "/opt/app/app.jar",
  "Hello"
]

Dockerfile 的 JSON 数组形式不能跨行随意断开。实际文件应写成单行:

ENTRYPOINT ["/opt/jre/bin/java", "-Xshare:on", "-XX:SharedArchiveFile=/opt/app/app.jsa", "-cp", "/opt/app/app.jar", "Hello"]

也可以改成脚本形式,但必须使用 exec

#!/bin/sh
exec /opt/jre/bin/java \
  -Xshare:on \
  -XX:SharedArchiveFile=/opt/app/app.jsa \
  -cp /opt/app/app.jar \
  Hello

如果使用脚本,Dockerfile 应为:

COPY entrypoint.sh /opt/app/entrypoint.sh
RUN chmod 755 /opt/app/entrypoint.sh
ENTRYPOINT ["/opt/app/entrypoint.sh"]

exec 会让 Java 进程替换 Shell,成为容器中的 PID 1,从而更直接地接收 Docker 发送的 SIGTERM。如果脚本不使用 exec,Shell 可能成为 PID 1,信号转发和优雅退出会变得不可靠。

4.3 生成类列表不是“运行一次就完整”

上例中的第一次运行只执行了 Hello.main。如果真实应用存在以下路径:

  • HTTP 请求处理;
  • 数据库驱动初始化;
  • JSON 序列化;
  • TLS 握手;
  • 动态代理;
  • 反射扫描;
  • 监控代理;

那么没有在采集阶段加载的类可能不会进入 AppCDS 归档。程序仍然可以运行,但这些类无法享受归档带来的收益。

更严格的流程是:

  1. 以和生产一致的 JVM、JAR、启动参数启动应用;
  2. 执行代表性启动路径;
  3. 访问关键接口;
  4. 覆盖常用框架和驱动路径;
  5. 生成归档;
  6. 在相同运行时和相同文件布局中验证。

这体现了 CDS 的一个重要条件:

可复用=JVM 版本匹配归档格式匹配类路径/模块路径匹配运行参数满足约束\text{可复用} = \text{JVM 版本匹配} \land \text{归档格式匹配} \land \text{类路径/模块路径匹配} \land \text{运行参数满足约束}

任意一项不成立,JVM 都可能放弃部分或全部归档内容。

4.4 为什么路径和版本不能随意改变

上例在 /opt/app/app.jar 位置生成归档,并在最终镜像的同一路径使用它。如果构建时使用:

/src/app.jar

运行时却复制为:

/app/app.jar

即使 JAR 内容相同,也可能因为类路径信息不一致而降低归档可用性或导致归档无法使用。

以下变化都应视为需要重新生成 CDS 归档:

  • Java 更新版本或补丁版本;
  • JLink 运行时重新生成;
  • 应用 JAR 内容变化;
  • 依赖 JAR 变化;
  • 类路径顺序变化;
  • 模块路径变化;
  • 影响类加载的 JVM 参数变化;
  • 使用了不同的基础运行环境或本地库。

生产环境可使用:

java -Xshare:on ...

-Xshare:on 要求共享归档可用,归档不匹配时倾向于直接失败,适合把构建错误尽早暴露。排查问题时可以暂时使用:

java -Xshare:auto ...

或者:

java -Xshare:off ...

但不能把自动回退误认为 CDS 一定生效。可以通过启动日志验证:

java -Xlog:cds=info \
     -XX:SharedArchiveFile=/opt/app/app.jsa \
     -cp /opt/app/app.jar \
     Hello

日志可能显示归档被映射、部分使用或因为不匹配而未使用。不同 JDK 版本的日志细节会变化,因此应以实际 JDK 的输出为准。


5. 镜像中是否应该保留完整 JDK

JLink 运行时通常不包含:

  • javac
  • jcmd
  • jmap
  • jstack
  • jfr 命令;
  • JDK 源码和头文件;
  • 构建工具。

这使运行镜像更小,但也减少了在线诊断能力。一个常见设计是构建两个镜像:

生产镜像:
  jlink runtime + app.jar + CDS archive

调试镜像:
  完整 JDK + app.jar + 调试工具

调试镜像不应该默认运行在生产流量路径中,否则可能因为工具、符号和额外文件增加攻击面和镜像体积。

如果生产容器只有精简运行时,可以采用以下诊断方式:

  • 在同一 JDK 版本、同一架构、同一启动参数下准备调试镜像;
  • 使用 JFR、JMX、OpenTelemetry 等预先设计的观测方式;
  • 将诊断工具作为临时调试层,而不是永久写入生产容器;
  • 在启动时启用必要的 -Xlog
  • 保留崩溃日志和 GC 日志输出路径。

直接把 jcmd 从另一个 JDK 复制进精简运行时并不可靠。JDK 工具可能依赖对应版本的动态库、模块和文件布局,工具版本也必须与目标 JVM 兼容。


6. Docker 内存限制和 JVM 堆参数不是同一个边界

6.1 Docker 的 --memory 是 cgroup 边界

例如:

docker run --rm \
  --memory=512m \
  --memory-swap=512m \
  --cpus=1 \
  --pids-limit=256 \
  my-java-app

这里:

  • --memory=512m 设置容器内存硬限制;
  • --memory-swap=512m 在常见 Linux 配置下表示内存与 swap 总量也限制在 512 MiB,实际行为仍取决于宿主机和 cgroup 配置;
  • --cpus=1 限制 CPU 配额;
  • --pids-limit=256 限制容器可创建的进程和线程数量。

--memory=512m 不是告诉 JVM “堆是 512 MiB”,而是告诉 Linux cgroup:这个 cgroup 的整体内存不能超过该值。

6.2 JVM 的 -Xmx 只限制 Java heap

例如:

java -Xmx320m -jar app.jar

表示最大堆接近 320 MiB,但以下内存仍可能继续增长:

  • Metaspace;
  • 线程栈;
  • Direct Buffer;
  • JIT Code Cache;
  • JNI 和动态库;
  • GC 数据结构;
  • JVM 内部 native allocation;
  • 类加载和框架缓存。

假设容器限制是 512 MiB,粗略预算为:

容器硬限制             512 MiB
保留安全余量             64 MiB
非堆和 native 预算      128 MiB
允许的最大堆            320 MiB

推导为:

HmaxLSheadroomNnonheapH_{\max} \leq L - S_{\text{headroom}} - N_{\text{nonheap}}

代入:

Hmax51264128=320 MiBH_{\max} \leq 512 - 64 - 128 = 320\text{ MiB}

这不是 JVM 的精确内存模型,而是资源治理时的预算起点。若应用创建大量线程、使用 Netty Direct Buffer、加载大量类或包含大型 JNI 库,128 MiB 可能远远不够。

6.3 MaxRAMPercentage 的含义

现代 HotSpot 会读取容器的 cgroup 内存限制,并据此进行部分人体工学参数计算。可以使用:

java \
  -XX:InitialRAMPercentage=25 \
  -XX:MaxRAMPercentage=60 \
  -XX:MinRAMPercentage=20 \
  -jar app.jar

含义是:

  • InitialRAMPercentage:根据可见内存估算初始堆;
  • MaxRAMPercentage:根据可见内存估算最大堆;
  • MinRAMPercentage:在较小内存场景下使用的另一套最小内存比例规则。

实际行为还受 JVM 版本、收集器、显式 -Xms/-Xmx 和其他人体工学规则影响。显式指定 -Xmx 时,不应再假设 MaxRAMPercentage 会继续控制堆上限。

例如,若 JVM 正确看到容器上限为 512 MiB:

MaxRAMPercentage=60
估算最大堆 ≈ 512 × 60% = 307.2 MiB

但这个 307.2 MiB 仍不是容器可用的全部内存。它只表示 heap 预算。

在内存上限稳定、应用行为稳定的服务中,显式 -Xmx 更容易审计;在多个环境使用不同容器上限的服务中,百分比参数更便于复用。无论哪种方式,都必须给 Metaspace、线程、Direct Buffer 和 native memory 留出空间。


7. 线程栈、Metaspace 和 Direct Buffer 的实际风险

7.1 线程栈

-Xss 设置单个 Java 线程栈的最大大小,例如:

-Xss512k

如果有 300 个线程,粗略保留量可能达到:

300×512 KiB150 MiB300 \times 512\text{ KiB} \approx 150\text{ MiB}

这只是帮助理解上界,不代表 RSS 一定立刻增加 150 MiB。线程栈可能按需提交,操作系统还会使用保护页和其他线程相关结构。

降低 -Xss 不能简单理解为“越小越好”:

  • 递归较深的代码可能触发 StackOverflowError
  • 复杂框架调用链可能需要更多栈空间;
  • 大量线程本身通常说明线程模型或阻塞策略需要检查。

7.2 Metaspace

Metaspace 存放类元数据,默认主要受 native memory 和类加载行为影响。动态生成大量类时,可能出现:

java.lang.OutOfMemoryError: Metaspace

可以设置:

-XX:MaxMetaspaceSize=128m

但这个参数是故障隔离手段,不是无代价的优化。设置过低会让正常应用因类元数据不足失败;完全不设置又可能使类加载泄漏逐渐吃掉 cgroup 预算。

常见高风险来源包括:

  • 热部署反复创建 ClassLoader;
  • 动态代理类无限增长;
  • 脚本引擎动态生成类;
  • 大型框架扫描和代码生成;
  • 组件卸载失败导致旧 ClassLoader 无法回收。

7.3 Direct Buffer

NIO 和 Netty 等组件可能在 Java heap 外分配 Direct Buffer。可以设置:

-XX:MaxDirectMemorySize=128m

它限制的是特定类别的直接内存,不等于所有 native memory。若应用使用 TLS、压缩、网络框架或高吞吐 I/O,应根据实际缓冲策略测试,而不是把这个值随意设置成堆大小。


8. JVM 是否真的看到了容器限制

现代 HotSpot 通常支持容器感知,并在 Linux cgroup 中读取:

  • 内存上限;
  • CPU 配额和周期;
  • CPU 集合;
  • 可用处理器数量;
  • 部分进程限制。

但“现代”不是一个可替代验证的版本号。应在目标 JDK 和目标容器中检查。

查看 JVM 参数:

docker run --rm \
  --memory=512m \
  my-java-app \
  -XX:+PrintFlagsFinal \
  -version

查看容器感知日志:

docker run --rm \
  --memory=512m \
  my-java-app \
  -Xlog:os+container=info \
  -version

需要诊断更详细信息时,可以临时使用:

-Xlog:os+container=trace

具体日志标签和输出会随 JDK 版本变化。若应用在容器里把宿主机全部 CPU 当作可用处理器,可能导致:

  • 创建过多 GC 线程;
  • 创建过多 ForkJoinPool 线程;
  • 并行任务过度竞争;
  • CPU 配额下出现调度延迟。

这时可以显式限制:

-XX:ActiveProcessorCount=2

它让 JVM 及部分基于 Runtime.availableProcessors() 的组件按 2 个处理器进行计算。这个参数只是 JVM 视角的逻辑 CPU 数,不会解除 Docker 的 CPU cgroup 限制。


9. JVM 参数的组合方式

推荐把 Docker 资源约束、JVM 参数和应用参数分成三层:

Docker/cgroup:
  --memory、--cpus、--pids-limit

JVM:
  -Xmx、MaxMetaspaceSize、MaxDirectMemorySize、Xss

应用:
  连接池大小、线程池大小、缓存大小、批量大小

例如:

java \
  -XX:MaxRAMPercentage=60 \
  -XX:MaxMetaspaceSize=128m \
  -XX:MaxDirectMemorySize=128m \
  -Xss512k \
  -XX:ActiveProcessorCount=2 \
  -jar app.jar

如果使用 -Xmx,则可以改为:

java \
  -Xms320m \
  -Xmx320m \
  -XX:MaxMetaspaceSize=128m \
  -XX:MaxDirectMemorySize=128m \
  -Xss512k \
  -XX:ActiveProcessorCount=2 \
  -jar app.jar

固定 -Xms=-Xmx 的效果是减少运行时扩堆变化,但会更早提交较多堆内存,在小内存容器中可能增加启动压力。它不是普遍适用的规则。

一个更接近实际的预算例子:

cgroup 限制:512 MiB

Java heap 上限:300 MiB
Metaspace:     80 MiB
Direct memory: 64 MiB
线程栈:        40 MiB
JVM/native:    20 MiB
安全余量:       8 MiB

总和:

300+80+64+40+20+8=512 MiB300 + 80 + 64 + 40 + 20 + 8 = 512\text{ MiB}

这个配置几乎没有故障缓冲区,生产上通常应继续降低堆或其他可控项。尤其是“线程栈 40 MiB”只是估计值,线程数量增长后会立即失效。

更合理的做法是通过压测观测峰值,再确定:

H+M+C+T×S+D+NLheadroomH + M + C + T \times S + D + N \leq L - \text{headroom}

其中 headroom 应覆盖突发流量、GC 峰值、类加载峰值和内核记账误差,而不是只按平均值计算。


10. Docker Compose 中声明资源和 JVM 参数

一个 Compose 服务可以写成:

services:
  app:
    build:
      context: .
    image: my-java-app:21
    mem_limit: 512m
    cpus: "1.0"
    pids_limit: 256
    stop_grace_period: 30s
    environment:
      JAVA_TOOL_OPTIONS: >-
        -Xmx300m
        -XX:MaxMetaspaceSize=80m
        -XX:MaxDirectMemorySize=64m
        -Xss512k
        -XX:ActiveProcessorCount=1
        -Xlog:gc*:stdout:time,level,tags

这里的 JAVA_TOOL_OPTIONS 会被 JVM 启动器读取,适合注入通用 JVM 参数。但它也可能影响所有 Java 命令,包括健康检查、迁移脚本和管理命令,因此大型项目通常会明确区分:

ENTRYPOINT ["/opt/app/entrypoint.sh"]
#!/bin/sh
set -eu

exec "$JAVA_HOME/bin/java" \
  ${JAVA_OPTS:-} \
  -jar /opt/app/app.jar

JAVA_OPTS 由 Shell 展开,包含空格和特殊字符时要谨慎。若参数来源不可信,不能直接拼接。更严格的脚本可以使用数组,但 POSIX /bin/sh 不支持 Bash 数组;在精简镜像中应根据实际 Shell 选择实现。

mem_limit 是 Compose 服务级资源字段。deploy.resources 在不同 Compose 实现和部署模式下的支持范围可能不同,不能仅因为配置文件被接受就推断资源限制已经由当前运行器执行。部署后应通过容器检查和 cgroup 文件验证。


11. OOM:Java 抛异常和内核杀进程是两条不同路径

11.1 Java 自己发现堆不足

当 Java heap 达到上限且 GC 无法回收足够对象时,JVM 可能抛出:

java.lang.OutOfMemoryError: Java heap space

这通常是 HH-Xmx 限制后的结果。进程可能生成 heap dump,也可能因为磁盘、权限或容器空间不足而生成失败。

其他 Java 层错误包括:

java.lang.OutOfMemoryError: Metaspace
java.lang.OutOfMemoryError: Direct buffer memory
java.lang.StackOverflowError

它们分别指向不同资源,不应都通过增加 -Xmx 解决。

11.2 Linux cgroup 先杀进程

如果进程整体内存超过 cgroup 限制,内核可能触发 cgroup OOM,并直接杀死进程。此时 Java 可能来不及打印异常。

典型表现:

docker inspect <container>

看到退出码:

137

137 通常等于:

128+9=137128 + 9 = 137

表示进程被 SIGKILL 终止,但退出码 137 本身不能单独证明一定是 OOM。还应检查:

docker stats <container>
docker events

在宿主机有权限时检查内核日志:

dmesg -T | grep -i -E 'oom|killed process|memory cgroup'

cgroup v2 下还可以查看容器对应 cgroup 中的:

memory.current
memory.max
memory.events

其中 memory.events 中的 oomoom_kill 能提供更直接的线索,但路径取决于容器运行时和 cgroup 挂载方式。

两条故障路径可以表示为:

Java 对象分配
    │
    ├── heap 达到 Xmx,GC 无法回收
    │       └── Java 抛 OutOfMemoryError
    │
    └── heap + native + cache + 栈超过 cgroup
            └── Linux OOM 选择进程并 SIGKILL

所以只看应用日志会漏掉内核 OOM,只看容器退出码又无法判断是哪一类内存耗尽。


12. 诊断精简镜像中的内存问题

12.1 观察 JVM 视角

完整 JDK 中可以使用:

jcmd 1 VM.flags
jcmd 1 GC.heap_info
jcmd 1 VM.native_memory summary

启用 Native Memory Tracking 后:

java -XX:NativeMemoryTracking=summary -jar app.jar

再执行:

jcmd 1 VM.native_memory summary

Native Memory Tracking 会带来一定开销,通常用于诊断或受控环境,不应无条件作为所有生产实例的默认配置。

jcmd 1 VM.native_memory summary 需要:

  • 目标 JVM 与 jcmd 兼容;
  • 具有足够权限;
  • 容器中能访问目标进程;
  • 目标进程启动时启用了 NMT。

精简 JLink 运行时通常没有 jcmd,因此应使用调试镜像或在构建时决定观测方案,而不是运行时临时假设工具一定存在。

12.2 观察容器视角

docker stats

可以看到容器当前内存使用和限制,但它是聚合视角,不能直接区分 heap、Metaspace 和 Direct Buffer。

建议同时记录:

  • JVM heap 使用;
  • GC 次数和暂停;
  • 活跃线程数;
  • 类加载数量;
  • Direct Buffer 使用;
  • 容器 memory.current
  • cgroup OOM 事件;
  • 应用请求延迟。

只有把 JVM 内部数据和 cgroup 数据放在同一时间线上,才能判断是堆增长、堆外泄漏、线程增长还是内核直接杀进程。


13. CDS 和 Docker 构建缓存不是一回事

Docker BuildKit cache 解决的是构建阶段的重复工作:

下载依赖、执行编译、复用构建层

CDS 解决的是运行阶段的类加载和元数据复用:

启动 JVM、映射共享归档、减少部分类处理

二者的失效条件也不同:

  • Docker cache 可能因为 Dockerfile 指令或输入文件变化而失效;
  • CDS 可能因为 JVM、类路径、JAR 内容或启动参数变化而失效。

例如:

COPY target/app.jar /opt/app/app.jar

只要 app.jar 内容变化,该层及其后的 CDS 生成步骤都会重新执行,这是正确行为。若为了“复用归档”而强行保留旧 app.jsa,可能得到不可用归档或错误的性能判断。

构建时应固定:

  • 基础镜像版本;
  • JDK 发行版和补丁版本;
  • CPU 架构;
  • 应用依赖版本;
  • JLink 模块集合;
  • CDS 生成时的类路径;
  • 运行时文件路径。

基础镜像最好使用摘要固定关键构建环境,例如:

FROM eclipse-temurin:21-jdk-jammy@sha256:<digest> AS build

摘要需要由实际镜像仓库查询得到,不能把标签当作不可变版本。标签可能被重新指向不同的镜像,从而改变 JDK 补丁版本、系统库和 CDS 结果。


14. 一个更接近生产的镜像结构

真实应用通常还需要:

  • 时区数据;
  • CA 证书;
  • DNS 配置;
  • 字体;
  • native library;
  • java.sqljava.namingjdk.crypto.ec 等模块;
  • 可能的 java.management 或 JMX 支持。

因此,生产 Dockerfile 的核心形态通常是:

# syntax=docker/dockerfile:1

FROM eclipse-temurin:21-jdk-jammy AS build
WORKDIR /build

COPY target/app.jar /build/app.jar

RUN jdeps \
      --print-module-deps \
      --ignore-missing-deps \
      --multi-release 21 \
      /build/app.jar \
      > /build/modules.txt

RUN jlink \
      --add-modules "$(cat /build/modules.txt),jdk.crypto.ec" \
      --strip-debug \
      --no-man-pages \
      --no-header-files \
      --compress=2 \
      --output /build/jre

FROM ubuntu:22.04
ENV JAVA_HOME=/opt/jre
ENV PATH=/opt/jre/bin:$PATH

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates tzdata \
    && rm -rf /var/lib/apt/lists/*

RUN useradd --system --uid 10001 --create-home app
WORKDIR /opt/app

COPY --from=build /build/jre /opt/jre
COPY --from=build /build/app.jar /opt/app/app.jar

USER 10001:10001
ENTRYPOINT ["/opt/jre/bin/java", "-jar", "/opt/app/app.jar"]

这个示例的 jdeps 输出仍需要在真实环境验证。--ignore-missing-deps 可能使结果不完整,尤其是依赖反射和动态发现的框架。生产流水线应加入启动测试和关键功能测试:

docker run --rm \
  --memory=512m \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  my-java-app

--read-only 会将容器根文件系统设为只读,但 Java 应用通常仍需要写入 /tmp、日志目录或临时文件,因此要显式提供可写挂载。-Djava.io.tmpdir=/tmp 可以让临时文件位置明确,但不能解决应用写入其他目录的问题。

如果启用 CDS,归档文件也必须在最终镜像中复制,并且使用生成时相同的 JVM 和类路径。若应用是 Spring Boot 等可执行 fat JAR,还要特别注意其自定义类加载器、嵌套 JAR 和启动方式;不能直接套用“普通 JAR 的类路径归档”假设,必须用真实启动命令生成并验证。


15. 常见误区和对应失败表现

误区一:镜像只有 100 MB,容器就只需要 100 MB 内存

错误原因是把磁盘文件大小当成进程内存。Java 进程启动后仍需分配 heap、线程栈、Metaspace、JIT 和 native memory。

诊断方式是同时观察:

docker stats

以及 JVM 的 heap、线程和 GC 指标。

误区二:设置 -Xmx512m 就能安全运行在 512 MiB 容器

错误原因是没有为 heap 之外的内存预留空间。最终可能表现为:

  • 137
  • 没有 Java OOM 日志;
  • 内核日志出现 cgroup OOM;
  • 容器突然退出。

应降低 heap,或提高 cgroup 限制,并根据线程、Direct Buffer 和类加载量重新预算。

误区三:jdeps 输出的模块一定完整

错误原因是静态分析无法可靠推断所有反射、服务加载和 JNI 行为。失败通常发生在启动或特定业务路径,而不是构建阶段。

恢复方式是:

  1. 根据异常加入缺失模块;
  2. 检查服务提供者和 native library;
  3. 用真实流量或集成测试启动;
  4. 重新生成运行时并验证。

误区四:CDS 归档可以跨 JDK 版本复用

错误原因是归档依赖 JVM 内部格式、类版本和运行时布局。升级 JDK 补丁版本后,旧归档可能被拒绝或无法获得预期收益。

恢复方式是让 CDS 文件与 JDK runtime 一起在同一构建阶段生成,并在 JDK 或应用变化时一并重建。

误区五:只要加 -Xshare:on,启动就一定更快

错误原因是:

  • 归档可能不匹配;
  • 类路径可能变化;
  • 采集阶段没有覆盖真实类;
  • 应用初始化远比类加载耗时;
  • 文件系统缓存和硬件差异可能掩盖收益。

应比较相同环境下的多次冷启动,并用 CDS 日志确认归档确实被使用,而不是只比较两次偶然启动时间。

误区六:Shell 作为 PID 1 没关系

错误原因是 Shell 可能不正确转发 SIGTERM,导致 docker stop 后 Java 没有及时优雅退出,最终被 SIGKILL

应使用 JSON 形式 ENTRYPOINT,或在启动脚本中使用:

exec java ...

16. 验证清单应围绕失败路径,而不是只看镜像大小

构建完成后,至少应验证以下路径。

验证 JLink 运行时

docker run --rm my-java-app java -version

确认:

  • Java 可以启动;
  • 运行时架构正确;
  • 基础镜像的动态库兼容;
  • 需要的模块和证书存在。

验证真实应用启动

docker run --rm \
  --memory=512m \
  --cpus=1 \
  my-java-app

确认:

  • 应用能够启动;
  • TLS、数据库、DNS 和时区功能正常;
  • 真实端点可以访问;
  • 没有因为缺少模块或系统文件而延迟失败。

验证 CDS

docker run --rm my-java-app \
  -Xlog:cds=info \
  -Xshare:on \
  -XX:SharedArchiveFile=/opt/app/app.jsa \
  -cp /opt/app/app.jar \
  Hello

确认日志显示归档被加载或使用。若失败,首先检查:

  • JDK 是否相同;
  • app.jar 内容是否相同;
  • 类路径和文件路径是否相同;
  • 归档文件是否复制到了正确位置;
  • 启动参数是否改变了类加载条件。

验证内存边界

docker run --rm \
  --name java-memory-test \
  --memory=512m \
  --memory-swap=512m \
  -e JAVA_TOOL_OPTIONS='-Xmx300m -XX:MaxMetaspaceSize=80m -XX:MaxDirectMemorySize=64m' \
  my-java-app

压测时同时记录:

docker stats java-memory-test

并关注容器是否出现:

  • OOM kill;
  • 退出码 137;
  • GC 频率异常;
  • 线程数量持续增长;
  • Direct Buffer 或 Metaspace 增长;
  • 请求延迟在内存接近上限时恶化。

结论

jlink、CDS 和 JVM 内存参数分别解决不同问题:

  • JLink:从模块层面缩减 Java 运行时,减少镜像内容和运行时文件;
  • CDS/AppCDS:复用符合条件的类数据,减少部分启动阶段的类处理;
  • Docker cgroup:限制容器整体资源;
  • -Xmx、Metaspace、Direct Memory、-Xss:分别约束 JVM 内部的不同内存区域。

可靠的生产方案应把它们连成一条可验证的构建链:

固定 JDK 和依赖
  → 多阶段构建
  → 分析并验证模块
  → 生成 jlink runtime
  → 在同一 runtime 和路径生成 CDS
  → 复制最小运行文件
  → 按 cgroup 上限进行 JVM 内存预算
  → 用真实启动、压测和 OOM 路径验证

最重要的边界是:镜像小不代表内存小,-Xmx 不代表进程总内存,JLink 不会自动解决反射依赖,CDS 也不能跨任意运行时复用。只有同时验证文件、模块、归档、JVM 参数和 Linux cgroup 行为,镜像优化才会从“看起来更小”变成可控的工程结果。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。