《Java 高级编程》8.1 JPMS 模块化:module-info、requires 与 jlink

classpath 上的 libraryd 能编译、能启动,却可能在运行时才暴露缺包和 split package。本节真写 module-info.java,用 jdeps 分析依赖、打成 modular jar,再用 jlink 裁出 32 MB、3 个模块的运行时,并记录缺模块、自动模块名和 classpath 绕过模块描述符的真实输出。

本节目标:把 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 的 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自动模块/classpathJDBC 驱动与连接池的模块名不稳定
libraryd-web自动模块/classpathJavalin、Jackson 的依赖树包含自动模块
libraryd-agent独立 jarPremain-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 的输入是 JDK jmods 加应用模块路径;本机镜像 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 库 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发