本节目标:把
libraryd的领域模块和应用模块做成真正的 JPMS 模块,用jdeps看清依赖,用jlink生成只含所需模块的运行时,并知道哪些第三方库暂时只能作为自动模块处理。
适用版本:JDK 25(Temurin 25.0.5)。module-info、jdeps、jlink均为 JDK 自带工具;示例不依赖第三方库,便于复现。
承接的问题
libraryd 在卷二一直是五模块 Maven 工程,但 Maven 的「模块」和 JPMS 的「模块」是两件事。Maven 模块是构建单元,最终产物仍然是一堆 jar 放在 classpath 上;JPMS 模块是运行期单元,有名字、有显式依赖、有导出边界。
classpath 模式下有三类问题会拖到运行期才暴露:
- 某个类引用了
java.sql,但jdeps没跑过,换一台裁剪过的 JRE 就报NoClassDefFoundError。 - 两个依赖包含同一个包(split package),classpath 上「谁先出现谁赢」,行为取决于 jar 顺序。
- 某个库通过反射访问
com.plume.library.domain的内部字段,模块化后没有opens,运行时报InaccessibleObjectException。
这些问题在开发机上通常不出现,因为开发机用的是完整 JDK,依赖也齐全。到了交付阶段,运行时被裁剪、模块被封闭,问题才浮出水面。
JPMS 的目标不是「让代码更整齐」,而是把「谁依赖谁、谁可以访问谁」变成构建期和启动期可检查的约束。代价是要维护 module-info.java,并处理第三方库的自动模块名。
先用一个最小可复现的模型把机制跑通:com.plume.library.domain 和 com.plume.library.app 两个模块,领域模块导出包,应用模块依赖它。真实 libraryd 的落地方式在后面单独讲。
核心机制一:module-info 声明名字、依赖与导出
领域模块的描述符只有两行:
module com.plume.library.domain {
exports com.plume.library.domain;
}
module 后面是模块名,惯例与包名一致,但不要求。exports 表示这个包对其他模块可见;没有 exports 的包是模块私有的,即使类是 public,其他模块也访问不到。requires 没有写 java.base,因为它是所有模块隐式依赖的,编译器自动加上。
应用模块:
module com.plume.library.app {
requires com.plume.library.domain;
}
requires 声明编译期和运行期依赖。模块路径上找不到 com.plume.library.domain 时,编译直接失败,而不是等到运行期抛 NoClassDefFoundError。
源码目录按模块组织:
jpms/
├── src/
│ ├── com.plume.library.domain/
│ │ ├── module-info.java
│ │ └── com/plume/library/domain/*.java
│ └── com.plume.library.app/
│ ├── module-info.java
│ └── com/plume/library/app/AppMain.java
├── mods/
└── jars/
编译命令:
javac --release 25 \
--module-source-path src \
-d mods \
--module com.plume.library.domain,com.plume.library.app
--module-source-path 告诉编译器「每个子目录是一个模块」,--module 指定要编译哪些模块。编译产物在 mods/com.plume.library.domain/ 和 mods/com.plume.library.app/ 下,目录结构本身就是模块路径。
打成 modular jar:
jar --create --file jars/com.plume.library.domain.jar \
-C mods/com.plume.library.domain .
jar --create --file jars/com.plume.library.app.jar \
-C mods/com.plume.library.app .
本机产物大小:
644 com.plume.library.app.jar 2.0K
644 com.plume.library.domain.jar 10.0K
用 jar --describe-module 确认描述符被写进去了:
com.plume.library.app jar:file:///private/tmp/vol3-c/jpms/jars/com.plume.library.app.jar!/module-info.class
requires com.plume.library.domain
requires java.base mandated
contains com.plume.library.app
com.plume.library.domain jar:file:///private/tmp/vol3-c/jpms/jars/com.plume.library.domain.jar!/module-info.class
exports com.plume.library.domain
requires java.base mandated
contains 是模块里包含的包,exports 是对外开放的包。应用模块没有 exports,因为它是入口,不需要被其他模块依赖。
核心机制二:jdeps 把依赖画出来
jdeps 读取 class 文件里的常量池,分析每个类引用了哪些包,再把包映射到模块。对应用模块跑:
jdeps --module-path jars --print-module-deps jars/com.plume.library.app.jar
本机输出:
com.plume.library.domain,java.base
--print-module-deps 只输出模块名,适合直接喂给 jlink --add-modules。想看包级细节,去掉这个参数:
com.plume.library.app
[file:///private/tmp/vol3-c/jpms/jars/com.plume.library.app.jar]
requires com.plume.library.domain
requires mandated java.base (@25)
com.plume.library.app -> com.plume.library.domain
com.plume.library.app -> java.base
com.plume.library.app -> com.plume.library.domain com.plume.library.domain
com.plume.library.app -> java.io java.base
com.plume.library.app -> java.lang java.base
com.plume.library.app -> java.lang.invoke java.base
com.plume.library.app -> java.time java.base
每一行是「源包 -> 目标包 -> 目标模块」。java.io、java.lang、java.lang.invoke、java.time 都落在 java.base,所以最终只需要 java.base 和领域模块。
jdeps 有三个使用边界:
- 它分析的是字节码里的静态引用。反射、
ServiceLoader、JNI、资源文件里的类名不会出现在结果里。 --ignore-missing-deps让分析在缺依赖时继续,但输出的是「在缺失依赖的前提下能看到的模块」,不能当成完整依赖清单。- 对非模块化 jar,
jdeps会派生自动模块名。名字可能来自MANIFEST.MF的Automatic-Module-Name,也可能来自 jar 文件名。
自动模块名的不稳定性可以直接看到。把领域类打成一个没有 module-info.class 的普通 jar:
jar --create --file jars/plain-domain.jar -C /tmp/vol3-c/ch7/out com/plume/library/domain
jar --describe-module --file jars/plain-domain.jar
本机输出:
找不到模块描述符。已派生自动模块。
plain.domain automatic
requires java.base mandated
contains com.plume.library.domain
plain-domain.jar 被派生成 plain.domain:文件名去掉 .jar,连字符换成点。把 jar 改名成 domain-1.0.jar,模块名可能变成 domain;加一个版本号,名字又可能变。依赖自动模块的 requires 子句会跟着文件名漂移,这是 libraryd 不急着给 Javalin、Jackson、PostgreSQL 驱动写 requires 的原因。
核心机制三:jlink 只裁 JDK 模块,应用模块要自己放进去
jlink 的输入是「JDK 的 jmods 目录 + 应用模块路径」,输出是一个自带 JVM 的运行时镜像。命令:
jlink \
--module-path "$JAVA_HOME/jmods:jars" \
--add-modules com.plume.library.app \
--output image \
--strip-debug \
--no-header-files \
--no-man-pages \
--compress=zip-6
参数逐个解释:
--module-path同时包含 JDK 的jmods和应用 jar 所在目录。少了应用目录,jlink找不到com.plume.library.app。--add-modules指定根模块。jlink会沿着requires把依赖闭包一起装进去。--strip-debug去掉调试符号,减小体积;需要保留堆栈行号时不要加。--no-header-files、--no-man-pages去掉 JNI 头文件和 man 手册,运行时不需要。--compress=zip-6用 zip 压缩资源。JDK 25 支持zip-0到zip-9,数字越大压缩越强、启动解压成本越高。
本机结果:
image_modules=3
jdk_modules=69
32M image
294M /Users/bingrong.yan/.jdks/jdk-25.0.5+8/Contents/Home
32624 image(du -sk,单位 KB)
301212 JDK(du -sk,单位 KB)
镜像里只有三个模块:
com.plume.library.app
com.plume.library.domain
java.base@25.0.5
完整 JDK 是 69 个模块、294 MB;镜像是 3 个模块、32 MB。这个比例说明 jlink 的收益主要来自「不装用不到的 JDK 模块」,而不是压缩应用代码。应用本身的 jar 只有 12 KB。
运行镜像:
./image/bin/java --module com.plume.library.app/com.plume.library.app.AppMain
本机输出:
module=com.plume.library.app
state=Overdue[dueOn=2026-11-04, days=6]
module= 那一行来自 AppMain.class.getModule().getName(),确认代码确实运行在命名模块里,而不是 unnamed module。state= 是领域模块算出的 LoanState,说明跨模块调用正常。
jlink 的能力边界要说清楚:
- 它裁剪的是 JDK 模块和显式加入的 modular jar。应用代码必须先是模块,才能作为根模块参与链接。
- 它不会把 classpath 上的 fat jar 自动模块化。第 16.1 节的 24 MB fat jar 仍然以 classpath 方式运行,
jlink只能给它裁一个更小的 JRE。 - 自动模块可以作为
--add-modules的根被链接,但自动模块名不稳定、可能包含 split package,链接结果不如显式模块可控。 - 服务提供者不会自动全部保留。依赖
ServiceLoader的模块需要--bind-services,否则运行时会发现 provider 缺失。
在 libraryd 里落地:先模块化领域与应用,第三方暂时留在自动模块
真实 libraryd 的模块化分三步,不追求一次全绿。
第一步,libraryd-domain 加 module-info.java:
module com.plume.library.domain {
exports com.plume.library.domain;
}
领域模块只依赖 java.base,没有第三方依赖,是最干净的一层。exports 暴露领域类型,供 persistence、web、app 使用。记录类型通过 accessor 访问,不需要 opens;如果 Jackson 要反射私有字段,再按需加 opens com.plume.library.domain to com.fasterxml.jackson.databind;。
第二步,libraryd-app 加描述符:
module com.plume.library.app {
requires com.plume.library.domain;
requires java.sql;
requires java.naming;
}
java.sql 给 JDBC 用,java.naming 给 Javalin/Jetty 的 JNDI 查找用。java.sql 不是 java.base 的一部分,必须显式 requires;这也是 jdeps 最有价值的场景:它会把「代码里用了但描述符没写」的模块找出来。
第三步,persistence 和 web 暂时不做显式模块化。它们依赖 Javalin、Jackson、HikariCP、Flyway、PostgreSQL 驱动,这些库大多没有 module-info.class,在模块路径上会变成自动模块。自动模块名可能是 io.javalin、com.fasterxml.jackson.databind、org.postgresql.jdbc,也可能跟着 jar 文件名变。把它们强行写进 requires,升级依赖时容易因为改名而编译失败。
当前策略是:
| 模块 | 状态 | 原因 |
|---|---|---|
libraryd-domain | 显式模块 | 无第三方依赖,边界清晰 |
libraryd-app | 显式模块 | 入口,需要 jlink 根模块 |
libraryd-persistence | 自动模块/classpath | JDBC 驱动与连接池的模块名不稳定 |
libraryd-web | 自动模块/classpath | Javalin、Jackson 的依赖树包含自动模块 |
libraryd-agent | 独立 jar | Premain-Class 清单,不参与应用模块图 |
libraryd-benchmarks | 不发布 | JMH 生成代码,不需要进镜像 |
这个组合可以做到「jdeps 能分析、jlink 能出带应用模块的运行时」,同时不强行给第三方库写 requires。等依赖库提供稳定的 Automatic-Module-Name 或正式 module-info,再逐个升级。
常见错误与边界
错误一:只把应用 jar 放进模块路径。 运行:
java --module-path jars/com.plume.library.app.jar \
--module com.plume.library.app/com.plume.library.app.AppMain
本机输出:
Error occurred during initialization of boot layer
java.lang.module.FindException: Module com.plume.library.domain not found, required by com.plume.library.app
--module-path 必须包含依赖模块。FindException 在启动阶段抛出,不会进入 main,所以日志里看不到应用自己的输出。排查时先确认模块路径下每个 requires 都能找到。
错误二:把 modular jar 放到 classpath 上,以为模块描述符会生效。 运行:
java --module-path jars --add-modules com.plume.library.domain \
-cp jars/com.plume.library.app.jar com.plume.library.app.AppMain
本机输出:
module=null
state=Overdue[dueOn=2026-11-04, days=6]
应用类被当成 unnamed module 加载,module-info.class 被忽略。代码能跑,但模块边界、exports 检查、jlink 根模块全部失效。要验证模块是否真的生效,在入口打印 getModule().getName(),不要只看「能跑」。
错误三:jlink 时忘了把应用 jar 放进 module path。 只给 $JAVA_HOME/jmods:
Error: Module com.plume.library.app not found
java.lang.module.FindException: Module com.plume.library.app not found
at java.base/java.lang.module.Resolver.findFail(Resolver.java:921)
...
jlink 不会去 Maven 仓库找依赖,也不会读 pom.xml。应用模块和它的依赖都必须已经在 --module-path 上。
错误四:split package。 两个模块导出同一个包,模块系统在解析阶段直接拒绝。典型场景是把 com.plume.library.domain 同时打进 domain.jar 和 app.jar。Maven 的 Shade 插件最容易制造这种重叠:它把所有类合并进一个 jar,模块路径上再放原始 jar,就会出现重复包。模块化构建要避免把同一份类打两遍。
错误五:给自动模块写死 requires,升级后模块名变了。 自动模块名来自 jar 文件名或 Automatic-Module-Name,前者会随版本号、构建工具命名变化。今天写 requires org.postgresql.jdbc;,明天驱动改名成 org.postgresql,编译失败。第三方库没有稳定模块名时,把它留在 classpath,或者用 --add-modules 在启动脚本里显式加入。
边界一:jdeps 看不到反射和 SPI。 jdeps 只分析静态引用。Jackson 通过反射访问 record 组件、Flyway 通过 ServiceLoader 找数据库插件、JDBC 驱动通过 META-INF/services 注册,这些都不会出现在 --print-module-deps 里。裁剪运行时后要跑一遍完整端到端测试,不能只看 jdeps 输出。
边界二:jlink 的 --compress 与启动成本。 压缩率越高,镜像越小,但 JVM 启动时要解压更多资源。--compress=zip-6 是一个折中;对启动时间敏感的服务,可以比较 zip-0 和 zip-6 的冷启动时间,不要只看镜像大小。
小结
- Maven 模块是构建单元,JPMS 模块是运行期单元;
module-info.java声明名字、requires和exports。 jdeps --print-module-deps给出模块闭包;它看不到反射、SPI 和 JNI,裁剪后必须用端到端测试兜底。- 没有
module-info.class的 jar 会变成自动模块,名字可能来自文件名;依赖自动模块的requires会随文件名漂移。 jlink的输入是 JDKjmods加应用模块路径;本机镜像 3 个模块、32 MB,完整 JDK 69 个模块、294 MB。- 把 modular jar 放到 classpath 会绕过模块描述符,
getModule().getName()返回 null;验证模块化是否生效不能只看「能运行」。 libraryd先模块化domain和app,第三方库暂时留在自动模块或 classpath,等模块名稳定后再升级。
模块化解决的是「类和包的边界」,没有解决「Java 代码怎么调用 C 库」。下一节用 FFM API 编译一个真实的 libisbn,通过 Linker 和 MemorySegment 调用 isbn13_check_digit,并记录不加 --enable-native-access 时的真实告警。
阅读导航:上一节:7.3 模式匹配的边界:守卫、null 与可读性 · 下一节:8.2 FFM API:MemorySegment 与调用 C 库 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。