上周推了一个服务更新,CI 流水线跑了将近八分钟,光是 docker push 就花掉三分钟。镜像大小 480MB,一点都不夸张。
查了下原因,FROM eclipse-temurin:21-jdk,完整 JDK,里面带着 javac、jconsole、jshell,一堆生产环境根本不会用的工具。但镜像每次都带着它们,绕不开。
其实 Java 9 就给了解法,只是大多数人没认真用过,就是 JMOD + jlink。
这篇文章把这套工具从头到尾走一遍,讲清楚原理,写完整代码,顺带说说坑在哪里。
Java 9 加了一个新阶段
很多人对 Java 的编译流程的理解停在两段:编译、运行。Java 9 其实悄悄加了一段夹在中间的步骤,叫链接(link time)。
完整的四段是这样的:
编译(javac)→ 打包(jar/jmod)→ 链接(jlink)→ 运行(java)
JDK拆分图解jlink 就是链接阶段的工具。它做的事很简单:你告诉它你的应用需要哪些模块,它就只把那些模块打包进去,多余的一概不要。
JMOD 是什么,和 JAR 有啥区别
JAR 大家都熟,JMOD 是 Java 9 新加的另一种打包格式,专门为链接阶段设计的。
两者最直接的区别,一张表就清楚了:
JDK 自带的那些标准模块(java.base、java.logging 之类的)在 $JAVA_HOME/jmods 目录下,都是 .jmod 格式。jlink 链接时就是去读这些文件,按需取用。
你自己写的业务应用,不需要打成 JMOD。用模块化 JAR 就够了,jlink 能识别。只有你在开发 JDK 平台级模块,或者需要把 native 库一起打包,才需要手动创建 JMOD。
这块我自己也搞混过,简单说清楚:JMOD 不是 JAR 的替代品,各有用途。
Java 模块依赖关系图:不同模块之间的 requires 和 requires transitive 关系,显示了模块图的结构,jlink 就是按照这张图只担包需要的节点
JMOD 文件里装了什么
拿 JDK 自带的 jmod describe 命令能直接看一个 JMOD 文件的内容:
$ jmod describe $JAVA_HOME/jmods/java.logging.jmod
java.logging@21
exports java.util.logging
requires java.base mandated
如果把一个 JMOD 文件解压开,内部目录结构是这样的:
hello.jmod(解压后)
├── classes/ # .class 文件
├── lib/ # native 库(.so / .dll)
├── conf/ # 配置文件
├── legal/ # 法律声明
└── bin/ # native 可执行文件
这个结构解释了为啥 JAR 做不到 JMOD 能做的事:JAR 的格式从诞生之初就只管 .class 和资源,native 库和系统配置文件没有固定位置,打进去 jlink 也不知道怎么处理。JMOD 把这些都规范化了。
实战:从零构建自定义运行时
用一个最简单的模块化应用走一遍完整流程。
目录结构
src/
├── module-info.java
└── com/example/jmod_demo/
└── Hello.java
第一步:写模块描述文件
module-info.java 声明模块名和依赖:
module com.example.jmod_demo {
requires java.logging;
}
Hello.java:
package com.example.jmod_demo;
import java.util.logging.Logger;
public class Hello {
private static final Logger LOG = Logger.getLogger(Hello.class.getName());
public static void
main(String[] args) {
LOG.info("Hello from custom runtime!");
}
}
第二步:编译
javac -d output $(find src -name "*.java")
第三步:打包成 JMOD(可选)
如果你的应用需要带 native 库,才需要这一步。普通应用跳过直接用 JAR。
jmod create \
--class-path output/ \
--main-class com.example.jmod_demo.Hello \
--module-version 1.0.0 \
hello.jmod
验证一下:
$ jmod describe hello.jmod
com.example.jmod_demo@1.0.0
requires java.base mandated
requires java.logging
main-class com.example.jmod_demo.Hello
第四步:用 jlink 生成自定义运行时
jlink \
--module-path $JAVA_HOME/jmods:hello.jmod \
--add-modules com.example.jmod_demo \
--launcher hello=com.example.jmod_demo/com.example.jmod_demo.Hello \
--strip-debug \
--compress=2 \
--no-header-files \
--no-man-pages \
--output custom-runtime
参数说明:
| |
|---|
--module-path | 去哪找模块,含 JDK 自带 jmods 和自己的模块 |
--add-modules | |
--launcher | |
--strip-debug | |
--compress=2 |
启用压缩(JDK 21+ 用 --compress=zip-6,效果一样) |
--no-header-files | |
--no-man-pages | |
第五步:验证
$ du -sh custom-runtime
35M custom-runtime/
$ ./custom-runtime/bin/java --list-modules
com.example.jmod_demo@1.0.0
java.base@21.0.2
java.logging@21.0.2
$ ./custom-runtime/bin/hello
INFO: Hello from custom runtime!
整个运行时只有 3 个模块,35MB,跑起来完全正常。
用 dive 工具分析 Java Docker 镜像层结构,BellSoft Liberica JDK 基础镜像体积 114MB,其中 JDK 层就占 87MBTips:这是极简示例的体积,真实 Spring Boot 项目通常在 80-130MB 区间,依然比完整 JDK 少 70% 以上。
Spring Boot 这类项目怎么处理
上面的例子是写 module-info.java 的模块化应用。很多小伙伴可能要问:Spring Boot 项目没有 module-info.java,这套行不行?
行的。需要用另一个工具先分析依赖:jdeps。
用 jdeps 分析依赖模块
jdeps 能扫描 .jar 文件,列出应用实际用到了哪些 JDK 模块。
Spring Boot 的 fat jar 需要先解压,再跨分析:
# 解压 fat jar
mkdir unpacked && unzip target/app.jar -d unpacked
# 分析依赖,输出模块列表
jdeps \
--ignore-missing-deps \
--print-module-deps \
--recursive \
--multi-release 21 \
--class-path="unpacked/BOOT-INF/lib/*" \
target/app.jar > modules.txt
cat modules.txt
# 输出示例:java.base,java.desktop,java.logging,java.naming,java.sql
拿到模块列表后,直接调 jlink:
jlink \
--module-path $JAVA_HOME/jmods \
--add-modules $(cat modules.txt) \
--strip-debug \
--no-header-files \
--no-man-pages \
--compress=2 \
--output custom-jre
这里有个坑:静态分析找不到反射依赖
jdeps 只看字节码,它不知道运行时通过反射动态加载的类。Spring 内部用了很多反射和动态代理,这部分的模块可能漏找。
一般需要手动补几个常用模块避免运行时报错:
# 在 modules.txt 内容后面手动补充
java.base,java.sql,java.naming,java.management,jdk.unsupported,jdk.crypto.ec
各模块为什么要加:
| |
|---|
jdk.unsupported | Netty、Kryo 等框架用到 sun.misc.Unsafe,没有它会崩 |
jdk.crypto.ec | |
java.sql | |
java.naming | |
java.management | |
实际经验: 镜像构建完之后一定要跑完整集成测试,如果局部功能报 NoClassDefFoundError 或模块未找到,回来把缺少的模块补上就好。
Docker 多阶段构建实战
把上面的流程全部集成到 Dockerfile 里。多阶段构建的思路很直接:第一阶段用完整 JDK 构建和分析,第二阶段把生成的精简运行时拷过去,就这么简单。

