模块系统(JPMS)


模块概念(module-info.java)

为什么需要模块系统?—— 从 Classpath Hell 说起

在 Java 9 之前,Java 应用程序的依赖管理完全依赖于 classpath 机制。你把所有的 JAR 包丢进 classpath,JVM 在运行时按照线性搜索的方式去查找类。这套机制看似简单,实则隐患巨大,业界将其形象地称为 "Classpath Hell"(类路径地狱)。

具体来说,classpath 时代存在以下核心痛点:

第一,没有真正的封装边界。 只要一个类被声明为 public,它就对整个应用可见。框架内部的实现细节(比如 sun.misc.Unsafe)本不应被外部访问,但开发者照样能直接引用。这导致框架的内部 API 一旦变动,大量依赖它的第三方库就会崩溃。Public 意味着 "对全世界可见"(accessible to the whole world),而不是 "对我的库的用户可见"。

第二,JAR 之间没有显式依赖关系。 一个 JAR 需要哪些其他 JAR 才能正常工作?classpath 机制完全不关心这件事。缺失依赖只会在运行时抛出 NoClassDefFoundError,而不是在编译或启动阶段就报错。这让大型项目的依赖排查变得极其痛苦。

第三,类冲突与版本冲突无法检测。 当 classpath 上存在两个同名类(split package 问题),JVM 只会加载先找到的那个,默默忽略另一个。如果恰好加载的是错误版本,程序行为将不可预测。

Java 9 引入的 JPMS(Java Platform Module System),项目代号 Jigsaw,正是为了从根本上解决这些问题。JPMS 在语言层面引入了 模块(Module) 这一概念,提供了编译期 + 运行期双重保障的强封装与显式依赖机制。


什么是模块(Module)?

一个模块(Module)是一个命名的、自描述的代码与数据集合。Oracle 官方定义为:

A uniquely named, reusable group of related packages, as well as resources and a module descriptor.

用更直白的话说,模块 = 一组相关的 package + 一个描述文件(module-info.java)。这个描述文件明确声明了两件至关重要的事:

  1. 我依赖谁(requires)—— 显式声明本模块需要哪些其他模块才能工作。
  2. 我暴露谁(exports)—— 显式声明本模块的哪些包可以被外部访问。

没有被 export 的包,即使内部类声明为 public,也无法被其他模块访问。这就是 JPMS 的核心理念——Strong Encapsulation(强封装)。你终于可以区分 "对模块内部公开" 和 "对外部世界公开" 了。


module-info.java —— 模块描述符

每个模块的根目录下都必须有一个特殊文件:module-info.java。它是模块的 "身份证" 和 "契约书",编译后生成 module-info.class,放在模块 JAR 的根路径下。

基本语法

Java
// module-info.java —— 模块描述符
// 文件必须位于模块源码根目录(即与顶层 package 目录同级)
module com.myapp.core {          // 声明模块名,推荐使用反向域名命名
    requires java.sql;           // 本模块依赖 java.sql 模块
    requires java.logging;       // 本模块依赖 java.logging 模块
    exports com.myapp.core.api;  // 将 api 包导出给所有依赖本模块的模块
}

几个关键语法点:

  • module 是上下文关键字(context keyword),不是保留字。也就是说你依然可以在普通代码中用 module 做变量名,但在 module-info.java 中它具有特殊含义。
  • 模块名推荐遵循反向域名约定(reverse domain name convention),如 com.company.project.submodule,这和包名的命名习惯一致,有助于全局唯一性。
  • 一个模块可以包含多个 package,但一个 package 只能属于一个模块。如果两个模块包含相同的 package,编译器和运行时都会报错(split package 禁止)。

文件位置

module-info.java 必须放在模块源码的根目录,即与顶层包目录同级:

Text
my-module/
├── module-info.java          ← 模块描述符(根目录)
└── com/
    └── myapp/
        └── core/
            ├── api/
            │   └── UserService.java
            └── internal/
                └── UserRepository.java

编译后的结构:

Text
my-module.jar
├── module-info.class         ← 编译后的模块描述符
└── com/
    └── myapp/
        └── core/
            ├── api/
            │   └── UserService.class
            └── internal/
                └── UserRepository.class

JDK 本身的模块化

JPMS 不仅是给应用开发者用的——JDK 自身也被拆分成了约 70+ 个模块。这是 Jigsaw 项目最庞大的工程之一。以前那个巨大的 rt.jar(约 60MB)被分解为一系列职责清晰的小模块。

其中最基础的模块是 java.base,它包含了 java.langjava.utiljava.io 等最核心的包。所有模块都隐式依赖 java.base,你不需要(也不应该)在 module-info.java 里显式 requires java.base,编译器会自动添加。

你可以用 java --list-modules 命令查看当前 JDK 的所有模块:

Java
// 在终端执行(不是 Java 代码)
// $ java --list-modules
// 输出类似:
// java.base@21
// java.compiler@21
// java.datatransfer@21
// java.desktop@21
// java.instrument@21
// java.logging@21
// java.management@21
// java.naming@21
// java.net.http@21
// java.sql@21
// ... 以及更多

模块的三大类型

在实际项目中,你会遇到三种不同类型的模块:

1. 命名模块(Named Module / Explicit Module)

拥有 module-info.java 的标准模块。开发者显式声明了模块名、依赖和导出。这是 JPMS 的"正统用法"。

Java
// 标准的命名模块
module com.myapp.service {
    requires com.myapp.core;     // 依赖另一个命名模块
    requires java.sql;           // 依赖 JDK 模块
    exports com.myapp.service;   // 导出服务包
}

2. 自动模块(Automatic Module)

没有 module-info.java 的普通 JAR 放到模块路径(module path)上时,会被自动视为 "自动模块"。这是 JPMS 为兼容旧库提供的过渡机制。

自动模块的行为非常特殊:

  • 模块名:优先从 JAR 的 MANIFEST.MF 中读取 Automatic-Module-Name 属性;如果没有,就从 JAR 文件名推导(去掉 .jar 后缀和版本号,把 - 替换为 .)。例如 guava-31.1-jre.jar → 模块名 guava
  • exports 一切:自动模块会导出它包含的所有 package。
  • requires 一切:自动模块可以读取所有其他模块(包括 unnamed module)。
  • 可被命名模块依赖:命名模块可以 requires 自动模块,这是实现渐进式迁移的关键。
Java
// 假设你的项目依赖 gson-2.10.jar(没有 module-info)
// 只要把它放到 module path,它就变成自动模块
module com.myapp.core {
    requires gson;   // 可以 requires 自动模块(名字来源于 JAR 文件名/Manifest)
}

3. 未命名模块(Unnamed Module)

放在传统 classpath 上的所有 JAR 统统归入一个 "未命名模块"(Unnamed Module)。这是最大的兼容性保障——你的旧项目不做任何修改,照样能在 Java 9+ 上运行。

未命名模块的特性:

  • 没有模块名(null)。
  • 可以读取(read)所有其他模块的导出包。
  • 命名模块不能 requires 未命名模块——这是单向的。命名模块只能依赖命名模块或自动模块。
Text
┌─────────────────────────────────────────────────────────────────┐
│                      Module Path(模块路径)                      │
│  ┌──────────────────┐  ┌──────────────────┐  ┌───────────────┐  │
│  │  Named Module    │  │  Named Module    │  │ Auto Module   │  │
│  │  (有module-info) │  │  (有module-info) │  │ (无module-info│  │
│  │  com.myapp.core  │  │  com.myapp.web   │  │  但在module   │  │
│  │                  │  │                  │  │  path上)      │  │
│  └──────────────────┘  └──────────────────┘  └───────────────┘  │
├─────────────────────────────────────────────────────────────────┤
│                      Classpath(类路径)                          │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │              Unnamed Module(未命名模块)                  │   │
│  │    所有放在 classpath 上的 JAR 统一归入此模块              │   │
│  └──────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

模块路径(Module Path)vs 类路径(Classpath)

Java 9 引入了一个新的路径概念:模块路径(Module Path),与传统的 classpath 并存。

对比维度Classpath (-cp)Module Path (--module-path / -p)
封装性所有 public 类对所有人可见只有 exports 的包中的 public 类可见
依赖声明隐式(运行时才知道缺依赖)显式(requires,编译期即检查)
重复包检测不检测(随机加载一个)编译/运行时报错(split package 禁止)
JAR 的身份都是"未命名模块"的一部分module-info → Named Module;无 → Automatic Module
适用场景遗留项目、快速原型新项目、需要强封装的库/框架

编译和运行命令的变化:

Java
// === 传统 classpath 方式 ===
// 编译
// $ javac -cp lib/gson.jar -d out src/com/myapp/Main.java
 
// 运行
// $ java -cp out:lib/gson.jar com.myapp.Main
 
