Java 平台模块系统(JPMS)是 Java 9 引入的最重大结构调整:它把「类路径上的一堆 jar」变成「边界清晰的模块」,让强封装、显式依赖与可裁剪运行时成为语言级能力。本文从模块描述符、依赖导出、服务加载、模块拆分到 JLink 精简运行时,完整解析 JPMS 的工程实践与落地约束。
前置基础可先阅读 JVM 内存模型与类加载机制 与 Spring Boot 核心原理与自动配置。
1. JPMS 背景与目标
1.1 类路径时代的痛点
类路径把不同来源的类混在一起,带来三类经典问题:
| 痛点 | 后果 |
|---|---|
| 类名冲突 | 两个 jar 含相同类,先加载者赢 |
| 隐式依赖 | 编译期不知依赖谁,升级失控 |
| 无封装 | 所有 public 类都能被外部访问 |
1.2 模块化核心概念
模块(Module)是比包更大的封装单元:模块声明依赖(requires)、公开接口(exports)、提供服务(provides)与消费服务(uses)。模块边界在编译与运行期都被强制检查,违者直接报错。
1.3 模块路径与类路径
JPMS 引入了与类路径并行的模块路径(--module-path)。放在模块路径上的模块遵循强封装规则,放在类路径上的代码仍按旧规则运行。迁移阶段两者可共存:
# 模块路径:显式声明的模块
java --module-path mods:lib -m order.app/com.example.Main
# 类路径:普通 jar,作为未命名模块运行
java -cp lib/*.jar com.example.LegacyMain
理解两条路径的区别,是解读大多数 JPMS 报错(Package not found、Module not found)的前提。
2. 模块描述符 module-info.java
2.1 基本语法
每个模块根目录下有一个 module-info.java,声明模块的全部意图:
// order-service/src/main/java/module-info.java
module order.service {
requires order.api; // 依赖 order.api 模块
requires java.sql; // 依赖 JDK 模块
requires com.fasterxml.jackson.core;
exports com.example.order.service; // 对外公开的包
}
exports 之外的包即使类是 public 也不可被外部访问,这就是强封装。
2.2 限定导出
只对指定模块开放某个包,适合内部实现共享:
module order.service {
exports com.example.order.spi to order.extension;
}
to 之后列出允许访问的模块名,未列出的模块引用会编译失败。
2.3 requires transitive
当模块 A 公开 API 的签名引用了模块 B 的类型时,依赖方必须用 requires transitive,把 B 的依赖传递下去,否则调用方编译报「类型不可访问」:
module order.api {
requires transitive order.model; // 使用方自动获得 order.model
exports com.example.order.api;
}
3. 依赖与导出
3.1 强封装与模块图
模块系统在运行期构建模块依赖图,任何 requires 缺失或循环依赖都会在启动时抛出 ResolutionException:
java -m order.service/com.example.Main
# 若依赖图中存在缺失模块,启动即失败
3.2 opens 与反射
强封装默认阻止反射访问未导出的包。像 JPA、MyBatis、Spring 这类依赖反射的框架需要显式开放:
module order.service {
requires jakarta.persistence;
opens com.example.order.entity to org.hibernate.orm;
// 只向 Hibernate 开放实体包的反射访问
}
全开放(opens ... to 不带模块)或无条件开放(open module)应谨慎,会削弱封装。
3.3 未命名模块与类路径
放在类路径(而非模块路径)上的 jar 属于未命名模块,可以读取任何模块但看不到已命名模块的未导出包。这是迁移期的关键兼容机制:老应用不改代码也能运行,只是享受不到强封装。
3.4 拆包的代价与缓解
强封装与拆包(splitting)是模块化最常见的冲突。同一包名被拆到多个 jar 时,模块系统会报 Package conflict。缓解手段是让包名与模块一一对应:
拆分前:com.example.service 既在 a.jar 又在 b.jar → 冲突
拆分后:com.example.service.api 在 order-api 模块
com.example.service.impl 在 order-core 模块 → 不冲突
包的归属必须唯一,这是拆分模块时的第一纪律。
4. 服务加载
4.1 provides 与 uses
服务加载让模块之间以接口为契约、运行期解耦,是 JDK 的依赖注入雏形:
// order.spi 模块定义接口
module order.spi {
exports com.example.order.spi;
}
// order.biz 模块提供实现
module order.biz {
requires order.spi;
provides com.example.order.spi.PriceCalculator
with com.example.order.biz.DefaultPriceCalculator;
}
// order.web 模块消费服务
module order.web {
requires order.spi;
uses com.example.order.spi.PriceCalculator;
}
4.2 ServiceLoader 使用
// 消费方通过 ServiceLoader 发现所有实现
List<PriceCalculator> calculators = new ArrayList<>();
for (PriceCalculator c : ServiceLoader.load(PriceCalculator.class)) {
calculators.add(c);
}
模块化的 provides 比传统的 META-INF/services 更可靠,且不会因类路径顺序产生歧义。
4.3 服务解析与 Spring 的兼容
Spring Boot 的自动装配走 META-INF/spring.factories 或 @AutoConfiguration,本身不依赖 JPMS 服务。若模块 A 以 provides 提供了某个接口实现,Spring 的 @Service 扫描仍需要包被 exports 到 Spring 模块或 opens 给反射扫描。
5. 拆分模块
5.1 拆分策略
把现有 jar 拆成多个模块,遵循「按依赖方向分层」的原则:
order-model(纯领域对象,无外部依赖)
↑
order-api(接口与 SPI,依赖 model)
↑
order-core(业务实现,依赖 api 与 model)
↑
order-app(启动入口,依赖 core)
顶层模块依赖下层,禁止反向依赖,模块图保持无环。
5.2 jdeps 分析
拆分前用 jdeps 分析现有 jar 的真实依赖,识别跨包访问与 JDK 内部 API 依赖:
jdeps --module-path . --generate-module-info out order-service.jar
--generate-module-info 能自动生成初版 module-info.java,是迁移的起点。再配合 --check 检查模块依赖图是否完备。
5.3 模块依赖图可视化
// 生成模块描述,再用 jmod 或工具画依赖图
jdeps --module-path . -s order.service
order.app → order.core → order.api → order.model
└──────→ java.sql, com.fasterxml.jackson.core
依赖图越小、方向越清晰,模块化的收益越明显。
5.4 增量迁移路径
已有工程迁移到 JPMS 不必一步到位,推荐四步渐进:
第 1 步:先用 jdeps 分析依赖,找出 JDK 内部 API 的调用点
第 2 步:将可模块化的核心 jar 加入模块路径,其余留在类路径
第 3 步:逐个补充 module-info.java,用 --generate-module-info 打底
第 4 步:全部收敛到模块路径后,启用 jlink 做运行时裁剪
每一步都可在 CI 中用 --check 校验模块图,把迁移风险前置到构建阶段。
6. JLink 精简运行时
6.1 jlink 基本用法
jlink 基于模块描述生成最小化运行时镜像,只打包用到的模块,显著缩小 JDK 体积:
# 生成精简运行时到 target/plume-runtime
jlink --module-path "$JAVA_HOME/jmods":target/mods \
--add-modules order.app,jdk.unsupported \
--output target/plume-runtime \
--strip-debug --no-man-pages --no-header-files \
--compress=2
6.2 自定义运行时镜像
镜像生成后,目录结构与 JRE 一致,直接执行应用入口:
target/plume-runtime/bin/java -m order.app/com.example.Main
--compress=2 用 zip 压缩类文件,进一步缩小镜像体积;--strip-debug 移除调试信息。镜像内自带精简的 java.base 与依赖模块,不含无关模块。
6.3 与 Docker 结合
精简运行时配合基础镜像,能做出体积很小的容器:
FROM alpine:3.20
COPY target/plume-runtime /opt/plume
COPY target/app /app
ENTRYPOINT ["/opt/plume/bin/java", "-m", "order.app/com.example.Main"]
完整 JDK 镜像约 300MB,jlink 精简运行时加 Alpine 可压到 50MB 以内
7. 与 Maven 和 Spring Boot 兼容性
7.1 Maven 模块与自动模块
Maven 的多模块工程与 JPMS 模块不是一回事:前者是构建期聚合,后者是运行期边界。未提供 module-info.java 的第三方 jar 在模块路径上会自动成为「自动模块」(模块名取自 jar 文件名),可以被 requires。
<!-- Maven 多模块与 JPMS 并存:每个模块 src/main/java/module-info.java -->
<modules>
<module>order-model</module>
<module>order-api</module>
<module>order-core</module>
<module>order-app</module>
</modules>
7.2 Spring Boot 模块化支持
Spring Boot 3 基于 Java 17,整体可作为自动模块被依赖,但完整模块化需要逐个 open:
module order.app {
requires spring.boot;
requires spring.boot.autoconfigure;
requires spring.context;
opens com.example.order.app to spring.core, spring.beans, spring.context;
uses com.example.order.spi.PriceCalculator;
}
启动类所在包必须 opens 给 Spring 容器,否则 @ComponentScan 扫描不到。
7.3 常见坑
| 坑 | 现象 | 对策 |
|---|---|---|
| 使用 JDK 内部 API | IllegalAccessError | 用标准 API 替代或 --add-exports 兜底 |
| 第三方依赖非模块 | 找不到模块 | 放类路径或接受自动模块 |
| 反射包未 opens | 运行期空指针/代理失败 | 逐个 opens 给对应框架 |
| 模块名与 jar 冲突 | 启动解析失败 | 统一用倒置域名命名 |
| 资源文件封装 | getResourceAsStream 读不到 | 用 Module.getResourceAsStream |
7.4 运行时参数兜底
对存量系统,启动参数是模块化的「逃生舱」。--add-exports、--add-opens 可在不改代码的情况下放行特定访问:
java --add-opens java.base/java.lang=order.app \
--add-exports java.base/sun.nio.ch=ALL-UNNAMED \
-m order.app/com.example.Main
这些参数是临时的兼容手段,应记录在构建脚本中并逐步消除,避免长期依赖成为新的技术债。
8. 总结
| 主题 | 核心要点 |
|---|---|
| 模块边界 | requires 声明依赖,exports 公开接口 |
| 强封装 | 未导出包不可访问,反射需 opens |
| 服务加载 | provides 实现、uses 消费、ServiceLoader 发现 |
| 模块拆分 | 按依赖方向分层,jdeps 辅助分析 |
| JLink | 按需打包模块,运行时镜像体积大幅缩小 |
| 兼容落地 | Maven 多模块并存,Spring 需 open 启动包 |
JPMS 的价值不在「必须模块化」,而在于给了架构以语言级约束力:依赖显式、封装强制、运行时可裁剪。对追求极致交付体积的云原生场景,JLink 精简运行时是立竿见影的收益;对成熟微服务,按模块拆分则能长期守护依赖边界的干净。理解 module-info 与模块路径的运行规则,是 Java 工程师面向未来的必修课。
延伸阅读
- JVM 内存模型与类加载机制 — 模块路径与类加载的分层关系
- Spring Boot 核心原理与自动配置 — 自动装配与模块 opens 的配合
- Java 微服务治理深化:熔断限流降级、灰度发布与全链路压测 — 模块边界在服务治理中的价值
- Java 测试策略与工程化 — 模块化工程的测试隔离
- Spring AOP 原理剖析与 AspectJ 企业级实战 — 代理与反射对 opens 的要求
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。