# 阶段 1:构建 + 分析依赖 + 生成自定义 JRE
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /app
# 拷入源码,假设用 Maven
COPY . .
RUN ./mvnw package -DskipTests
# 解压 fat jar 分析依赖
RUN mkdir -p unpacked && unzip target/*.jar -d unpacked
RUN jdeps \
--ignore-missing-deps \
--print-module-deps \
--recursive \
--multi-release 21 \
--class-path "unpacked/BOOT-INF/lib/*" \
target/*.jar > modules.txt
# 生成自定义运行时
RUN $JAVA_HOME/bin/jlink \
--module-path "$JAVA_HOME/jmods" \
--add-modules $(cat modules.txt),jdk.unsupported,jdk.crypto.ec \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output /custom-jre
# 阶段 2:最终运行镜像
FROM debian:bookworm-slim
ENV JAVA_HOME=/opt/java
ENV PATH="$JAVA_HOME/bin:$PATH"
# 只拷自定义 JRE 和应用 JAR
COPY --from=builder /custom-jre $JAVA_HOME
COPY --from=builder /app/target/*.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
三种方式的镜像大小对比:
| |
|---|
| |
| |
| jlink 自定义 JRE + debian-slim | ~50-80MB |
对于一个常规 Spring Boot 应用,包含业务逻辑和依赖库,最终镜像一般落在 100-130MB,比完整 JDK 镜像少三分之二以上。
常见坑和注意事项
Alpine 黑坑问题
Alpine 基础镜像只有 5MB 左右,看着确实诱人。但它用的是 musl libc,而大多数 JDK 分发版本都基于 glibc。两者不兼容,直接用会报错。
两种处理方式:
方式 1:构建阶段和运行阶段都用 Alpine 版 JDK:
# 构建阶段
FROM eclipse-temurin:21-jdk-alpine AS builder
...
# 运行阶段
FROM alpine:latest
...
方式 2:构建用 Jammy,运行用 debian:bookworm-slim(推荐,兼容性最好)
总之记住一点:构建镜像和运行镜像要用同一系列的基础镜像。
jlink 生成的运行时不能跨平台
在 macOS 上跑 jlink 生成的运行时,到 Linux 容器里跑不了。必须在目标平台上构建。Docker 多阶段构建本身就是在容器里跑的,所以打镜像时这不是问题。如果你想在本地测试 jlink 输出,记得在对应平台跑。
jlink 和 jpackage 的区别
部署到服务器用 jlink,分发桌面应用用 jpackage。
jlink 和 GraalVM Native Image 怎么选
很多人会再面对一个选择:用 jlink 还是用 GraalVM Native Image?
两者都能大幅内缩镜像体积,但路径不一样:
我自己的感受:项目没有特别强烈的启动速度要求的话,jlink 是整体改动最小、收益最稳的选择。改个 Dockerfile 就能搞定,不用改一行业务代码。GraalVM 的效果确实更大,但它对项目模块化要求高,反射和动态代理要能配置就出,更适合新项目或滑层服务。已有工程要迁移过去还是要踩不少坑,我这块还在摸索,等踩坑完了再单独写。
一些常见问题
Q:jlink 报错 "module not found" 怎么办?
检查 --module-path 里有没有包含 $JAVA_HOME/jmods 路径。尤其在 Docker 里,有时候环境变量没展开,手动写绝对路径比较保险。
Q:JMOD 能发布到 Maven Central 吗?
不行。Maven Central 只接受 JAR 文件。JMOD 是给链接阶段用的,不用上传到仓库里。
Q:我代码里用了
Class.forName() 这种反射,jlink 打出来的运行时会缺模块吗?
会的。jlink 自己不做动态分析。遇到这种情况,手动在 --add-modules 里把缺少的模块补上,然后多跑几轮集成测试。
Q:--compress=2 和 --compress=zip-6 有啥区别?
--compress=2 是 JDK 17 及以下的写法,对应最高级压缩。JDK 21 开始改成了 --compress=zip-6,效果一样,只是参数名称改了,--compress=2 仍可用但已标记废弃。版本搞清楚再写。
总结一下
这一套下来,实际能干的事就是把 Docker 镜像里那些根本用不上的 JDK 组件全部剪掉。对于非模块化项目,jdeps + jlink + 手动补充几个常用模块,最多半个小时就能配好。
已经在用完整 JDK 打 Docker 镜像的小伙伴,这个改动成本很低,可以直接动。
你的镜像当前多大?试过 jlink 之后有没有明显差异,可以评论区讨论一下。