Java 9 引入的 JPMS(Java Platform Module System,Java 平台模块系统)是语言层面最被低估也最被误解的特性。它用 module-info.java 声明模块边界,用 requires/exports 显式描述依赖与可见性,把「类路径上的一切互相可见」变成「只有导出的包才可见」。很多团队因为 Spring 生态的反射依赖而对它敬而远之,但 jlink 裁剪运行时、强封装带来的可维护性,仍然是大型工程值得掌握的武器。
一、为什么需要模块系统
类路径(Classpath)模型有两个根本问题:
| 问题 | 表现 | 后果 |
|---|---|---|
| 全可见 | 所有 JAR 的 public 类互相可见 | 内部 API 被误用,重构即破坏兼容 |
| 无依赖声明 | 依赖靠构建工具约定 | 缺 JAR 到运行时才 NoClassDefFoundError |
| 无版本冲突检测 | 同名类先到先得 | 类遮蔽(class shadowing)难排查 |
| 无法裁剪 | 整包 JRE 一起打包 | 制品臃肿,无法只带用到的模块 |
JPMS 的三个目标:
1. 可靠的配置 —— 依赖在启动时校验,缺模块立即报错
2. 强封装 —— 未 export 的包外部不可访问(含反射)
3. 可裁剪 —— jlink 只打包用到的模块,生成精简运行时
一句话总结: JPMS 把「隐式全可见 + 运行时才发现缺失」换成「显式声明 + 启动即校验」,核心价值是可靠配置与强封装,jlink 只是它的附加红利。
二、module-info.java 的指令
module com.example.order {
requires java.sql; // 依赖标准模块
requires transitive com.example.common; // 传递依赖:下游也能看到
requires static lombok; // 编译期需要,运行期可选
exports com.example.order.api; // 对外公开的包
exports com.example.order.spi to com.example.plugin; // 限定导出
opens com.example.order.entity to org.hibernate.orm.core; // 反射开放
opens com.example.order.dto; // 对所有模块开放反射
uses com.example.order.spi.PaymentProvider; // 声明服务消费者
provides com.example.order.spi.PaymentProvider // 声明服务提供者
with com.example.order.impl.AlipayProvider;
}
2.1 关键指令语义
| 指令 | 含义 | 常见误用 |
|---|---|---|
requires | 依赖某模块 | 漏写导致 module not found |
requires transitive | 依赖且向下游传递 | 滥用导致依赖泄漏 |
requires static | 编译期必需、运行期可选 | 与 optional 语义混淆 |
exports | 公开包(编译+反射可见) | 把所有包都导出,失去封装 |
exports ... to | 只对指定模块公开 | 限定模块名写错静默失效 |
opens | 仅反射可见(编译不可见) | Spring/Hibernate 必需 |
uses / provides | 服务加载(ServiceLoader) | 忘记 uses 导致 SPI 找不到 |
exports vs opens 的区别:
exports:编译期可 import,反射默认可访问 public 成员
opens: 编译期不可 import,但反射可访问全部成员(含 private)
框架(Spring/Hibernate/Jackson)大量用反射 → 需要 opens
一句话总结:
exports管「编译可见」,opens管「反射可见」;给框架的包用opens,给业务调用方的包用exports,这是模块化最核心的一条区分。
三、模块路径 vs 类路径
# 类路径(传统)
java --class-path lib/*:app.jar com.example.Main
# 模块路径(JPMS)
java --module-path mods:lib --module com.example.order/com.example.order.Main
# 简写
java -p mods:lib -m com.example.order/com.example.order.Main
# 运行未命名模块(有 main 的 JAR)
java -p mods -m com.example.order
两类路径的区别:
类路径 classpath
- 所有 JAR 平等可见,无边界
- 无法使用 jlink
- 仍然可用(向后兼容)
模块路径 module-path
- 模块目录(含 module-info.class 或自动模块名)
- 启动时解析依赖图,缺失立即报错
- 可用 jlink 生成定制运行时
3.1 模块的三种类型
| 类型 | 判定 | 特性 |
|---|---|---|
| 具名模块 | 有 module-info.class | 完整模块语义 |
| 自动模块(Automatic) | JAR 无 module-info,但放在 module-path | 模块名从 JAR 文件名推导,导出全部包,可读所有模块 |
| 无名模块(Unnamed) | 在 class-path 上 | 可读所有模块,但无人能 requires 它 |
自动模块名推导规则:
foo-bar-1.2.3.jar → foo.bar
去掉版本号,把非字母数字替换为点,去重连续点
可用 --describe-module 查看推导结果:
jar --describe-module --file=foo-bar-1.2.3.jar
一句话总结: 自动模块是「过渡桥梁」——把传统 JAR 放上 module-path 就能被具名模块
requires,但它导出所有包、可读一切,是迁移期的临时方案,不是终态。
四、拆分包(Split Package)冲突
同一个包出现在两个模块里,是 JPMS 最常见的报错来源:
错误示例:
module com.example.a 导出 com.example.util
module com.example.b 也导出 com.example.util
→ 启动报错:module com.example.a reads package com.example.util from both ...
根因:
类路径允许同名包分散在多个 JAR(合并成一个「包空间」)
模块路径禁止一个包被两个模块导出(模块是包的唯一起源)
常见拆分包场景:
1. javax.annotation 分散在 JDK 与第三方 JAR
2. 老框架把 api 与 impl 放在同一个包
3. 工具类被复制到多个模块
4. Spring 的 spring-core 与第三方共享 org.springframework.*
应对:
1. 升级到已模块化的库版本
2. 用 --patch-module 临时合并(迁移期)
3. 把冲突包收敛到一个模块
# 迁移期临时补丁:把补丁 JAR 合并进目标模块
java --module-path mods \
--patch-module com.example.a=patch/a-extra.jar \
-m com.example.a/com.example.a.Main
一句话总结: 拆分包的本质是「同一个包不能有两个来源」;优先升级库,其次用
--patch-module过渡,长期必须把冲突包归并到单一模块。
五、工具链:jdeps、jlink、jmod
5.1 jdeps:分析依赖
# 分析 JAR 的模块依赖
jdeps --module-path mods --multi-release 21 --print-module-deps app.jar
# 生成 module-info.java 草稿
jdeps --generate-module-info out --module-path mods app.jar
# 检查是否使用了 JDK 内部 API
jdeps --jdk-internals app.jar
# 输出示例
app.jar -> java.base
app.jar -> java.sql
app.jar -> java.logging
5.2 jlink:生成定制运行时
# 只打包用到的模块,生成精简 JRE
jlink --module-path $JAVA_HOME/jmods:mods \
--add-modules com.example.order \
--output custom-runtime \
--strip-debug \
--no-header-files \
--no-man-pages \
--compress=2
# 体积对比
du -sh $JAVA_HOME # 完整 JDK:约 300MB
du -sh custom-runtime # 定制运行时:约 40MB
# 用定制运行时启动(甚至可以用 jpackage 打成安装包)
custom-runtime/bin/java -m com.example.order/com.example.order.Main
# 打成可分发的原生安装包(Windows/macOS/Linux)
jpackage --runtime-image custom-runtime \
--module com.example.order/com.example.order.Main \
--name order-app --type deb
5.3 jmod:模块归档格式
# 查看模块内容
jmod list $JAVA_HOME/jmods/java.base.jmod | head
# 创建自定义 jmod
jmod create --class-path mods/com.example.order mymod.jmod
一句话总结:
jdeps负责「看清依赖」,jlink负责「裁剪运行时」,jmod是 JDK 内部的模块归档格式;jdeps --print-module-deps的输出可以直接喂给 jlink 的--add-modules。
六、迁移策略:从 classpath 到 module path
6.1 分阶段路线
阶段一:分析
jdeps 扫描现有依赖,识别拆分包与 JDK 内部 API 使用
阶段二:自底向上模块化
先模块化「叶子」库(无第三方依赖的模块),再逐层向上
每个模块写最小 module-info:只 requires 必需,只 exports 必需
阶段三:处理第三方
未模块化的库 → 放 module-path 当自动模块,或用 --add-modules ALL-MODULE-PATH
阶段四:收敛 opens
给反射框架显式 opens,而非 opens 整个模块
阶段五:jlink 收尾
生成定制运行时,接入 CI 校验依赖图
# 迁移期常用「宽进」参数,先跑通再收紧
java --module-path mods \
--add-modules ALL-MODULE-PATH \
--add-opens com.example.order/com.example.order.dto=org.springframework.core \
-m com.example.order/com.example.order.Main
6.2 与构建工具集成
<!-- Maven:编译期使用 module path,且要求依赖可解析 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>21</release>
</configuration>
</plugin>
// Gradle:显式开启模块路径推断(Java 模块化插件)
java {
modularity.inferModulePath.set(true)
}
一句话总结: 迁移的正确姿势是「自底向上 + 宽进严出」——先跑通再逐步收紧 exports/opens,同时把
--add-opens当作技术债显式记账,而不是长期依赖。
七、Spring 生态与 JPMS 的现实取舍
Spring Framework 6 / Boot 3 的态度:
1. 官方未提供 module-info,仍以自动模块形式工作
2. 依赖大量反射 → 需要 opens,而非 exports
3. CGLIB 动态代理需要 opens 代理目标包
4. 常见报错:InaccessibleObjectException / IllegalAccessError
// 应用侧自建模块时,给框架开放的典型写法
module com.example.app {
requires spring.boot;
requires spring.context;
opens com.example.app.config to spring.core, spring.beans;
opens com.example.app.entity to org.hibernate.orm.core;
exports com.example.app.api;
}
务实结论:
1. 应用层模块化收益有限、成本高 —— 除非要做 jlink 裁剪
2. 库/框架作者模块化收益大 —— 强封装保护内部 API
3. 内部平台/中间件(自研)适合模块化
4. 大量 Spring 反射的场景,JPMS 更多是「可控的 add-opens」而非纯收益
一句话总结: 在 Spring 生态里做 JPMS,收益最大的是自研库与需要 jlink 裁剪的场景,应用层更多是给框架补
opens;先想清楚动机再动手。
八、常见错误速查
| 报错 | 原因 | 修复 |
|---|---|---|
module not found: xxx | 依赖未在 module-path 或未 requires | 补 requires 或加 --add-modules |
package xxx is not visible | 目标包未 exports | 加 exports 或 --add-exports |
module reads package ... from both | 拆分包 | 归并冲突包 / --patch-module |
does not declare 'provides' | SPI 未声明 | 补 provides ... with ... |
IllegalAccessError | 反射访问未 opens | 加 opens 或 --add-opens |
class file has wrong version | 模块编译版本不一致 | 统一 --release |
unable to derive module descriptor | 自动模块名冲突/无效 | 用 --module-name 或改 JAR 名 |
# 排查利器:打印模块描述符与依赖图
java --describe-module com.example.order
java --show-module-resolution -p mods -m com.example.order/com.example.order.Main
一句话总结: JPMS 的报错信息普遍明确,
--describe-module与--show-module-resolution是排查依赖图的两把钥匙;把报错关键词对上表即可快速定位。
小结
| 维度 | 要点 |
|---|---|
| 核心机制 | module-info.java 声明 requires/exports/opens/provides |
| 可见性 | exports 管编译,opens 管反射 |
| 路径 | module-path 有边界可校验,classpath 全可见 |
| 兼容 | 自动模块桥接传统 JAR,无名模块兜底 classpath |
| 工具 | jdeps 分析、jlink 裁剪、jmod 归档 |
| 策略 | 自底向上、宽进严出、按需 opens |
JPMS 的价值不在「一定要用」,而在「理解它之后你能判断什么时候该用」。库作者用它守护内部 API,平台团队用它裁剪运行时,应用团队则可以在 Spring 反射的现实下,把 --add-opens 从「临时补丁」升级为「显式契约」。
延伸阅读
- JVM 类加载机制与字节码深度解析 — 模块化背后的类加载与命名空间
- Maven 与 Gradle 构建优化深度实践 — 模块化工程的构建配置
- Java 核心基础深度精讲 — 从语言基础理解包与可见性
- GitHub Actions 中的 Java 与 JVM CI 流水线 — 在 CI 中校验模块依赖图
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。