Java 基础体系 · 第 80/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 25 模块系统 JPMS:requires、exports、opens、服务和迁移
Java Platform Module System(JPMS)是 Java 9 引入、在 Java 25 LTS 中继续使用的模块系统。它解决的不是“把多个 JAR 放在一起”这么简单的问题,而是同时约束:
- 模块之间是否可读;
- 包中的类型是否对其他模块可访问;
- 反射是否可以深入访问非公开成员;
- 服务接口与服务实现如何解耦;
- 类路径上的旧代码如何逐步迁移到模块路径;
- 编译期、启动期和运行期分别在哪里失败。
理解 JPMS 时,必须区分三个概念:
- 模块可读性(readability):模块 A 是否依赖并可以读取模块 B;
- 包导出(export):模块 B 是否允许模块 A 进行普通 Java 访问;
- 包开放(open):模块 B 是否允许模块 A 通过反射访问其非公开成员。
可把普通访问条件抽象为:
其中:
- 是访问者模块;
- 是被访问模块;
- 是类型
T所在的包; Reads(A,B)表示 A 的模块图中可读 B;Exports(B,p,A)表示 B 将包p导出给 A,或者导出给所有模块。
即使包已经 exports,如果 A 不 requires B,普通访问仍然不成立;即使 A requires B,如果 B 没有导出该包,普通访问也不成立。
1. 模块、模块描述符和模块路径
1.1 模块是什么
一个命名模块通常由以下部分组成:
com.example.order
├── module-info.java
└── com/example/order/OrderService.java
其中 module-info.java 是模块描述符,声明模块名称、依赖、导出包、开放包和服务关系。
最小示例:
module com.example.order {
}
模块名称不是 JAR 文件名。JAR 文件通常可以命名为:
com.example.order-1.0.jar
但真正的模块名来自:
- 模块化 JAR 中的
module-info.class; - 非模块化 JAR 的
Automatic-Module-Name清单属性; - 如果两者都没有,则由模块路径上的 JAR 文件名推导自动模块名。
模块名应使用稳定、唯一的反向域名形式,例如:
com.example.order
org.example.persistence
模块名属于模块图的标识。随意修改模块名会影响 requires、--add-reads、服务配置以及下游编译命令。
1.2 模块路径与类路径
JPMS 引入了模块路径(module path),但没有废弃类路径(class path)。
| 位置 | 主要作用 |
|---|---|
类路径 -cp / --class-path |
传统 Java 类和 JAR |
模块路径 -p / --module-path |
命名模块和自动模块 |
升级模块路径 --upgrade-module-path |
替换系统模块中的模块 |
补丁模块 --patch-module |
向指定模块追加类,主要用于测试和特殊迁移 |
类路径上的全部类型属于未命名模块(unnamed module)。未命名模块没有 module-info.java,不能声明 requires、exports 或 opens,但它具有特殊的兼容行为:
- 未命名模块可以读取所有可观察到的命名模块;
- 未命名模块的所有包都相当于开放且导出;
- 命名模块不能通过普通
requires读取未命名模块。
这解释了为什么许多旧应用放在类路径上可以访问几乎任何内容,而迁移到模块路径后会突然出现访问错误。
2. requires:建立模块可读性
2.1 基本形式
module com.example.app {
requires com.example.order;
}
这表示:
com.example.app ---> com.example.order
也就是应用模块可以读取订单模块。requires 同时影响:
javac编译时的符号解析;- JVM 链接模块图时的依赖解析;
- 运行时访问检查;
- 模块化工具对依赖关系的分析。
requires 不等于“可以访问目标模块中的所有类”。它只建立可读性,目标包是否导出仍由 exports 决定。
2.2 java.base 是隐式依赖
每个命名模块都隐式读取 java.base,因此不需要写:
requires java.base;
下面的声明是错误或没有意义的:
module com.example.app {
requires java.base;
}
java.base 是 Java SE 核心模块,包含 java.lang、java.util、java.io 等基础包。其他平台模块,例如 java.sql、java.net.http、java.logging,则必须显式声明:
module com.example.report {
requires java.sql;
requires java.net.http;
}
2.3 普通 requires 的传递边界
假设有三个模块:
com.example.app requires com.example.service
com.example.service requires com.example.model
如果 com.example.service 使用了 com.example.model 的公开类型,但没有把这些类型暴露在自己的公共 API 中,普通 requires 通常足够。
但如果 com.example.service 的公共 API 出现了 com.example.model 的类型,例如:
package com.example.service.api;
import com.example.model.Order;
public interface OrderService {
Order findById(long id);
}
那么使用 OrderService 的下游模块也需要读取 com.example.model。这时可以在服务模块中声明:
module com.example.service {
requires transitive com.example.model;
exports com.example.service.api;
}
requires transitive 的含义是:
A requires transitive B
对于任何读取 A 的模块 C,模块系统也会让 C 读取 B。它传播的是可读性,不是包导出,也不是实现类访问。
因此:
module com.example.service {
requires transitive com.example.model;
}
并不表示 com.example.model 的所有包都被重新导出。模型模块仍然必须自己声明:
module com.example.model {
exports com.example.model;
}
2.4 requires static:编译期依赖
module com.example.app {
requires static com.example.annotations;
}
requires static 表示:
- 编译
com.example.app时需要com.example.annotations; - 运行
com.example.app时,模块系统不要求该依赖一定存在。
典型用途是仅用于注解处理或编译期 API 的模块。
例如:
import com.example.annotations.Generated;
@Generated
public class OrderMapper {
}
如果注解只保留在源码或类文件中,并且运行时不需要加载注解类型,requires static 可能合适。
但如果运行时会执行:
Class.forName("com.example.annotations.SomeRuntimeType");
或者框架会读取并实例化该类型,那么 requires static 不能替代运行时依赖。缺少模块时,错误可能从启动期延迟到运行期,表现为:
ClassNotFoundException
NoClassDefFoundError
LayerInstantiationException
2.5 requires 的失败位置
对于:
module com.example.app {
requires com.example.service;
}
可能出现三类失败:
-
编译时找不到模块
module not found: com.example.service通常是
--module-path不正确,或者模块名与声明不一致。 -
启动时找不到依赖
java.lang.module.FindException: Module com.example.service not found编译路径和运行路径不是同一个概念,编译成功不代表运行时模块路径正确。
-
访问类型时失败
package com.example.service.internal is not visible (package ... is declared in module ..., which does not export it)此时依赖已经找到,但目标包没有导出。
3. exports:控制普通 Java 访问
3.1 导出包,而不是导出类型
模块描述符写的是包:
module com.example.order {
exports com.example.order.api;
}
这表示 com.example.order.api 包中的可访问类型可以被符合模块可读性条件的其他模块使用。
exports 不接受单个类名:
exports com.example.order.api.OrderService; // 错误
包中的 public 类型可以被其他模块使用;包中的包私有、private 和 protected 成员仍然遵循 Java 语言访问控制。
因此,exports 是两层条件中的一层:
module com.example.order {
exports com.example.order.api;
}
调用方仍然需要:
module com.example.app {
requires com.example.order;
}
调用代码:
import com.example.order.api.OrderService;
public class Main {
public static void main(String[] args) {
OrderService service = ...;
}
}
只有 requires 和 exports 同时满足,普通编译和运行访问才成立。
3.2 不导出的包是真正的模块边界
下面的包没有导出:
module com.example.order {
exports com.example.order.api;
}
因此:
com.example.order.api 对外 API
com.example.order.internal 模块内部实现
其他模块不能直接导入:
import com.example.order.internal.OrderRepository;
编译器会拒绝:
package com.example.order.internal is not visible
模块系统的价值正在这里:类路径时代常见的“约定上不要使用 internal 包”,变成了编译器和运行时都能检查的边界。
3.3 限定导出
可以只向指定模块导出:
module com.example.order {
exports com.example.order.spi
to com.example.order.adapter,
com.example.test;
}
这叫限定导出(qualified export)。此时 com.example.order.spi:
- 对列出的模块可访问;
- 对其他命名模块不可访问;
- 不会因为普通
requires就对所有模块开放。
限定导出适合模块之间存在明确的内部 SPI,但不希望它成为公共 API 的场景。
常见误解是:
exports com.example.order.spi to com.example.adapter;
会自动让 com.example.adapter 读取 com.example.order。不会。调用方仍应声明:
module com.example.adapter {
requires com.example.order;
}
模块可读性和包导出是独立检查。
3.4 exports 不等于反射开放
下面的代码可以进行普通访问:
module com.example.domain {
exports com.example.domain;
}
但不能保证框架通过反射执行:
field.setAccessible(true);
constructor.setAccessible(true);
method.invoke(target, args);
特别是访问非公开构造器、字段或方法时,通常需要 opens,而不是 exports。
4. opens:控制深层反射
4.1 opens 的语义
module com.example.domain {
opens com.example.domain;
}
opens 允许运行时反射深入访问该包中的类型和成员,包括非公开成员。它主要影响 java.lang.reflect、序列化框架、依赖注入框架、ORM、JSON 映射器等工具。
但 opens 不提供普通编译访问:
module com.example.domain {
opens com.example.domain;
}
其他模块仍不能直接写:
import com.example.domain.Customer;
除非同时存在:
exports com.example.domain;
因此:
| 声明 | 普通编译访问 | 非公开成员反射 |
|---|---|---|
exports p |
可以访问公开类型 | 不保证 |
opens p |
不可以 | 可以 |
exports p; opens p; |
可以 | 可以 |
open module |
普通访问仍取决于 exports |
所有包开放 |
4.2 限定开放
可以只允许指定框架模块反射:
module com.example.domain {
opens com.example.domain.persistence
to org.hibernate.orm.core;
}
这比无条件 opens 更严格。只有 org.hibernate.orm.core 可以对该包进行深层反射。
如果框架运行在类路径上的未命名模块中,常见写法是:
module com.example.domain {
opens com.example.domain.persistence to ALL-UNNAMED;
}
ALL-UNNAMED 不是一个实际模块名,而是命令行和模块声明中表示所有未命名模块的特殊目标。
4.3 开放模块
open module com.example.domain {
exports com.example.domain.api;
}
开放模块中的所有包都对运行时反射开放,但没有因此自动导出。也就是说:
open影响反射;exports影响普通访问;open module不能再写opens指令,因为所有包已经隐式开放。
开放模块适合需要大量反射的应用,但边界较宽。库模块通常更适合使用精确的限定 opens,避免把整个实现面暴露给所有代码。
4.4 反射失败的具体表现
假设模块没有开放 com.example.domain,框架执行:
Constructor<Customer> constructor =
Customer.class.getDeclaredConstructor();
constructor.setAccessible(true);
可能出现:
java.lang.reflect.InaccessibleObjectException:
Unable to make ... accessible:
module com.example.domain does not "opens com.example.domain"
to module ...
这和 IllegalAccessException 不完全相同:
IllegalAccessException常表示语言级访问检查失败;InaccessibleObjectException通常表示强封装阻止了深层反射。
临时诊断可以使用:
java \
--add-opens com.example.domain/com.example.domain=org.hibernate.orm.core \
--module-path mods \
-m com.example.app/com.example.app.Main
但 --add-opens 是启动参数覆盖,不应被误解为模块描述符中的正式设计。生产环境应确认实际需要开放的包和目标模块,再决定是否永久加入模块声明。
5. 完整示例:两个模块的编译与运行
目录结构:
src/
├── com.example.order/
│ ├── module-info.java
│ └── com/example/order/api/OrderService.java
└── com.example.app/
├── module-info.java
└── com/example/app/Main.java
订单模块:
// src/com.example.order/module-info.java
module com.example.order {
exports com.example.order.api;
}
// src/com.example.order/com/example/order/api/OrderService.java
package com.example.order.api;
public final class OrderService {
public String find(long id) {
return "order-" + id;
}
}
应用模块:
// src/com.example.app/module-info.java
module com.example.app {
requires com.example.order;
}
// src/com.example.app/com/example/app/Main.java
package com.example.app;
import com.example.order.api.OrderService;
public final class Main {
public static void main(String[] args) {
OrderService service = new OrderService();
System.out.println(service.find(42));
}
}
使用 Java 25 的 javac:
javac -d out \
--module-source-path src \
-m com.example.order,com.example.app
这里:
-d out指定类文件输出目录;--module-source-path src告诉编译器模块源码的根目录;-m指定要编译的模块;- 编译器读取
com.example.app的requires,再解析com.example.order。
运行:
java \
--module-path out \
--module com.example.app/com.example.app.Main
预期输出:
order-42
如果删除 exports com.example.order.api;,编译时会失败;如果保留导出但删除 requires com.example.order;,应用模块也会在编译时失败。两个声明分别验证可读性和可访问性。
可以使用以下命令检查模块描述符:
jar --describe-module --file out/com.example.order/module-info.class
更常见的是对模块化 JAR 执行:
jar --describe-module --file com.example.order.jar
也可以列出模块解析结果:
java \
--show-module-resolution \
--module-path out \
--module com.example.app/com.example.app.Main
该选项会输出根模块及其依赖模块,适合诊断“为什么某个模块没有被解析”或“某个依赖从哪里进入模块图”。
6. 模块图、根模块和解析过程
模块系统不是简单地扫描所有 JAR 并全部加载。启动时需要先确定一组根模块(root modules),再递归解析它们的 requires。
可以把解析过程表示为:
根模块
↓ 读取 requires
直接依赖
↓ 继续读取 requires
传递依赖
↓
形成 Configuration 和 ModuleLayer
启动应用:
java --module-path mods \
--module com.example.app/com.example.app.Main
通常 com.example.app 是根模块,java.base 也会被纳入。其余模块只有在模块图解析规则将其纳入,或者通过命令行显式加入时才可见。
显式加入根模块:
java \
--module-path mods \
--add-modules com.example.provider \
--module com.example.app/com.example.app.Main
--add-modules 常用于:
- 服务提供者模块;
- 只通过反射访问、没有静态
requires的模块; - 启动时需要强制纳入的可选模块;
- 测试和诊断。
模块解析成功后,JVM 创建一个配置,并据此创建模块层(ModuleLayer)。模块层定义了模块实例、类加载器关系和可读性关系。应用可以创建额外的层,但同一个模块名在不同层中可以对应不同模块实例。
这意味着服务发现和类加载并非只由 JAR 文件决定,还取决于服务提供者所在模块是否进入了当前层。
7. JPMS 服务:uses、provides ... with 和 ServiceLoader
JPMS 服务机制用于把“接口定义方”和“实现提供方”解耦。
一个服务由三部分组成:
- 服务接口:定义能力;
- 服务使用者:声明
uses并通过ServiceLoader查找; - 服务提供者:声明
provides ... with,给出实现类或提供者方法。
7.1 服务接口模块
src/
└── com.example.payment.api/
├── module-info.java
└── com/example/payment/PaymentProcessor.java
// module-info.java
module com.example.payment.api {
exports com.example.payment;
}
package com.example.payment;
public interface PaymentProcessor {
String name();
String pay(int amount);
}
服务接口必须导出给使用者和提供者,以便它们在编译期引用接口。
7.2 提供者模块
src/
└── com.example.payment.mock/
├── module-info.java
└── com/example/payment/mock/MockPaymentProcessor.java
module com.example.payment.mock {
requires com.example.payment.api;
provides com.example.payment.PaymentProcessor
with com.example.payment.mock.MockPaymentProcessor;
}
package com.example.payment.mock;
import com.example.payment.PaymentProcessor;
public final class MockPaymentProcessor
implements PaymentProcessor {
public MockPaymentProcessor() {
}
@Override
public String name() {
return "mock";
}
@Override
public String pay(int amount) {
return "paid " + amount;
}
}
提供者实现包不需要 exports。这是服务机制的重要边界:
module com.example.payment.mock {
requires com.example.payment.api;
provides com.example.payment.PaymentProcessor
with com.example.payment.mock.MockPaymentProcessor;
}
使用者不需要直接导入 MockPaymentProcessor,因此实现包可以保持封装。
服务提供者必须满足 ServiceLoader 的提供者构造要求。常见形式是具有公开无参构造器的公开提供者类。Java 也支持声明一个公开静态提供者方法,由该方法创建服务对象;实际使用时应遵循 ServiceLoader 对提供者类或提供者方法的约束。
7.3 使用者模块
module com.example.payment.app {
requires com.example.payment.api;
uses com.example.payment.PaymentProcessor;
}
package com.example.payment.app;
import com.example.payment.PaymentProcessor;
import java.util.ServiceLoader;
public final class Main {
public static void main(String[] args) {
ServiceLoader<PaymentProcessor> loader =
ServiceLoader.load(PaymentProcessor.class);
boolean found = false;
for (PaymentProcessor processor : loader) {
found = true;
System.out.println(processor.name());
System.out.println(processor.pay(100));
}
if (!found) {
throw new IllegalStateException(
"No PaymentProcessor provider found");
}
}
}
uses 声明的是“本模块会查找某服务”,它不等于直接依赖某个实现模块。这样,应用模块可以在不重新编译的情况下替换服务提供者。
7.4 编译与运行
编译全部模块:
javac -d out \
--module-source-path src \
-m com.example.payment.api,com.example.payment.mock,com.example.payment.app
运行时,为了明确把提供者模块纳入模块图:
java \
--module-path out \
--add-modules com.example.payment.mock \
--module com.example.payment.app/com.example.payment.app.Main
预期输出:
mock
paid 100
运行过程可以分解为:
com.example.payment.app读取服务接口模块;uses告知模块系统和ServiceLoader,该模块查找PaymentProcessor;com.example.payment.mock声明自己提供该服务;ServiceLoader在当前模块层中寻找提供者;- JVM 加载提供者类并调用其构造器;
- 应用只依赖接口,不依赖实现包。
如果提供者模块没有进入当前模块层,ServiceLoader 可能返回空结果,而不是抛出“类找不到”。因此服务问题应同时检查:
java --show-module-resolution ...
以及:
jar --describe-module --file provider.jar
确认:
provides的服务接口名称拼写正确;- 实现类确实在模块中;
- 提供者模块位于运行时模块路径;
- 提供者模块已经被解析到当前层;
- 使用者声明了
uses; - 提供者实现满足实例化要求。
7.5 服务提供者与模块可读性
服务机制降低的是实现耦合,不是所有模块检查。
使用者通常需要:
requires com.example.payment.api;
uses com.example.payment.PaymentProcessor;
提供者通常需要:
requires com.example.payment.api;
provides com.example.payment.PaymentProcessor
with com.example.payment.mock.MockPaymentProcessor;
提供者模块并不需要被使用者模块通过普通 requires 直接读取。服务提供者由模块层和 ServiceLoader 发现。
但是,服务接口本身必须能被使用者和提供者读取。如果接口模块没有导出接口包,编译阶段就无法建立服务声明和调用代码。
8. exports、opens 与服务的组合边界
以一个 ORM 模块为例:
module com.example.domain {
exports com.example.domain.api;
opens com.example.domain.entity
to org.hibernate.orm.core;
}
这里有两个不同的使用方向:
应用代码 ──普通访问──> com.example.domain.api
Hibernate ──深层反射──> com.example.domain.entity
应用可以导入 API 包中的公开类型,但不能直接访问实体实现包;Hibernate 可以反射实体包中的私有字段,但不因此获得普通编译访问权。
如果同时把实体包导出:
exports com.example.domain.entity;
那么任何读取该模块的代码都可以普通访问实体公开类型。这样可能破坏领域模型的封装。反过来,如果只写 exports 而不写 opens,ORM 可能在运行时初始化实体失败。
这不是 Java 语言访问修饰符能够单独解决的问题。public 只说明类型在语言层面公开,模块系统还会检查包导出和模块可读性;反射则另外检查包是否开放。
9. 从类路径迁移到 JPMS
迁移不是简单地给每个 JAR 增加一行 module-info.java。旧系统通常依赖三类类路径行为:
- 任意代码都能读取任意公开类型;
- 框架可以反射访问私有成员;
- 同一个包可以由多个 JAR 共同提供。
JPMS 会分别对这三点施加限制。
9.1 第一步:识别依赖和模块名
先检查 JAR 是否已经模块化:
jar --describe-module --file legacy-lib.jar
如果 JAR 没有模块描述符,可以使用:
jdeps --generate-module-info generated legacy-lib.jar
jdeps 会根据字节码引用推断模块声明草稿,但它不能可靠推断:
- 反射访问;
ServiceLoader使用;- 字符串形式的类名;
- 配置文件中的类;
- 动态代理和字节码生成;
- 运行时由容器注入的依赖。
因此生成的模块描述符必须人工审核。
检查依赖关系:
jdeps --recursive \
--module-path libs \
legacy-app.jar
检查模块内部是否存在问题:
jdeps --check com.example.app \
--module-path mods
这些命令提供静态证据,但不能替代集成测试。
9.2 自动模块
把没有 module-info.class 的 JAR 放到模块路径上时,它会成为自动模块。
自动模块通常具有以下特征:
- 拥有一个自动模块名;
- 对其他模块暴露全部包;
- 读取所有其他可观察模块;
- 适合过渡,但边界宽松。
如果 JAR 清单包含:
Automatic-Module-Name: com.example.legacy
则模块名稳定为 com.example.legacy。否则名称可能由文件名推导,例如:
legacy-utils-2.4.1.jar
推导名称可能随版本文件名改变,因此生产依赖应优先使用稳定的 Automatic-Module-Name 或正式模块化版本。
自动模块不是“已经完成模块化”。它解决了模块图中如何命名和连接的问题,但没有提供精确的封装边界。
9.3 迁移阶段的混合布局
一种可行的过渡状态是:
模块路径:
com.example.app
com.example.domain
legacy-framework.jar // 自动模块
类路径:
旧的应用插件
动态加载的脚本组件
迁移期间需要明确:
- 哪些代码属于命名模块;
- 哪些依赖仍是自动模块;
- 哪些库必须留在类路径;
- 反射目标是否位于命名模块;
- 服务提供者是否进入了当前模块层。
命名模块不能直接依赖未命名模块。若命名模块必须读取类路径中的特定库,可以临时使用:
java \
--add-reads com.example.app=ALL-UNNAMED \
--class-path legacy.jar \
--module-path mods \
--module com.example.app/com.example.app.Main
但这只是启动参数建立的临时读取边,不能让普通模块声明永久表达这种依赖。更稳定的方案是将依赖放到模块路径上成为自动模块,或为其提供正式模块描述符。
9.4 分裂包
分裂包(split package) 指同一个包由多个模块提供,例如:
com.example.lib.a.jar com.example.shared
com.example.lib.b.jar com.example.shared
模块路径上的命名模块不允许这种布局,因为一个包必须有明确的所有者。典型错误是:
java.lang.module.ResolutionException:
Modules ... export package com.example.shared to module ...
迁移时应采取以下结构性修复之一:
- 合并两个 JAR;
- 将同一包拆成不同包;
- 把公共类型抽到一个独立模块;
- 使用
--patch-module仅处理测试或明确的特殊场景。
--patch-module:
java \
--patch-module com.example.domain=test-classes \
--module-path mods \
--module com.example.app/com.example.app.Main
它会把额外类放入已有模块的内容中,适用于测试模块内部包访问等场景,但会改变模块内容,不应当作为普通生产依赖管理方式。
9.5 反射迁移
旧代码在类路径上可能依赖:
field.setAccessible(true);
迁移后应按访问目的分类:
- 普通调用公开 API:使用
exports; - 框架反射非公开成员:使用
opens; - 只允许某个框架:使用限定
opens; - 临时验证:使用
--add-opens; - 仅需读取公开成员:不应无条件开放整个模块。
例如:
java \
--add-opens com.example.domain/com.example.domain.entity=org.hibernate.orm.core \
--module-path mods \
--module com.example.app/com.example.app.Main
--add-opens 的目标必须是实际运行时模块名。若框架在类路径上,目标通常使用:
--add-opens com.example.domain/com.example.domain.entity=ALL-UNNAMED
--add-exports 只解决限定的普通访问,不等同于 --add-opens:
--add-exports com.example.domain/com.example.domain.internal=org.example.test
它允许目标模块访问导出包中的公开类型,但不能让目标模块反射进入非公开成员。
9.6 服务迁移
类路径时代的服务实现通常通过:
META-INF/services/com.example.payment.PaymentProcessor
登记。模块化后,服务关系可以写进 module-info.java:
uses com.example.payment.PaymentProcessor;
provides com.example.payment.PaymentProcessor
with com.example.payment.mock.MockPaymentProcessor;
迁移时要检查两种服务声明是否同时存在。保留 META-INF/services 有时有助于兼容旧的类路径加载方式,但不能因此假设模块系统一定会自动解析并加载提供者模块。
服务提供者是否可见取决于模块层。仅把 JAR 放在 --module-path 上,不一定意味着它已经进入当前配置。必要时应使用:
--add-modules provider.module
或者在自定义模块层中使用带服务绑定的解析方式。对 jlink 生成运行时镜像时,--bind-services 可将服务提供者及其依赖绑定进镜像;这会改变镜像包含的模块集合,应在构建后验证服务发现结果。
10. 迁移后的最小模块设计
一个常见的分层设计可以是:
com.example.domain
└── exports com.example.domain.api
com.example.repository.api
├── requires com.example.domain
└── exports com.example.repository
com.example.repository.jdbc
├── requires com.example.repository.api
├── requires java.sql
└── provides ... with ...
com.example.app
├── requires com.example.repository.api
└── uses ...
关键关系如下:
graph TD
APP[com.example.app]
API[com.example.repository.api]
DOMAIN[com.example.domain]
JDBC[com.example.repository.jdbc]
SQL[java.sql]
APP -->|requires| API
API -->|requires| DOMAIN
JDBC -->|requires| API
JDBC -->|requires| SQL
APP -->|uses Repository| API
JDBC -->|provides Repository| API
其中:
- 应用依赖接口模块,不依赖 JDBC 实现模块;
- JDBC 模块通过服务提供实现;
- 领域模块只导出稳定 API;
java.sql是显式平台模块依赖;- 实现包可以不导出,只通过服务机制暴露能力。
如果应用的公共类型把 domain 类型暴露给下游,才考虑:
requires transitive com.example.domain;
否则不应为了“方便”随意使用 transitive。传递依赖会扩大下游模块图,并使 API 依赖关系变得隐含。
11. 常用命令与故障诊断
查看 JAR 的模块描述符
jar --describe-module --file application.jar
用于确认:
- 实际模块名;
requires;exports;opens;uses;provides。
观察启动时的模块解析
java \
--show-module-resolution \
--module-path mods \
--module com.example.app/com.example.app.Main
重点关注:
- 根模块是否正确;
- 依赖模块是否来自预期路径;
- 服务提供者是否进入解析结果;
- 是否误加载了自动模块或旧版本模块。
查询 JDK 中的模块
java --list-modules
Java 25 的运行时镜像通常会列出类似:
java.base@25
java.sql@25
java.net.http@25
具体模块集合取决于所使用的 JDK 发行版和镜像内容。
典型错误与原因
Module not found
error: module not found: com.example.order
优先检查:
javac --module-path libs ...
java --module-path mods ...
编译和运行是否使用了不同目录,模块名是否来自实际 module-info.class,以及 JAR 是否放在模块路径而不是类路径。
package ... is not visible
package com.example.order.internal is not visible
通常表示:
- 调用方没有
requires; - 目标模块没有导出该包;
- 目标包是限定导出,但访问者不在允许列表中。
does not export ... to module ...
这是模块导出边界失败。应先判断代码是否真的应该依赖该内部包,而不是立即添加 --add-exports。如果是应用自身的错误依赖,修正包边界通常比扩大导出更安全。
does not "opens ..." to module ...
这是深层反射失败。应检查框架实际所在模块:
java --show-module-resolution ...
然后选择:
- 正式添加
opens; - 添加限定
opens; - 临时使用
--add-opens; - 改造框架配置,改用公开构造器和公开 API。
LayerInstantiationException 或分裂包错误
检查所有模块的包清单:
jar --describe-module --file a.jar
jar --describe-module --file b.jar
确认同一包是否由多个命名模块提供。该问题不能通过 exports 解决,因为冲突发生在模块层建立之前。
12. 强封装的边界和兼容选项
JPMS 的强封装意味着命名模块中的非公开 JDK 内部 API 不应被任意访问。迁移旧代码时,可能会看到:
WARNING: Illegal reflective access
或者在更严格的运行环境中直接失败。解决方向依次是:
- 使用标准 Java SE API;
- 使用库公开的扩展点;
- 对确有必要的应用包添加精确
opens; - 只在明确知道风险时使用
--add-exports、--add-opens; - 通过测试验证升级 JDK 后仍然成立。
--add-exports 和 --add-opens 都是运行时覆盖机制,不应被当作模块设计的替代品。它们会把部署命令与内部包结构绑定起来,并可能在库升级或模块重命名后失效。
13. 规范保证、实现行为和工程取舍
需要区分三类结论。
规范保证包括:
- 命名模块通过模块描述符声明依赖和边界;
- 普通访问需要模块可读性和包导出;
opens控制运行时深层反射;uses和provides描述服务关系;- 模块路径上的命名模块不能存在不合法的分裂包关系;
java.base是隐式依赖。
常见实现行为包括:
javac和java根据模块路径扫描模块;jdeps根据字节码推断静态依赖;ServiceLoader在当前ModuleLayer中查找服务;- JDK 工具输出模块解析过程和模块描述符。
工程建议则取决于应用:
- 库通常应导出少量稳定 API;
- 框架需要反射时应优先限定
opens; requires transitive应只用于公共 API 的传递依赖;- 自动模块适合迁移过渡,不代表完整封装;
--add-opens、--add-exports应作为经过验证的兼容手段,而非默认配置;- 服务提供者必须在构建和运行时都验证是否进入目标模块层。
14. 一条可验证的迁移路径
可以按以下顺序推进,但每一步都应结合编译和运行验证:
- 盘点依赖:使用
jdeps、jar --describe-module确认静态依赖和模块名。 - 先模块化应用入口:为应用增加
module-info.java,保留部分依赖为自动模块。 - 逐个处理访问错误:区分缺少
requires、缺少exports、缺少opens和分裂包。 - 登记服务关系:把接口模块、使用者模块和提供者模块分别写入
uses、provides。 - 验证启动模块图:使用
--show-module-resolution检查编译图和运行图是否一致。 - 收紧反射范围:把无条件
open module逐渐替换为限定opens。 - 清理临时参数:评估
--add-reads、--add-exports、--add-opens是否可以转为正式模块声明或代码修改。 - 验证打包结果:如果使用
jlink或自定义ModuleLayer,额外验证服务发现、资源加载和反射框架初始化。 - 测试失败路径:不仅测试主流程,还要测试缺失服务提供者、缺少反射开放、缺少可选依赖和重复模块版本。
JPMS 的核心不是“每个 JAR 都有一个模块名”,而是让依赖、访问和运行时扩展点变成可检查的结构。requires 解决模块是否可读,exports 解决普通访问,opens 解决深层反射,服务声明解决接口与实现的发现关系;迁移工作则是把类路径时代依赖隐含行为逐步转化为这些显式关系。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Gradle 完整基础:Task、配置缓存、依赖、插件和多模块构建
- 下一篇:Java 代码质量:Checkstyle、SpotBugs、Error Prone、覆盖率和门禁
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论