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 时,必须区分三个概念:

  1. 模块可读性(readability):模块 A 是否依赖并可以读取模块 B;
  2. 包导出(export):模块 B 是否允许模块 A 进行普通 Java 访问;
  3. 包开放(open):模块 B 是否允许模块 A 通过反射访问其非公开成员。

可把普通访问条件抽象为:

Access(A,B.p.T)=Reads(A,B)Exports(B,p,A)\text{Access}(A, B.p.T) = \text{Reads}(A,B) \land \text{Exports}(B,p,A)

其中:

  • AA 是访问者模块;
  • BB 是被访问模块;
  • pp 是类型 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,不能声明 requiresexportsopens,但它具有特殊的兼容行为:

  • 未命名模块可以读取所有可观察到的命名模块;
  • 未命名模块的所有包都相当于开放且导出;
  • 命名模块不能通过普通 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.langjava.utiljava.io 等基础包。其他平台模块,例如 java.sqljava.net.httpjava.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;
}

可能出现三类失败:

  1. 编译时找不到模块

    module not found: com.example.service
    

    通常是 --module-path 不正确,或者模块名与声明不一致。

  2. 启动时找不到依赖

    java.lang.module.FindException:
    Module com.example.service not found
    

    编译路径和运行路径不是同一个概念,编译成功不代表运行时模块路径正确。

  3. 访问类型时失败

    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 类型可以被其他模块使用;包中的包私有、privateprotected 成员仍然遵循 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 = ...;
    }
}

只有 requiresexports 同时满足,普通编译和运行访问才成立。

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.apprequires,再解析 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 服务:usesprovides ... withServiceLoader

JPMS 服务机制用于把“接口定义方”和“实现提供方”解耦。

一个服务由三部分组成:

  1. 服务接口:定义能力;
  2. 服务使用者:声明 uses 并通过 ServiceLoader 查找;
  3. 服务提供者:声明 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

运行过程可以分解为:

  1. com.example.payment.app 读取服务接口模块;
  2. uses 告知模块系统和 ServiceLoader,该模块查找 PaymentProcessor
  3. com.example.payment.mock 声明自己提供该服务;
  4. ServiceLoader 在当前模块层中寻找提供者;
  5. JVM 加载提供者类并调用其构造器;
  6. 应用只依赖接口,不依赖实现包。

如果提供者模块没有进入当前模块层,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. exportsopens 与服务的组合边界

以一个 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。旧系统通常依赖三类类路径行为:

  1. 任意代码都能读取任意公开类型;
  2. 框架可以反射访问私有成员;
  3. 同一个包可以由多个 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

或者在更严格的运行环境中直接失败。解决方向依次是:

  1. 使用标准 Java SE API;
  2. 使用库公开的扩展点;
  3. 对确有必要的应用包添加精确 opens
  4. 只在明确知道风险时使用 --add-exports--add-opens
  5. 通过测试验证升级 JDK 后仍然成立。

--add-exports--add-opens 都是运行时覆盖机制,不应被当作模块设计的替代品。它们会把部署命令与内部包结构绑定起来,并可能在库升级或模块重命名后失效。


13. 规范保证、实现行为和工程取舍

需要区分三类结论。

规范保证包括:

  • 命名模块通过模块描述符声明依赖和边界;
  • 普通访问需要模块可读性和包导出;
  • opens 控制运行时深层反射;
  • usesprovides 描述服务关系;
  • 模块路径上的命名模块不能存在不合法的分裂包关系;
  • java.base 是隐式依赖。

常见实现行为包括:

  • javacjava 根据模块路径扫描模块;
  • jdeps 根据字节码推断静态依赖;
  • ServiceLoader 在当前 ModuleLayer 中查找服务;
  • JDK 工具输出模块解析过程和模块描述符。

工程建议则取决于应用:

  • 库通常应导出少量稳定 API;
  • 框架需要反射时应优先限定 opens
  • requires transitive 应只用于公共 API 的传递依赖;
  • 自动模块适合迁移过渡,不代表完整封装;
  • --add-opens--add-exports 应作为经过验证的兼容手段,而非默认配置;
  • 服务提供者必须在构建和运行时都验证是否进入目标模块层。

14. 一条可验证的迁移路径

可以按以下顺序推进,但每一步都应结合编译和运行验证:

  1. 盘点依赖:使用 jdepsjar --describe-module 确认静态依赖和模块名。
  2. 先模块化应用入口:为应用增加 module-info.java,保留部分依赖为自动模块。
  3. 逐个处理访问错误:区分缺少 requires、缺少 exports、缺少 opens 和分裂包。
  4. 登记服务关系:把接口模块、使用者模块和提供者模块分别写入 usesprovides
  5. 验证启动模块图:使用 --show-module-resolution 检查编译图和运行图是否一致。
  6. 收紧反射范围:把无条件 open module 逐渐替换为限定 opens
  7. 清理临时参数:评估 --add-reads--add-exports--add-opens 是否可以转为正式模块声明或代码修改。
  8. 验证打包结果:如果使用 jlink 或自定义 ModuleLayer,额外验证服务发现、资源加载和反射框架初始化。
  9. 测试失败路径:不仅测试主流程,还要测试缺失服务提供者、缺少反射开放、缺少可选依赖和重复模块版本。

JPMS 的核心不是“每个 JAR 都有一个模块名”,而是让依赖、访问和运行时扩展点变成可检查的结构。requires 解决模块是否可读,exports 解决普通访问,opens 解决深层反射,服务声明解决接口与实现的发现关系;迁移工作则是把类路径时代依赖隐含行为逐步转化为这些显式关系。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。