// === 模块化方式 ===
// 编译(-p 是 --module-path 的简写,-d 指定输出目录)
// $ javac -p mods -d out/com.myapp.core src/com.myapp.core/module-info.java \
//         src/com.myapp.core/com/myapp/core/Main.java
 
// 运行(-m 是 --module 的简写,格式为 模块名/主类全限定名)
// $ java -p mods:out -m com.myapp.core/com.myapp.core.Main

模块解析过程(Module Resolution)

当 JVM 启动一个模块化应用时,它会执行一个叫做 Module Resolution 的过程来构建模块图(Module Graph)。这个过程从根模块开始,递归地解析所有 requires 声明的依赖,直到所有必要的模块都被找到。

如果解析过程中发现缺少依赖模块、存在 split package(同一个包出现在多个模块中)、或检测到循环依赖等问题,JVM 会在启动阶段就报错,而不是等到运行中才抛出 NoClassDefFoundError。这就是 Reliable Configuration(可靠配置) 的含义——尽早失败,尽早发现问题。


模块化项目的目录结构实践

一个典型的多模块项目结构如下:

Text
my-project/
├── com.myapp.core/                    ← 核心模块
│   ├── module-info.java
│   └── com/myapp/core/
│       ├── api/
│       │   ├── UserService.java       ← 对外暴露的 API
│       │   └── OrderService.java
│       └── internal/
│           ├── UserRepository.java    ← 内部实现,不导出
│           └── CacheManager.java

├── com.myapp.web/                     ← Web 模块
│   ├── module-info.java
│   └── com/myapp/web/
│       └── controller/
│           └── UserController.java

└── com.myapp.persistence/             ← 持久层模块
    ├── module-info.java
    └── com/myapp/persistence/
        └── JpaUserRepository.java

对应的 module-info.java

Java
// com.myapp.core 的 module-info.java
module com.myapp.core {
    // 只导出 api 包,internal 包对外不可见
    exports com.myapp.core.api;
    // 不需要显式 requires java.base(自动隐含)
}
Java
// com.myapp.web 的 module-info.java
module com.myapp.web {
    requires com.myapp.core;        // 依赖核心模块
    requires java.net.http;         // 依赖 JDK 的 HTTP 模块
    exports com.myapp.web.controller;
}
Java
// com.myapp.persistence 的 module-info.java
module com.myapp.persistence {
    requires com.myapp.core;        // 依赖核心模块
    requires java.sql;              // 依赖 JDBC
    exports com.myapp.persistence;
}

此时,com.myapp.web 模块中的代码可以使用 com.myapp.core.api.UserService,但无法访问 com.myapp.core.internal.UserRepository——即使 UserRepositorypublic 的。尝试访问会在编译期就报错:

Java
// 在 com.myapp.web 模块中尝试访问未导出的包
// 编译报错:package com.myapp.core.internal is not visible
//   (package com.myapp.core.internal is declared in module com.myapp.core,
//    which does not export it)
import com.myapp.core.internal.UserRepository; // ← 编译失败!

这就是模块系统的强封装能力——真正实现了 "public 不再意味着全局可见"(public is no longer universally accessible)


module-info.java 完整指令速览

虽然后续章节会深入讲解每个指令,这里先给出一个全景索引,帮你建立整体认知:

Java
// module-info.java 支持的所有指令一览
module com.myapp.example {
 
    // ① requires —— 声明依赖
    requires java.sql;                        // 普通依赖
    requires transitive java.logging;         // 传递依赖(依赖本模块的人也自动获得 java.logging)
    requires static com.optional.lib;         // 编译期依赖,运行时可选(Optional Dependency)
 
    // ② exports —— 导出包
    exports com.myapp.example.api;            // 对所有人导出
    exports com.myapp.example.spi to          // 限定导出(Qualified Export),只对指定模块可见
        com.myapp.plugin.a,
        com.myapp.plugin.b;
 
    // ③ opens —— 反射开放
    opens com.myapp.example.model;            // 对所有人开放反射访问
    opens com.myapp.example.entity to         // 限定开放,只对指定模块(如框架)开放反射
        org.hibernate.core,
        com.google.gson;
 
    // ④ uses —— 声明使用的服务接口(SPI 消费者)
    uses com.myapp.example.spi.Plugin;
 
    // ⑤ provides —— 提供服务实现(SPI 提供者)
    provides com.myapp.example.spi.Plugin
        with com.myapp.example.internal.DefaultPlugin;
}

小结:模块带来的核心价值

模块系统不仅仅是一个新的语法特性,它从根本上改变了 Java 应用程序的架构能力。JPMS 的核心价值可以归纳为三点:

Strong Encapsulation(强封装)——模块内部的实现细节不再泄露到外部,public 不再等于 "全世界可见"。API 的边界终于可以在语言层面被严格执行。

Reliable Configuration(可靠配置)——所有的模块依赖在启动时就被完整验证。缺依赖、重复包、循环依赖,全部在启动阶段暴露,而非在生产环境的某个深夜突然崩溃。

Scalable Platform(可伸缩平台)——JDK 本身的模块化意味着你可以用 jlink 工具创建自定义运行时镜像(Custom Runtime Image),只包含你的应用实际需要的模块。一个微服务的运行时可以从完整 JDK 的 300MB+ 缩减到 30-40MB,这对容器化部署(Docker)有巨大意义。


📝 练习题

以下关于 Java 模块系统(JPMS)的说法,哪一项是错误的

A. 每个模块的 module-info.java 必须放在模块源码的根目录下

B. 所有模块都隐式依赖 java.base,无需在 module-info.java 中显式声明 requires java.base

C. 放在 classpath 上的 JAR 如果没有 module-info.java,会被视为自动模块(Automatic Module)

D. 命名模块中,即使一个类声明为 public,如果它所在的包没有被 exports,其他模块也无法访问它

【答案】 C

