用 jdeps 算出模块清单,再交给 jlink,就能给 Spring Boot 应用做一个只有 60M 的专属运行时。我在 JDK 27 上用它跑通了一个 Spring Boot 4.1.1 的 Web 应用,作为对比,完整的 JDK 27 有 386M。JMOD 文件本身只在打包和链接时有用,运行时根本不认它。
这篇先用一个 Hello 模块在 JDK 27 上走一遍 jmod create 和 jlink,再把 Spring Boot 应用也塞进去,下面是过程和碰到的几个问题。
环境
$ java -version
openjdk version "27" 2026-09-15
OpenJDK Runtime Environment (build 27+35-2325)
OpenJDK 64-Bit Server VM (build 27+35-2325, mixed mode, sharing)
JDK 是 OpenJDK 27 GA,自带 jmods 目录。应用用的是 Spring Boot 4.1.1,内嵌 Tomcat 11.0.24。
JMOD 是什么
JMOD 的定义是:
A JMOD file is a packaging format for Java modules that are intended to be used during compile and link time, but not directly at runtime.
意思是 JMOD 是给编译期和链接期用的模块打包格式,运行时不能直接用。和 JAR 相比,它还能装原生库、配置文件、法律声明这些东西,jlink 会把它们放到生成的运行时镜像里对应的目录。JDK 自己的 jmods/java.base.jmod 这些文件就是这个格式。
先走一遍 Hello 模块
模块声明:
module com.spring4all.hello {
requires java.logging;
}
主类。Logger.getLogger() 要传 Hello.class,写成不存在的 Main 类会编译不过:
package com.spring4all.hello;
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 spring4all! running on " + Runtime.version());
}
}
编译后打成 JMOD。我多加了 --config conf,把一个配置文件一起装进去,看看 JMOD 比 JAR 多出来的那部分能力:
$ javac -d output $(find src -name "*.java")
$ jmod create --class-path output --main-class com.spring4all.hello.Hello --module-version 1.0.0 --config conf hello.jmod
$ jmod describe hello.jmod
com.spring4all.hello@1.0.0
requires java.base mandated
requires java.logging
contains com.spring4all.hello
main-class com.spring4all.hello.Hello
$ jmod list hello.jmod
classes/module-info.class
classes/com/spring4all/hello/Hello.class
conf/hello.properties
jmod list 能看到 JMOD 内部是分区的,类在 classes/,配置在 conf/。
试着直接拿它当模块路径运行:
$ java -p hello.jmod -m com.spring4all.hello
Error occurred during initialization of boot layer
java.lang.module.FindException: JMOD format not supported at execution time: hello.jmod
报错信息说得很直接,执行期不支持 JMOD 格式。
jlink 生成运行时
jlink 的老写法 --compress=2 在 JDK 27 上会出警告:
$ jlink --module-path hello.jmod --add-modules com.spring4all.hello --launcher hello=com.spring4all.hello/com.spring4all.hello.Hello --strip-debug --compress=2 --no-header-files --no-man-pages --output rt-hello
Warning: The 2 argument for --compress is deprecated and may be removed in a future release
jlink --help 的说明是,--compress 现在用 zip-0 到 zip-9 的写法,2 对应 zip-6:
--compress <compress> Compress all resources in the output image:
Accepted values are:
zip-{0-9}, where zip-0 provides no compression,
and zip-9 provides the best compression.
Default is zip-6.
Deprecated values to be removed in a future release:
0: No compression. Use zip-0 instead.
1: Constant String Sharing
2: ZIP. Use zip-6 instead.
换成新写法后警告就没了:
$ jlink --module-path hello.jmod --add-modules com.spring4all.hello --launcher hello=com.spring4all.hello --strip-debug --compress=zip-6 --no-header-files --no-man-pages --output rt-hello2
这条命令还有两处可以简化。--module-path 常被写成 $JAVA_HOME/jmods:hello.jmod。按 jlink 文档,不写 --module-path 时默认就用 $JAVA_HOME/jmods,写了但里面解析不到 java.base,jlink 也会自动把 $JAVA_HOME/jmods 追加进去,所以只写自己的 JMOD 就够了。--launcher 后面也可以只写模块名,jlink 会用 JMOD 里记录的 main-class。
看结果:
$ du -sh rt-hello rt-hello2
40M rt-hello
40M rt-hello2
$ du -sh "$JAVA_HOME" | sed "s#$HOME/jdks/##"
386M jdk-27
$ rt-hello2/bin/java --list-modules
com.spring4all.hello@1.0.0
java.base@27
java.logging@27
$ cat rt-hello2/conf/hello.properties
greeting=hello
$ rt-hello2/bin/hello
Oct 13, 2026 8:07:35 AM com.spring4all.hello.Hello main
INFO: Hello spring4all! running on 27+35-2325
两个运行时都是 40M,新旧压缩写法效果一样。体积会随 JDK 版本和平台变化。打包时放进 JMOD 的 hello.properties 出现在了运行时的 conf/ 目录下,模块化 JAR 没有这种分区,做不到这一点。
换成 Spring Boot 应用
一个最简单的 Web 应用,接口返回当前 Java 版本和启动层里的模块数量:
@SpringBootApplication
@RestController
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@GetMapping("/info")
public Map<String, Object> info() {
return Map.of(
"java", Runtime.version().toString(),
"modules", ModuleLayer.boot().modules().size(),
"uptimeMs", ManagementFactory.getRuntimeMXBean().getUptime());
}
}
pom.xml 里只有 spring-boot-starter-webmvc,java.version 设为 27,端口配成 18081。
Spring Boot 的 fat jar 不是模块化 JAR,没法直接打成 JMOD。不过不需要,应用本身照样用 java -jar 跑,jlink 只负责做一个装着平台模块的运行时。关键是知道应用到底用了哪些平台模块,这个交给 jdeps。
jdeps 看不懂 fat jar 里嵌套的 jar,要先解开:
$ ls -lh target/jlink-demo-0.0.1.jar
-rw-r--r-- 1 user user 19M Oct 13 08:07 target/jlink-demo-0.0.1.jar
$ mkdir exploded && cd exploded && jar -xf ../target/jlink-demo-0.0.1.jar && cd ..
$ ls exploded/BOOT-INF/lib | wc -l
34
$ jdeps --ignore-missing-deps --print-module-deps --recursive --multi-release 27 --class-path "exploded/BOOT-INF/lib/*" exploded/BOOT-INF/classes
java.base,java.compiler,java.desktop,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.scripting,java.security.jgss,java.sql,jdk.jfr,jdk.unsupported
--ignore-missing-deps 少不了。Spring 生态里有不少可选依赖不在 classpath 上,不加它 jdeps 会报 Missing dependencies 直接退出,第一个列出来的就是 commons-logging 里引用的 log4j 1.x 和 Avalon 类。--multi-release 27 让它按 27 的版本去读多版本 JAR。
这份清单里有 java.desktop,看着奇怪,一个 Web 应用要桌面模块干什么。原因是 java.beans 包在 java.desktop 里,Spring 的属性绑定会用到它。
把清单交给 jlink:
$ jlink --add-modules java.base,java.compiler,java.desktop,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.scripting,java.security.jgss,java.sql,jdk.jfr,jdk.unsupported --strip-debug --compress=zip-6 --no-header-files --no-man-pages --output rt-boot
$ du -sh rt-boot
60M rt-boot
$ rt-boot/bin/java --list-modules | wc -l
18
指定了 13 个,实际有 18 个,多出来的是这些模块的传递依赖。用这个运行时启动 fat jar:
2026-10-13T08:08:18.467+08:00 INFO 660556 --- [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 18081 (http) with context path '/'
2026-10-13T08:08:18.473+08:00 INFO 660556 --- [ main] com.spring4all.demo.DemoApplication : Started DemoApplication in 1.016 seconds (process running for 1.369)
$ curl -s localhost:18081/info
{"modules":18,"uptimeMs":1560,"java":"27+35-2325"}
同一个 jar 用完整 JDK 27 跑一次做对照:
$ curl -s localhost:18081/info
{"modules":60,"java":"27+35-2325","uptimeMs":1554}
2026-10-13T08:09:08.414+08:00 INFO 662209 --- [ main] com.spring4all.demo.DemoApplication : Started DemoApplication in 1.047 seconds (process running for 1.363)
两边启动时间差不多,jlink 省下的只是体积。启动层里的模块从 60 个降到 18 个,运行时从 386M 降到 60M。
模块给少了会怎样
如果凭感觉只给 java.base,java.logging:
$ jlink --add-modules java.base,java.logging --strip-debug --compress=zip-6 --no-header-files --no-man-pages --output rt-tiny
$ du -sh rt-tiny
40M rt-tiny
启动直接失败:
08:08:21.276 [main] ERROR org.springframework.boot.SpringApplication -- Application run failed
java.lang.NoClassDefFoundError: java/beans/PropertyEditorSupport
at java.base/java.lang.ClassLoader.defineClass1(Native Method)
at java.base/java.lang.ClassLoader.defineClass(Unknown Source)
at java.base/java.security.SecureClassLoader.defineClass(Unknown Source)
at java.base/java.net.URLClassLoader.defineClass(Unknown Source)
at java.base/java.net.URLClassLoader.findClass(Unknown Source)
at java.base/java.lang.ClassLoader.loadClass(Unknown Source)
at org.springframework.boot.loader.net.protocol.jar.JarUrlClassLoader.loadClass(JarUrlClassLoader.java:107)
at org.springframework.boot.loader.launch.LaunchedClassLoader.loadClass(LaunchedClassLoader.java:91)
at java.base/java.lang.ClassLoader.loadClass(Unknown Source)
at org.springframework.boot.context.properties.bind.BindConverter$TypeConverterConverter.<clinit>(BindConverter.java:194)
缺的正好是 java.desktop 里的 java.beans。另外注意栈里 JDK 自己的帧都显示 Unknown Source,没有行号,这是 --strip-debug 去掉调试信息的结果,Spring 的帧不受影响,因为它们来自应用 jar。
jdeps 只做静态分析,通过反射或 ServiceLoader 按名字加载的类它看不到,JDBC 驱动和各种 SPI 实现都属于这一类,用到时要自己把模块补进 --add-modules。诊断工具也一样,这个运行时的 bin 目录里只有三个命令:
$ ls rt-boot/bin
java
jfr
keytool
jfr 在,是因为 jdeps 发现 Spring 引用了 jdk.jfr。jcmd、jstack 都没有,它们在 jdk.jcmd 模块里,线上需要的话得显式加上。
一起交流
分享你的思考,让讨论更进一步。