【解析】 选项 C 的错误在于混淆了自动模块未命名模块的概念。放在 classpath 上的 JAR(无论有没有 module-info.java)都会被归入未命名模块(Unnamed Module),而不是自动模块。自动模块(Automatic Module) 是指没有 module-info.java 但被放到了 模块路径(Module Path,即 --module-path / -p 上的 JAR。这两个概念的关键区分点在于 JAR 被放置的路径(classpath vs module path),而非是否包含 module-info.java。选项 A、B、D 的描述均正确。


exports / requires —— 模块间的"可见性契约"

在传统的 classpath 时代,只要一个类被声明为 public,它就对整个应用中的所有代码可见——这被称为 "大泥球"(Big Ball of Mud) 问题。Java 9 引入的模块系统(JPMS)通过 exportsrequires 两个关键字,在 public 之上新增了一层 模块级的访问控制(module-level access control),让开发者可以精确声明"我对外暴露什么"以及"我需要依赖什么"。

理解这两个关键字,是掌握整个模块系统的核心。可以这样类比:exports 是模块的"出口清关单",决定哪些包裹(package)允许出关;requires 是模块的"进口采购单",声明需要从哪些供应商(module)进货。两者配合,构成了模块之间的 双向契约(bilateral contract)


exports —— 控制包的对外可见性

基本语法与语义

module-info.java 中,exports 指令用于将模块内部的某个包(package)暴露给外部模块。语法非常简洁:

Java
// 文件: module-info.java
module com.myapp.core {
    // 将 com.myapp.core.api 包导出,使其他模块可以访问该包下的 public 类型
    exports com.myapp.core.api;
}

这里有一个至关重要的认知转变:在 JPMS 中,public 不再等于"全局可见"。一个类即使是 public 的,如果它所在的包没有被 exports,那么其他模块依然无法访问它。换句话说,JPMS 在原有的四级访问控制(private、package-private、protectedpublic)之上,实质上创造了一种新的可见性语义——"模块内公开但模块外不可见"(public but not accessible)

这就好比你家客厅里的东西对家人(同模块)是可见的,但如果你没有打开大门(exports),邻居(其他模块)是看不到也拿不到的。

exports 的作用粒度

exports 的粒度是 包(package)级别,而不是类级别。你不能只导出某个包中的某一个类,要么整个包导出,要么整个包不导出。这是一个深思熟虑的设计决策:包本身就是 Java 中天然的"逻辑分组单元",以包为粒度可以避免过于碎片化的配置,同时鼓励开发者在设计模块时就做好包的职责划分。

一个模块可以导出多个包:

Java
module com.myapp.core {
    // 导出公共 API 包 —— 面向所有消费者的接口定义
    exports com.myapp.core.api;
    // 导出数据传输对象包 —— DTO 通常也需要跨模块传递
    exports com.myapp.core.dto;
    // 注意: com.myapp.core.internal 包没有被导出
    // 即使其中有 public 类, 外部模块也无法访问
}

未被导出的包(如上面的 com.myapp.core.internal)属于模块的 内部实现细节(implementation detail),对外完全封装。这种"默认封闭"的哲学与之前"默认开放"的 classpath 模型截然相反,极大地改善了大型项目中的 封装性(encapsulation)

限定导出(Qualified Exports)

有时候,你希望某个包只对特定的"可信"模块开放,而不是对所有模块开放。这就是 限定导出(qualified exports) 的用途,使用 exports ... to ... 语法:

Java
module com.myapp.core {
    // 公开导出: 所有模块都能访问
    exports com.myapp.core.api;
 
    // 限定导出: 只有 com.myapp.web 和 com.myapp.admin 能访问此包
    // 其他模块即使 requires 了 com.myapp.core, 也看不到这个包
    exports com.myapp.core.spi to
        com.myapp.web,
        com.myapp.admin;
}

限定导出在框架开发中非常常见。例如,Spring Framework 的内部模块可能会将某些底层工具包只暴露给自己的兄弟模块,而不暴露给最终用户。JDK 自身也大量使用了限定导出——许多 jdk.internal.* 包只对 JDK 内部的特定模块开放。

下面用一张 Mermaid 图来可视化 exports 的两种模式:

从图中可以清晰地看到三种包的可见性差异:公开导出的包所有人都能访问,限定导出的包只有指定模块能访问,未导出的包则完全封闭。


requires —— 声明模块依赖

基本语法与语义

如果说 exports 是"供给侧"的声明,那么 requires 就是"需求侧"的声明。一个模块要使用另一个模块导出的包,必须在自己的 module-info.java 中显式声明依赖:

Java
module com.myapp.web {
    // 声明对 com.myapp.core 模块的依赖
    // 这样才能访问 com.myapp.core 通过 exports 导出的包
    requires com.myapp.core;
}

这种显式依赖声明带来了一个革命性的好处:依赖关系在编译期就会被严格检查。如果你 requires 了一个不存在的模块,或者漏写了某个 requires,编译器会直接报错,而不是像 classpath 时代那样等到运行时才抛出 ClassNotFoundException。这将大量"运行时炸弹"提前到了编译期,极大地提升了项目的可靠性。

值得注意的是,每个模块都隐式地依赖 java.base 模块(它包含 java.langjava.utiljava.io 等最基础的包),因此你不需要也不应该显式写 requires java.base;

requires transitive —— 传递性依赖

考虑这样一个场景:模块 A 的公开 API 中使用了模块 B 的类型。例如,com.myapp.core 的某个导出接口的方法签名中返回了 com.myapp.model 模块中定义的 User 类。此时,任何使用 com.myapp.core 的模块也必须能看到 com.myapp.model 中的 User 类型,否则连编译都过不了。

如果让每个消费者模块自己手动加上 requires com.myapp.model,会非常繁琐且容易遗漏。requires transitive 正是为了解决这个问题:

Java
module com.myapp.core {
    // 传递性依赖: 任何 requires com.myapp.core 的模块
    // 将自动获得对 com.myapp.model 的读取权限
    requires transitive com.myapp.model;
 
    // 普通依赖: 仅 com.myapp.core 自己能访问, 不会传递给消费者
    requires com.myapp.utils;
 
    // 导出的 API 中引用了 com.myapp.model 中的类型
    exports com.myapp.core.api;
}
Java
module com.myapp.web {
    // 只需要声明对 core 的依赖
    // 由于 core 使用了 requires transitive com.myapp.model
    // web 模块自动获得对 model 的访问权
    requires com.myapp.core;
 
    // 不需要写: requires com.myapp.model;  (已被传递)
    // 但仍然看不到: com.myapp.utils         (非传递依赖)
}

何时使用 requires transitive 的经验法则:如果模块 A 的 公开 API(即导出包中的 public 类型签名——方法参数、返回值、继承关系)中出现了来自模块 B 的类型,那么 A 就应该用 requires transitive B。如果模块 B 只在 A 的内部实现中使用而不出现在公开 API 中,则使用普通的 requires B 即可。

下面用图来展示传递性依赖的"可见性传播"效果:

requires static —— 编译时依赖(可选依赖)

还有一种特殊的依赖形式:requires static。它表示"这个依赖在 编译时必须存在,但在 运行时是可选的"。这非常适合用于注解处理器、编译期代码生成工具,或者那些"有就用、没有也不影响正常运行"的增强型库。

Java
module com.myapp.core {
    // 编译时需要 lombok 的注解, 但运行时不需要 lombok 存在
    requires static lombok;
 
    // 编译时需要 checker framework 的注解做空安全检查
    // 运行时不需要
    requires static org.checkerframework.checker.qual;
}

典型的使用场景包括:SLF4J 的可选 binding、Lombok 的编译期注解、JSR-305 的 @Nullable / @NonNull 注解等。在这些场景中,相关库只在编译阶段发挥作用,打包后的运行时环境中并不需要它们。


exports 与 requires 的协作机制

两个关键字并不是孤立工作的,它们共同定义了模块间的 可读性(readability)可访问性(accessibility) 两层关卡:

第一层关卡——可读性(Readability):模块 A 是否 requires 了模块 B?如果是,A 就获得了对 B 的"读取权限"(can read)。这相当于拿到了进入 B 大楼的门禁卡。

第二层关卡——可访问性(Accessibility):模块 B 是否 exports 了某个包?如果是,A 中的代码才能实际使用该包中的 public 类型。这相当于大楼里的某些楼层是否对你开放。

两层关卡必须同时满足,访问才能成功:

Text
访问成功的条件:
  ① 消费者模块 requires 了提供者模块          (可读性 ✓)
  ② 提供者模块 exports 了目标包               (可访问性 ✓)
  ③ 目标类型本身是 public 的                   (Java 原有的访问控制 ✓)

如果任何一个条件不满足,编译器都会报错。我们来看一个完整的多模块协作示例,把所有知识点串联起来:

Java
// ===== 模块 1: com.myapp.model =====
// 定义核心数据模型, 被其他多个模块依赖
module com.myapp.model {
    // 导出实体类包 —— 所有模块都可以使用这些模型类
    exports com.myapp.model.entity;
    // 导出枚举/常量包
    exports com.myapp.model.enums;
    // com.myapp.model.internal 没有导出 —— 纯内部使用
}
Java
// ===== 模块 2: com.myapp.core =====
// 核心业务逻辑层, API 中大量使用 model 中的类型
module com.myapp.core {
    // 传递性依赖: 因为 core 的公开 API 的方法签名中
    // 包含 model 模块中的 User, Order 等类型
    // 消费者通过 core 模块间接获得对 model 的读取权限
    requires transitive com.myapp.model;
 
    // 普通依赖: 日志框架仅在 core 内部使用
    // 不暴露给 core 的消费者
    requires org.slf4j;
 
    // 可选依赖: Micrometer 监控, 有则启用指标收集, 无则跳过
    requires static io.micrometer.core;
 
    // 导出业务服务接口包
    exports com.myapp.core.service;
    // 导出异常定义包
    exports com.myapp.core.exception;
}
Java
// ===== 模块 3: com.myapp.web =====
// Web 层, 面向最终用户的 HTTP 接口
module com.myapp.web {
    // 依赖 core 模块
    // 由于 core 使用了 requires transitive com.myapp.model
    // web 自动获得对 model 的读取权限, 可以直接使用 User, Order 等类型
    requires com.myapp.core;
 
    // Web 层自身的依赖
    requires java.net.http;
 
    // web 模块一般不导出任何包 —— 它是依赖图的"叶子节点"
    // 没有其他模块需要依赖它
}

这种分层结构形成了清晰的依赖方向:web → core → model。每个箭头都是一个显式的 requires 声明,每个模块只暴露必要的包,内部实现完全隐藏。


常见编译错误与排查

在实际使用 exports / requires 时,有几种典型的编译错误值得了解:

错误 1:Package ... is not visible(包不可见)

当你尝试使用另一个模块中未被 exports 的包中的类时,就会遇到这个错误。解决方式:检查目标模块是否遗漏了对应的 exports 声明。

错误 2:Module ... does not read module ...(模块不可读)

当你的模块缺少对应的 requires 声明时触发。解决方式:在你的 module-info.java 中添加缺失的 requires

错误 3:Cyclic dependency(循环依赖)

JPMS 严格禁止模块间的循环依赖。如果模块 A requires B,同时 B 又 requires A,编译器会直接拒绝。这是与传统 classpath 的一个重大区别——在 classpath 时代,循环依赖虽然是坏实践但技术上是允许的。JPMS 的这一限制迫使开发者在设计阶段就理清依赖方向,形成 有向无环图(DAG, Directed Acyclic Graph),这对大型项目的架构健康非常有益。

解决循环依赖的常见手段是 提取公共模块:将两个模块共同依赖的接口或类型抽取到一个新的、独立的模块中,让原来的两个模块都单向依赖这个新模块。


与 Maven/Gradle 依赖管理的关系

一个常见的疑惑是:既然 Maven 的 pom.xml 和 Gradle 的 build.gradle 已经管理了依赖,为什么还需要 module-info.java 中的 requires

答案是它们 工作在不同的层面。Maven/Gradle 管理的是 构建时的 artifact 依赖(JAR 包从哪里下载、版本是什么),而 requires 管理的是 编译时和运行时的模块可读性(哪个模块能看到哪个模块的代码)。两者并不冲突,反而是互补的:Maven 保证 JAR 包在 classpath/modulepath 上可用,requires 保证模块间的访问权限正确配置。

在实际项目中,如果你使用了 JPMS,通常需要在两个地方都做正确的声明:在构建工具中声明 artifact 依赖,在 module-info.java 中声明模块依赖。


📝 练习题

以下是模块 com.app.service 的声明:

Java
module com.app.service {
    requires transitive com.app.model;
    requires com.app.utils;
    exports com.app.service.api;
}

模块 com.app.web 的声明如下:

Java
module com.app.web {
    requires com.app.service;
}

请问,在 com.app.web 模块中,以下哪些模块的导出包是可以直接使用的?

A. com.app.servicecom.app.model

B. com.app.servicecom.app.modelcom.app.utils

C. 仅 com.app.service

D. 仅 com.app.model

【答案】 A

【解析】 com.app.web 显式声明了 requires com.app.service,因此可以访问 com.app.service 导出的包(即 com.app.service.api)。同时,由于 com.app.service 使用了 requires transitive com.app.model,这意味着任何依赖 com.app.service 的模块都会自动获得对 com.app.model 的读取权限,所以 com.app.web 也能访问 com.app.model 的导出包。而 com.app.utilscom.app.service 中只是普通的 requires(没有 transitive),其可读性不会传递给 com.app.web,因此 com.app.web 无法访问 com.app.utils 的内容。正确答案是 A。


服务机制(provides/uses、ServiceLoader)

Java 平台模块系统(JPMS)中的服务机制是实现面向接口编程松耦合架构的核心手段。它将传统的 Service Provider Interface(SPI) 模式从"约定俗成的 classpath 发现"提升为"编译期可检查、模块化声明式绑定"的全新高度。在 JPMS 之前,Java 的 SPI 依赖 META-INF/services/ 目录下的纯文本配置文件来注册实现类,这种方式既不安全(没有编译期检查)也不透明(运行时才知道哪些实现存在)。JPMS 通过 provides ... withuses 关键字,把服务的提供消费关系直接写入 module-info.java,使其成为模块契约的一部分。

理解服务机制的关键在于把握三个角色:服务接口(Service Interface)服务提供者(Service Provider)服务消费者(Service Consumer)。整个流程可以用一句话概括——消费者声明"我需要某个接口的实现"(uses),提供者声明"我为该接口提供了一个具体实现"(provides ... with),而 ServiceLoader 负责在运行时将两者桥接起来。


核心概念:三角色模型

在正式看语法之前,先建立整体认知。一个完整的 JPMS 服务交互至少涉及三个模块(也可以合并,但逻辑角色不变):

这个三角结构的精妙之处在于:Consumer 模块在编译时完全不知道 Provider 模块的存在。它只依赖 API 模块中的接口。Provider 可以在不修改任何消费端代码的情况下被替换或新增——这就是真正的插件化架构(Plugin Architecture)


uses 关键字:声明服务消费

uses 出现在消费者模块module-info.java 中,表示"本模块需要加载某个服务接口的实现"。

Java
// 文件: consumer.module/module-info.java
module consumer.module {
    // 声明本模块依赖 API 模块(因为要用到 MessageService 接口)
    requires api.module;
 
    // 关键: 声明本模块将通过 ServiceLoader 消费 MessageService 的实现
    // uses 后面跟的是接口(或抽象类)的全限定名
    uses com.example.api.MessageService;
}

uses 声明有几个关键约束和含义:

第一,uses 后面的类型必须是当前模块能访问到的。通常这意味着 API 模块已经通过 exports 将接口包暴露出来,且消费者模块通过 requires 依赖了 API 模块。如果你 uses 了一个无法访问的类型,编译器会直接报错。

第二,uses 声明并不会导致模块对任何 Provider 产生编译期依赖。它只是告诉模块系统:"在运行时,请帮我找到这个接口的所有可用实现"。这是一个纯运行时的发现机制。

第三,一个模块可以有多个 uses 声明,用来消费不同的服务接口。


provides ... with 关键字:声明服务提供

provides ... with 出现在提供者模块module-info.java 中,表示"本模块为某个服务接口提供了具体的实现类"。

Java
// 文件: provider.email/module-info.java
module provider.email {
    // 依赖 API 模块以获取 MessageService 接口
    requires api.module;
 
    // 关键: 声明本模块为 MessageService 接口提供 EmailService 实现
    // provides <接口> with <实现类1>, <实现类2>, ...;
    provides com.example.api.MessageService
        with com.example.provider.EmailService;
}

provides ... with 的规则更为严格:

第一,with 后面的实现类必须满足以下条件之一:拥有 public 无参构造器,或拥有 public static 的 provider() 工厂方法(Java 9+ 新增,返回类型必须是服务接口或其子类型)。如果两者都有,provider() 工厂方法优先。

第二,实现类不需要被 exports。这是一个非常重要的特性!Provider 模块可以将实现类完全封装在内部包中,只通过 provides 声明暴露"我能提供这个服务"的事实,而不暴露具体实现细节。这是比传统 SPI 更强的封装能力。

第三,一个 provides 声明可以用逗号列出多个实现类,也可以用多条 provides 语句为不同接口提供实现。

Java
// 一个模块可以为同一接口提供多个实现
module provider.multi {
    requires api.module;
 
    // 为同一个接口提供两个不同实现
    provides com.example.api.MessageService
        with com.example.provider.EmailService,
             com.example.provider.SmsService;
 
    // 也可以为另一个接口提供实现
    provides com.example.api.LogService
        with com.example.provider.FileLogService;
}

provider() 静态工厂方法

Java 9 引入了一个灵活的替代方案:如果实现类不方便直接实例化(例如需要配置参数,或者你想返回一个内部类/子类实例),可以定义一个 public static provider() 方法。

Java
// 文件: com/example/provider/EmailService.java
package com.example.provider;
 
import com.example.api.MessageService;
 
public class EmailService implements MessageService {
 
    // 私有构造器 — 不提供 public 无参构造
    private final String smtpHost;
 
    private EmailService(String smtpHost) {
        this.smtpHost = smtpHost;
    }
 
    // ServiceLoader 优先调用此工厂方法
    // 返回类型必须是 MessageService 或其子类型
    public static MessageService provider() {
        // 可以在这里做复杂的初始化逻辑
        String host = System.getProperty("smtp.host", "localhost");
        return new EmailService(host);
    }
 
    @Override
    public void send(String message) {
        System.out.println("Sending via " + smtpHost + ": " + message);
    }
}

provider() 工厂方法的查找优先级如下:


ServiceLoader:运行时服务发现引擎

java.util.ServiceLoader 是整个服务机制的运行时核心。它负责扫描模块路径(Module Path),找到所有通过 provides 声明的实现,并将它们实例化后交给消费者使用。

基本用法

Java
package com.example.consumer;
 
import com.example.api.MessageService;
import java.util.ServiceLoader;
 
public class Application {
    public static void main(String[] args) {
        // 创建 ServiceLoader 实例,泛型参数为服务接口类型
        // ServiceLoader.load() 会查找模块路径上所有 provides MessageService 的模块
        ServiceLoader<MessageService> loader = ServiceLoader.load(MessageService.class);
 
        // 方式一: 遍历所有可用的服务实现
        for (MessageService service : loader) {
            // 每次迭代都会获得一个不同的实现实例
            service.send("Hello from ServiceLoader!");
        }
 
        // 方式二: 使用 Stream API(Java 9+),获取更多控制权
        loader.stream()
              // stream() 返回 Stream<ServiceLoader.Provider<MessageService>>
              // Provider 是一个延迟加载包装器,调用 get() 才会真正实例化
              .map(ServiceLoader.Provider::get)
              // 过滤出特定类型的实现
              .filter(s -> s.getClass().getSimpleName().equals("EmailService"))
              .forEach(s -> s.send("Filtered message"));
 
        // 方式三: 只取第一个可用实现(常用于"有且仅有一个实现"的场景)
        MessageService primary = loader.findFirst()
            .orElseThrow(() -> new RuntimeException("No MessageService implementation found!"));
        primary.send("Primary service message");
    }
}

ServiceLoader 的生命周期与缓存

ServiceLoader 不是每次迭代都重新扫描和实例化的。它内部有一个缓存机制:首次遍历时实例化的服务对象会被缓存起来,后续遍历直接返回缓存对象。如果需要重新发现(比如在运行时动态增加了新的 Provider 模块),可以调用 reload() 方法清空缓存。

Java
// 创建 loader — 此时尚未实例化任何服务
ServiceLoader<MessageService> loader = ServiceLoader.load(MessageService.class);
 
// 第一次遍历: 触发发现 + 实例化 + 缓存
for (MessageService s : loader) { /* ... */ }
 
// 第二次遍历: 直接读取缓存,不会重新实例化
for (MessageService s : loader) { /* ... */ }
 
// 清空缓存,下次遍历将重新发现和实例化
loader.reload();

这种 Lazy Instantiation(延迟实例化) 策略在 stream() API 中体现得更明显。ServiceLoader.Provider 包装器允许你在不实例化对象的前提下检查实现类的类型信息:

Java
loader.stream()
      // type() 返回实现类的 Class 对象,此时并未创建实例
      .filter(provider -> provider.type().isAnnotationPresent(Priority.class))
      // 仅对符合条件的才调用 get() 实例化
      .map(ServiceLoader.Provider::get)
      .forEach(s -> s.send("Lazy and filtered!"));

完整示例:搭建三模块服务架构

下面用一个完整示例把所有知识串联起来。我们构建一个消息推送系统,包含三个模块。

项目结构

Text
messaging-system/
├── api.module/
│   ├── module-info.java
│   └── com/example/api/
│       └── MessageService.java
├── provider.email/
│   ├── module-info.java
│   └── com/example/provider/email/
│       └── EmailService.java
├── provider.sms/
│   ├── module-info.java
│   └── com/example/provider/sms/
│       └── SmsService.java
└── consumer.app/
    ├── module-info.java
    └── com/example/app/
        └── Application.java

API 模块:定义服务接口

Java
// api.module/module-info.java
module api.module {
    // 将服务接口所在的包导出给所有人
    exports com.example.api;
}
Java
// api.module/com/example/api/MessageService.java
package com.example.api;
 
/**
 * 消息服务接口 — 所有消息推送实现必须遵循此契约
 * 这是 SPI 模式中的 "Service Interface"
 */
public interface MessageService {
    // 发送消息的核心方法
    void send(String recipient, String message);
 
    // 默认方法: 返回服务类型标识(Java 8+)
    default String getType() {
        return "UNKNOWN";
    }
}

Provider 模块一:Email 实现

Java
// provider.email/module-info.java
module provider.email {
    // 依赖 API 模块
    requires api.module;
 
    // 提供 Email 实现 — 注意 EmailService 所在包没有 exports!
    // 实现类被完全封装,外部无法直接 new EmailService()
    provides com.example.api.MessageService
        with com.example.provider.email.EmailService;
}
Java
// provider.email/com/example/provider/email/EmailService.java
package com.example.provider.email;
 
import com.example.api.MessageService;
 
// 这个类不在任何 exports 包中 — 模块外部无法直接访问
// 只能通过 ServiceLoader 间接获取
public class EmailService implements MessageService {
 
    // public 无参构造器 — ServiceLoader 需要此构造器来实例化
    public EmailService() {
        System.out.println("[EmailService] Initialized");
    }
 
    @Override
    public void send(String recipient, String message) {
        // 模拟发送邮件
        System.out.printf("[EMAIL] To: %s | Content: %s%n", recipient, message);
    }
 
    @Override
    public String getType() {
        return "EMAIL";
    }
}

Provider 模块二:SMS 实现(使用 provider() 工厂)

Java
// provider.sms/module-info.java
module provider.sms {
    requires api.module;
 
    provides com.example.api.MessageService
        with com.example.provider.sms.SmsService;
}
Java
// provider.sms/com/example/provider/sms/SmsService.java
package com.example.provider.sms;
 
import com.example.api.MessageService;
 
public class SmsService implements MessageService {
 
    // 短信服务的区号配置
    private final String countryCode;
 
    // 私有构造器 — 不允许外部直接 new
    private SmsService(String countryCode) {
        this.countryCode = countryCode;
        System.out.println("[SmsService] Initialized with code: " + countryCode);
    }
 
    // ServiceLoader 优先调用此静态工厂方法
    // 方法签名必须是: public static <服务接口类型> provider()
    public static MessageService provider() {
        // 从系统属性读取配置,提供默认值
        String code = System.getProperty("sms.country.code", "+86");
        return new SmsService(code);
    }
 
    @Override
    public void send(String recipient, String message) {
        // 模拟发送短信
        System.out.printf("[SMS] To: %s%s | Content: %s%n", countryCode, recipient, message);
    }
 
    @Override
    public String getType() {
        return "SMS";
    }
}

Consumer 模块:使用 ServiceLoader

Java
// consumer.app/module-info.java
module consumer.app {
    // 依赖 API 模块以访问 MessageService 接口
    requires api.module;
 
    // 声明消费 MessageService — 不需要 requires 任何 provider 模块!
    uses com.example.api.MessageService;
}
Java
// consumer.app/com/example/app/Application.java
package com.example.app;
 
import com.example.api.MessageService;
import java.util.ServiceLoader;
import java.util.List;
import java.util.stream.Collectors;
 
public class Application {
    public static void main(String[] args) {
        // 加载所有 MessageService 的实现
        ServiceLoader<MessageService> loader = ServiceLoader.load(MessageService.class);
 
        // 将所有发现的服务收集到 List 中
        List<MessageService> services = loader.stream()
                .map(ServiceLoader.Provider::get) // 延迟实例化 -> 立即实例化
                .collect(Collectors.toList());
 
        // 打印发现了多少个服务实现
        System.out.println("Discovered " + services.size() + " service(s):");
 
        // 使用每一个发现的服务发送消息
        for (MessageService service : services) {
            System.out.println("  - Type: " + service.getType());
            service.send("user@example.com", "Hello from JPMS Service Mechanism!");
        }
 
        // 如果没有发现任何实现,给出友好提示
        if (services.isEmpty()) {
            System.err.println("WARNING: No MessageService providers found on module path!");
        }
    }
}

编译与运行

Text
# 编译全部模块(--module-source-path 指向模块源码根目录)
javac -d out --module-source-path . $(find . -name "*.java")
 
# 运行消费者模块(--module-path 包含所有编译后的模块)
# 只要 provider.email 和 provider.sms 在模块路径上,ServiceLoader 就能发现它们
java --module-path out -m consumer.app/com.example.app.Application

输出示例:

Text
[EmailService] Initialized
[SmsService] Initialized with code: +86
Discovered 2 service(s):
  - Type: EMAIL
[EMAIL] To: user@example.com | Content: Hello from JPMS Service Mechanism!
  - Type: SMS
[SMS] To: +86user@example.com | Content: Hello from JPMS Service Mechanism!

传统 SPI 与 JPMS 服务机制对比

理解新旧两种方式的差异,有助于在实际项目中做出正确的架构选择。

以下是关键差异的详细说明:

编译期安全性(Compile-time Safety):传统 SPI 的 META-INF/services/ 文件只是纯文本,如果你拼错了类名,编译器完全不会报错,只有在运行时才会抛出 ServiceConfigurationError。而 JPMS 的 provides ... with 语句在编译期就会检查——实现类是否存在?是否实现了指定接口?是否有合法的构造器或 provider() 方法?任何问题都会在编译阶段暴露。

封装能力(Encapsulation):传统 SPI 要求实现类必须是 public 且在公开的包中,因为 ServiceLoader 需要通过反射调用其构造器。JPMS 下实现类可以位于未被 exports 的内部包中——模块系统会为 ServiceLoader 特别开放这些类的访问权限,但其他模块仍然无法直接引用它们。

可发现性(Discoverability):查看一个模块提供了哪些服务、消费了哪些服务,只需阅读 module-info.java。而传统方式则需要翻遍 META-INF/services/ 目录、甚至解压多个 JAR 才能拼出完整图景。

向后兼容性(Backward Compatibility):如果你的项目尚未迁移到模块系统(即仍然使用 classpath),传统 SPI 仍然完全有效。实际上,ServiceLoader 在非模块化环境下会自动回退到 META-INF/services/ 发现机制。这意味着同一个 JAR 可以同时包含 module-info.java(为模块化项目服务)和 META-INF/services/ 文件(为传统项目服务),实现无缝兼容。


进阶:ServiceLoader 在主流框架中的应用

JPMS 服务机制并非仅存在于教科书中。很多 Java 生态的核心组件都在使用它:

JDBC 驱动发现:自 Java 9 起,java.sql 模块声明了 uses java.sql.Driver。JDBC 驱动(如 MySQL Connector/J、PostgreSQL JDBC)通过 provides java.sql.Driver with ... 注册自己。DriverManager 内部使用 ServiceLoader 自动发现驱动,不再需要 Class.forName("com.mysql.cj.jdbc.Driver") 这种手动加载方式。

SLF4J 日志框架:SLF4J 2.x 使用 ServiceLoader 发现日志后端(如 Logback、Log4j2),取代了早期基于 classpath 静态绑定的方式。

自定义插件系统:在微服务或平台型应用中,你可以利用 JPMS 服务机制构建插件体系——核心模块定义扩展点接口,各插件模块实现并 provides,核心模块通过 uses + ServiceLoader 动态发现和加载所有插件。


常见陷阱与排错指南

陷阱一:忘记声明 uses。如果消费者模块没有在 module-info.java 中声明 usesServiceLoader.load() 在模块化环境下将返回空结果(不会抛异常),这可能让你困惑为什么明明 Provider 在模块路径上却什么都找不到。

陷阱二:实现类没有合法的实例化方式。如果实现类既没有 public 无参构造器,也没有 public static provider() 方法,编译 module-info.java 时会报错。但如果你是在非模块化项目中,这个错误会推迟到运行时。

陷阱三:混淆模块路径和类路径ServiceLoader 在模块路径上使用 module-info.java 中的 provides/uses 声明进行发现,在类路径上则回退到 META-INF/services/。如果你把 Provider JAR 放在了类路径而不是模块路径上,JPMS 的声明不会生效。

陷阱四:provides 的接口类型不匹配provides A with B 中,B 必须是 A 的实现类(或子类)。如果 B 实现的是 A 的某个子接口而非 A 本身,声明 uses A 的消费者仍然可以发现 B——只要 B 最终确实 implements A


📝 练习题

以下 module-info.java 存在编译错误,请找出原因:

Java
module my.provider {
    requires api.module;
 
    provides com.example.api.MessageService
        with com.example.internal.MyImpl;
}
Java
// com/example/internal/MyImpl.java
package com.example.internal;
 
import com.example.api.MessageService;
 
class MyImpl implements MessageService {
    public void send(String recipient, String msg) { }
}

A. MyImpl 所在包未被 exports,无法作为服务提供者

B. MyImpl 的类访问修饰符不是 public,ServiceLoader 无法实例化

C. module-info.java 中缺少 uses 声明

D. provides 语句语法错误,应写为 provides ... using

【答案】 B

【解析】 provides ... with 后面指定的实现类必须是 public(即使它所在的包不需要被 exports)。题目中 MyImpl 使用了包级私有访问修饰符(缺少 public 关键字),这会导致编译错误。选项 A 是常见误区——JPMS 服务机制的一大优势正是实现类的包不需要被 exports,模块系统会特别允许 ServiceLoader 访问未导出包中的 public 类。选项 C 错误是因为 uses 只需要在消费者模块中声明,而非提供者模块。选项 D 的语法是虚构的,正确关键字就是 with


📝 练习题

关于 ServiceLoaderstream() 方法,以下说法正确的是:

A. stream() 返回的每个元素都是已经实例化好的服务对象

B. stream() 返回 Stream<ServiceLoader.Provider<S>>,调用 Provider.get() 时才触发实例化

C. stream() 只能在 JPMS 模块化项目中使用,传统 classpath 项目不支持

D. stream() 方法每次调用都会清空 ServiceLoader 的内部缓存

【答案】 B

【解析】 ServiceLoader.stream() 是 Java 9 引入的 API,它返回的是 Stream<ServiceLoader.Provider<S>> 类型。每个 Provider 对象是一个延迟加载包装器(Lazy Wrapper),它持有实现类的 Class 信息(可通过 type() 方法获取),但不会立即创建实例。只有在显式调用 Provider.get() 时才真正触发实例化。这使得消费者可以在不付出实例化成本的前提下,先通过 type() 检查类信息(如注解、类名等)来筛选需要的实现。选项 A 错误,因为这描述的是 iterator() 遍历的行为(且即使 iterator() 也是惰性的,只是没有显式的 Provider 包装层)。选项 C 错误,stream()ServiceLoader 类自身的方法,不依赖 JPMS,在 classpath 项目中同样可用。选项 D 错误,stream() 不会清缓存,清缓存需要显式调用 reload()


opens(反射开放)

Java 9 引入模块系统后,最剧烈的"副作用"之一就是:反射(Reflection)默认被模块边界挡住了。在 Java 8 及之前的世界里,只要你拿到一个 Class 对象,就可以通过 setAccessible(true) 肆意访问私有字段、调用私有方法——整个 JVM 像一扇敞开的大门。模块系统关上了这扇门,而 opens 指令就是你选择性地、有控制地把某些房间的门重新打开的钥匙。

理解 opens 的核心在于一句话:exports 管的是编译时(compile-time)的可见性,opens 管的是运行时(runtime)的反射可达性(reflective accessibility)。两者各司其职,互不替代。


为什么需要 opens?—— 反射在模块系统下的困境

在模块化之前,大量 Java 框架依赖"深度反射"(deep reflection)来工作。所谓深度反射,指的是通过 java.lang.reflect.AccessibleObject.setAccessible(true) 突破 Java 语言层面的访问控制(privateprotected、package-private),直接读写对象内部状态。典型场景包括:

  • Spring Framework:通过反射注入 @Autowired 标注的私有字段,无需 setter 方法。
  • Hibernate / JPA:反射读取实体类的私有字段来完成 ORM 映射。
  • Jackson / Gson:反射遍历对象字段进行 JSON 序列化与反序列化。
  • JUnit / Mockito:反射访问被测类的内部状态或创建 Mock 代理。

模块系统上线后,JVM 引入了一套严格的 强封装(Strong Encapsulation) 机制。规则很简单:

一个模块中未被 exportsopens 的包,对外部模块来说 既不可编译引用,也不可反射访问

这意味着即使你在运行时拿到了某个类的 Field 对象,调用 field.setAccessible(true) 时,JVM 会直接抛出 InaccessibleObjectException(它是 IllegalAccessException 的子类)。这在 Java 8 迁移到 Java 9+ 的过程中是最常见的"翻车"现场之一。

Java
// 假设 com.example.model 包属于模块 my.app,且未被 opens
// 外部模块尝试反射访问:
Field nameField = User.class.getDeclaredField("name"); // 获取私有字段
nameField.setAccessible(true);                          // 💥 抛出 InaccessibleObjectException
Object value = nameField.get(someUser);                 // 永远走不到这一行

opens 指令正是为了解决这个问题而设计的——它允许模块作者 声明式地 打开某些包的反射权限,而不必放弃编译时的封装性。


opens 的语法与形式

opens 指令写在 module-info.java 中,有两种形式:

形式一:无限定 opens(Unqualified Opens)

Java
// module-info.java
module my.app {
    // 将 com.example.model 包对所有模块开放反射访问
    opens com.example.model;
}

这表示 任何模块 都可以在运行时对 com.example.model 包中的类使用深度反射,包括访问 private 成员。但请注意——这个包在编译时仍然 不可见,外部模块不能直接 import 里面的类(除非你同时也 exports 了它)。

形式二:限定 opens(Qualified Opens)

Java
// module-info.java
module my.app {
    // 仅对 spring.core 模块开放 com.example.model 的反射权限
    opens com.example.model to spring.core;
 
    // 可以同时指定多个模块,用逗号分隔
    opens com.example.internal to spring.core, hibernate.core, jackson.databind;
}

限定 opens 遵循 最小权限原则(Principle of Least Privilege):只把反射权限授予真正需要它的框架模块,而非向所有人敞开。这在安全性要求较高的场景中尤其重要。


exports vs opens:编译时 vs 运行时

这是模块系统中最容易混淆的一对概念。下面用一张表格来厘清它们的职责边界:

Code
┌──────────────────┬──────────────────────────┬──────────────────────────┐
│       维度       │        exports           │         opens            │
├──────────────────┼──────────────────────────┼──────────────────────────┤
│  作用时机        │  编译时 + 运行时          │  仅运行时                │
│  控制对象        │  public API 的可见性      │  反射(Reflection)可达性  │
│  外部能否 import │  ✅ 能                    │  ❌ 不能(除非也 exports) │
│  外部能否反射    │  仅 public 成员           │  所有成员(含 private)    │
│  典型用途        │  定义模块的公共 API       │  允许框架做依赖注入/ORM  │
│  安全等级        │  中 (仍遵守访问修饰符)    │  低 (突破访问修饰符)     │
└──────────────────┴──────────────────────────┴──────────────────────────┘

一个关键的推论:exports 一个包并不意味着外部模块可以对它做深度反射。如果你只 exports com.example.model,外部模块可以在代码中 import com.example.model.User,可以调用 Userpublic 方法,但是 不能 通过反射访问 Userprivate 字段。要想允许深度反射,必须使用 opens

反过来也成立:opens 一个包并不意味着外部模块可以在代码中 import 它opens 只打开运行时的反射通道,编译时的门仍然是关着的。

所以在实际项目中,你经常会看到同一个包 既被 exports 又被 opens 的写法:

Java
module my.app {
    // 编译时可见:外部模块可以 import 这个包中的 public 类
    exports com.example.model;
 
    // 运行时反射可达:框架可以反射访问 private 字段
    opens com.example.model to spring.core, hibernate.core;
}

下面用一张流程图来直观展示 JVM 在进行反射访问时的权限检查逻辑:


open module:整个模块级别的反射开放

如果你的模块中有大量包都需要被框架反射访问,逐个写 opens 会非常繁琐。Java 提供了一个"偷懒"的写法——open module(开放模块):

Java
// 在 module 关键字前加 open,整个模块所有包都对所有模块开放反射
open module my.app {
    requires spring.core;
    requires hibernate.core;
 
    // 不需要再写任何 opens 语句
    // 所有包自动对所有模块开放深度反射
    exports com.example.api;  // exports 仍然可以正常使用
}

使用 open module 后的效果等价于:模块中 每一个包 都被隐式地声明了 opens ... ;(无限定 opens)。这意味着任何外部模块都可以对你的所有包做深度反射。

注意事项

  • open module不允许 再出现任何 opens 语句,因为整个模块已经全部开放,再写就是冗余的,编译器会报错。
  • open module 不影响 exports 的行为。你仍然需要显式 exports 来决定哪些包在编译时可见。换言之,open 只管反射通道,不管编译时的可见性。
  • 在安全性上,open module 相当于把模块的反射防线完全撤除,应当 谨慎使用。它适合"应用层"模块(Application Module,不会被其他库依赖的顶层模块),而不适合"库模块"(Library Module)。
Code
┌──────────────────────────────────────────────────────────┐
│                    open module my.app                     │
│                                                          │
│  ┌─────────────────┐  ┌─────────────────┐               │
│  │ com.example.api │  │ com.example.svc │               │
│  │   (exports ✅)  │  │   (未 exports)  │               │
│  │   (opens 隐式✅)│  │   (opens 隐式✅)│               │
│  └─────────────────┘  └─────────────────┘               │
│  ┌─────────────────┐  ┌─────────────────┐               │
│  │com.example.model│  │com.example.util │               │
│  │   (未 exports)  │  │   (未 exports)  │               │
│  │   (opens 隐式✅)│  │   (opens 隐式✅)│               │
│  └─────────────────┘  └─────────────────┘               │
│                                                          │
│  编译时可见:仅 com.example.api                          │
│  运行时反射:全部包均可                                  │
└──────────────────────────────────────────────────────────┘

opens 在 Spring Boot 项目中的实战

来看一个贴近真实项目的例子。假设我们有一个 Spring Boot 应用,它的模块描述如下:

Java
// module-info.java
module com.mycompany.webapp {
    // ---- 编译依赖 ----
    requires spring.boot;            // Spring Boot 核心
    requires spring.boot.autoconfigure; // 自动配置
    requires spring.beans;           // Bean 工厂
    requires spring.context;         // 应用上下文
    requires spring.web;             // Web MVC
    requires jakarta.persistence;    // JPA 注解 (Jakarta EE)
 
    // ---- 公共 API ----
    exports com.mycompany.webapp.controller; // REST 控制器对外可见
 
    // ---- 反射开放 ----
    // Spring 需要反射注入 @Autowired 私有字段
    opens com.mycompany.webapp.config to spring.core, spring.beans;
    opens com.mycompany.webapp.service to spring.core, spring.beans;
 
    // Hibernate/JPA 需要反射读取实体类的 private 字段
    opens com.mycompany.webapp.entity to hibernate.core, org.hibernate.orm.core;
 
    // Jackson 需要反射做 JSON 序列化
    opens com.mycompany.webapp.dto to com.fasterxml.jackson.databind;
}

这段配置体现了几个重要的设计决策:

  1. controller 层只 exports,不 opens:因为 Spring MVC 的 DispatcherServlet 查找 @RestController 时只需要看到 public 类和 public 方法,不需要深度反射访问 private 成员。
  2. service / config 层 opens 给 Spring:因为 Spring 的依赖注入要通过反射设置 private 字段的值。
  3. entity 层 opens 给 Hibernate:Hibernate 在做 ORM 映射时,会反射读取实体类的 private 字段(通常通过 field access strategy)。
  4. dto 层 opens 给 Jackson:Jackson 在序列化/反序列化 JSON 时,需要反射访问 DTO 对象的字段。

如果你嫌太麻烦——尤其是在开发阶段——直接用 open module 也完全可以:

Java
open module com.mycompany.webapp {
    requires spring.boot;
    requires spring.boot.autoconfigure;
    requires spring.beans;
    requires spring.context;
    requires spring.web;
    requires jakarta.persistence;
 
    exports com.mycompany.webapp.controller;
    // 不需要任何 opens,整个模块已经全部开放反射
}

命令行方式:--add-opens

在某些场景下,你无法修改目标模块的 module-info.java(比如你想反射访问 JDK 内部的 java.base 模块中的类),这时可以通过 JVM 启动参数 --add-opens 来动态打开反射权限:

Bash
# 语法:--add-opens <source-module>/<package>=<target-module>
# 含义:将 source-module 中的 package 对 target-module 开放反射
 
# 示例 1:将 java.base 模块的 java.lang 包对所有未命名模块开放
java --add-opens java.base/java.lang=ALL-UNNAMED -jar myapp.jar
 
# 示例 2:将 java.base 的 java.util 包对指定模块开放
java --add-opens java.base/java.util=my.app -jar myapp.jar
 
# 示例 3:多个 --add-opens 可以同时使用
java --add-opens java.base/java.lang=ALL-UNNAMED \
     --add-opens java.base/java.lang.reflect=ALL-UNNAMED \
     --add-opens java.base/java.io=ALL-UNNAMED \
     -jar myapp.jar

ALL-UNNAMED 是一个特殊标识,代表所有 未命名模块(Unnamed Module)——也就是所有放在 classpath(而非 module path)上的 JAR。在从 Java 8 迁移到 Java 9+ 的过渡期,大量老项目都需要加上 --add-opens 参数才能正常运行。

你在升级 JDK 版本时,如果看到类似下面的警告信息:

Code
WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by com.example.SomeLib to field java.lang.String.value
WARNING: Please consider reporting this to the maintainers of com.example.SomeLib
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access

这就是 JVM 在告诉你:某个库在做未授权的深度反射。在 Java 9-15 中,JVM 默认以 --illegal-access=permit 模式运行(允许但警告);从 Java 16 开始,默认改为 --illegal-access=deny(直接拒绝),强制要求使用 --add-opens 或在 module-info.java 中声明 opens。到了 Java 17+--illegal-access 参数本身已被移除,强封装成为唯一选项。


opens 与 exports 的组合策略总结

最佳实践可以归纳为四条原则:

第一,API 包用 exports,内部实现包什么都不加。这是模块化设计的基本功——对外暴露接口,对内隐藏实现。

第二,框架需要反射的包用 opens to(限定形式)。把反射权限精确地授予需要它的框架模块,而不是对所有人无差别开放。

第三,应用层顶层模块可以大方地使用 open module。因为应用模块是最终消费者,不会被其他库依赖,强封装的意义不大,而开发便利性更重要。

第四,库模块(Library Module)绝不要用 open module。库是被别人依赖的,你不知道消费者是谁,全开放等于放弃了模块系统带来的所有封装收益。


opens 的运行时 API

Java 模块系统提供了一组 API 让你在运行时查询和操作 opens 状态:

Java
// 获取当前模块
Module myModule = getClass().getModule();              // 获取当前类所在的模块对象
 
// 检查某个包是否对指定模块开放了反射
Module springCore = ModuleLayer.boot()                 // 获取启动模块层
        .findModule("spring.core")                     // 在启动层中查找 spring.core 模块
        .orElseThrow();                                // 如果找不到则抛出异常
 
boolean isOpen = myModule.isOpen(                      // 检查 myModule 是否对 springCore 开放
        "com.example.model",                           // 被检查的包名
        springCore                                     // 目标模块
);
System.out.println("isOpen to spring.core: " + isOpen); // 输出检查结果
 
// 动态添加 opens(仅限模块自身调用,不能替别人开门)
myModule.addOpens(                                     // 在运行时动态开放一个包
        "com.example.internal",                        // 要开放的包名
        springCore                                     // 授予反射权限的目标模块
);
// 注意:只有模块自身才能调用 addOpens,否则抛出 IllegalCallerException

Module.addOpens() 是一个强大但受限的方法。它的设计哲学是:模块只能打开自己的门,不能撬别人的锁。这确保了模块的封装性不会被外部代码破坏。框架通常利用这个 API 实现"协作式开放"——在模块的启动代码中,主动把必要的包开放给框架模块。


📝 练习题

以下 module-info.java 配置中,外部模块 framework.core 尝试通过反射访问 com.app.entity.User 类的 private String name 字段,哪种情况下能够成功?

Java
// 选项 A
module my.app {
    exports com.app.entity;
}
 
// 选项 B
module my.app {
    opens com.app.entity to framework.core;
}
 
// 选项 C
module my.app {
    exports com.app.entity;
    opens com.app.entity to framework.other;
}
 
// 选项 D
module my.app {
    exports com.app.entity to framework.core;
}

A. 选项 A

B. 选项 B

C. 选项 C

D. 选项 D

【答案】 B

【解析】 访问 private 字段属于深度反射(deep reflection),必须通过 opens 授权。选项 B 将 com.app.entity 包通过 opens ... to framework.core 精确地授予了 framework.core 模块的反射权限,因此 framework.core 可以成功调用 setAccessible(true) 并读取 private String name。选项 A 和 D 只使用了 exportsexports 仅开放编译时的 public API 可见性,无法授予对 private 成员的反射权限。选项 C 虽然使用了 opens,但授权目标是 framework.other 而非 framework.core,限定 opens 的目标模块不匹配,所以 framework.core 仍然无法进行深度反射。


本章小结

Java 平台模块系统(Java Platform Module System, JPMS)是自 Java 9 起引入的一次 架构级变革。它从根本上改变了 Java 应用的组织方式——从过去 classpath 上"一锅端"的扁平 JAR 堆砌,进化为具有 显式依赖声明强封装边界 的模块化体系。本章围绕 module-info.java 这一核心描述符,逐步展开了模块系统的四大支柱。以下是对全章知识脉络的系统性回顾。

核心概念回顾

模块(Module) 是比包(Package)更高一级的代码组织单元。每个模块在其根目录下放置一个 module-info.java 文件,这个文件就是模块的"身份证"与"合同书",它同时声明了三件事:我是谁(module 名称)、我需要谁(requires)、我向外暴露谁(exports)。这套机制让 JVM 在 编译期和运行期 都能执行强约束,而非像旧时代那样仅依赖开发者的自觉。

模块名采用 反向域名 惯例(如 com.myapp.core),与包名体系保持一致但逻辑上独立。一个模块可以包含多个包,但一个包只能归属于一个模块——这是 split package 禁令 的根源,JVM 启动时会直接拒绝同一个包出现在两个模块中的情况。

依赖与导出:模块间的契约

requiresexports 构成了模块间交互的 双向契约

requires 声明的是"我依赖谁"。普通的 requires 只建立单层依赖关系:模块 A requires 模块 B,那么 A 能读取 B 导出的包,但 A 的消费者无法自动看到 B。而 requires transitive 则将这种可见性向上传递——如果 A requires transitive B,那么任何 requires A 的模块都隐式获得了对 B 的读取权限。这在设计 API 模块时至关重要:当你的公开接口中的方法签名引用了另一个模块的类型时,就 必须 使用 transitive,否则下游调用者将面临编译错误。此外,requires static 提供了编译期必须、运行期可选的弱依赖语义,非常适合注解处理器、可选集成等场景。

exports 声明的是"我暴露谁"。未被导出的包对外部模块完全不可见——即使你用反射也无法突破这道墙(除非搭配 opens)。exports ... to ... 进一步提供了 定向导出(Qualified Export) 的能力,让你可以把某些内部 API 仅暴露给特定的"友元模块",实现精细化的访问控制。这种设计理念的核心哲学是:默认封闭,显式开放(Closed by default, open by declaration)

服务机制:面向接口的解耦

provides ... with ...uses 配合 java.util.ServiceLoader,构成了模块系统内建的 SPI(Service Provider Interface) 框架。服务消费者通过 uses 声明自己需要某个服务接口,服务提供者通过 provides 声明自己用哪个实现类来兑现这个接口。ServiceLoader.load() 在运行时自动发现并加载所有已注册的实现,消费者代码中 无需出现任何实现类的名字

这套机制替代了传统的 META-INF/services 文本文件方案,并带来了两大提升:第一,声明从文本文件迁移到了 module-info.java,获得了 编译期校验——拼写错误、缺失实现等问题在编译阶段就能被捕获;第二,模块系统在解析模块图时就已知晓服务关系,使得 jlink 等工具可以进行更精确的依赖分析与裁剪。

实际项目中,服务机制常被用于 插件架构:核心模块定义接口,各插件模块各自提供实现,主程序在运行时动态发现所有插件,实现了真正的"对扩展开放、对修改关闭"(Open-Closed Principle)。

反射开放:兼容框架生态

opens 是为了在强封装的新世界中为 反射型框架(如 Spring、Hibernate、Jackson)留出生存空间而设计的。exports 只开放编译期的公共 API 访问,而 opens 则在运行期允许深度反射——包括访问私有字段、调用私有构造器等操作。

opens 同样支持定向形式 opens ... to ...,只向指定模块开放反射权限,最大程度地收窄暴露面。而 open module 则是一种"全开放"的便捷写法,将模块内所有包都在运行期开放给反射,适用于那些被框架深度扫描的应用模块(如 Spring Boot 应用的主模块)。

需要特别理解的是 exportsopens 的本质区别:exports编译期+运行期的公共 API 可见性opens仅运行期的深度反射可达性。一个包可以同时被 exportsopens,分别服务于不同的访问场景。

全局视图

下面这张图将模块系统的核心指令在一个完整的 module-info.java 中进行全景展示,从依赖声明到服务注册,再到反射开放,形成一个完整的模块定义:

迁移与实践建议

对于已有项目向 JPMS 迁移,模块系统提供了平滑的过渡策略。未模块化的 JAR 放在 classpath 上会被归入 Unnamed Module,它能读取所有模块但不被任何命名模块 requires;放在 module path 上但没有 module-info.java 的 JAR 则成为 Automatic Module,其模块名从 JAR 文件名或 Automatic-Module-Name manifest 属性推导而来,且默认导出所有包。这两层兼容机制让你可以逐步、渐进地模块化项目,而非被迫一次性全部改造。

实际迁移时的常见策略是 自底向上:先将底层工具库模块化(添加 module-info.java),然后逐层向上推进,最后处理应用入口模块。对于依赖的第三方库尚未模块化的情况,可以先利用 Automatic Module 作为桥梁,待上游库发布模块化版本后再替换。


📝 练习题 1

以下 module-info.java 存在一个问题,会导致下游模块 com.app.web 在编译时报错(无法找到 com.lib.utils.StringHelper 类),请问根本原因是什么?

Java
// com.app.core 的 module-info.java
module com.app.core {
    requires com.lib.utils;           // com.lib.utils 导出了 com.lib.utils 包
    exports com.app.core.api;         // api 包中的接口方法返回 StringHelper 类型
}
 
// com.app.web 的 module-info.java
module com.app.web {
    requires com.app.core;
}

A. com.lib.utils 模块没有 exports 其包

B. com.app.core 应使用 requires transitive com.lib.utils

C. com.app.web 需要直接 requires com.lib.utils

D. B 和 C 都可以解决,但 B 是正确的设计实践

【答案】 D

【解析】 问题的核心在于 com.app.core 的公开 API(exports 出去的包)中的方法签名引用了 com.lib.utils 的类型 StringHelper。当 com.app.web requires com.app.core 时,它能看到 API 接口,但方法签名中的 StringHelper 类型来自 com.lib.utils,而 com.app.web 的模块图中并没有对 com.lib.utils 的读取权限。选项 C 确实能解决编译问题——让 web 模块直接依赖 utils 模块。但选项 B 是 正确的设计做法:既然 core 模块的公开 API 中暴露了 utils 的类型,就应该由 core 模块通过 requires transitive 将这个依赖自动传递给所有下游消费者,这是 transitive 语义存在的根本意义。如果让每个下游模块都自己手动 requires 一个它本不该直接了解的内部依赖,既违反了封装原则,也增加了维护负担。因此 D 最为准确。


📝 练习题 2

在模块化应用中,Spring Framework 需要在运行时通过反射访问应用模块 com.myappcom.myapp.model 包下实体类的私有字段。以下哪种 module-info.java 配置能满足需求且最符合最小权限原则

A. open module com.myapp { ... }

B. exports com.myapp.model;

C. opens com.myapp.model;

D. opens com.myapp.model to spring.core;

【答案】 D

【解析】 Spring 需要的是 运行期深度反射(访问私有字段),而非编译期 API 访问,因此 exports(选项 B)不够——它只提供 public 类型的编译期可见性,无法授权对私有成员的反射访问。选项 A 的 open module 虽然可行,但它将模块内 所有包 都对 所有模块 开放反射,远超实际需要。选项 C 的 opens com.myapp.model 将该包对所有模块开放反射,范围也偏大。选项 D 使用 opens ... to ... 定向开放,仅允许 spring.core 模块对 com.myapp.model 包进行深度反射,精确匹配了需求且严格遵守了 最小权限原则(Principle of Least Privilege)