JPMS 模块系统

系统讲解 JPMS 模块系统:module-info.java 的 requires/exports/opens/provides 语义、模块路径与类路径的差异、自动模块与无名模块的兼容规则、拆分包冲突、jdeps/jlink/jmod 工具链,以及从 classpath 迁移到 module path 的分阶段策略与 Spring 生态的兼容取舍。

Java 9 引入的 JPMS(Java Platform Module System,Java 平台模块系统)是语言层面最被低估也最被误解的特性。它用 module-info.java 声明模块边界,用 requires/exports 显式描述依赖与可见性,把「类路径上的一切互相可见」变成「只有导出的包才可见」。很多团队因为 Spring 生态的反射依赖而对它敬而远之,但 jlink 裁剪运行时、强封装带来的可维护性,仍然是大型工程值得掌握的武器。

一、为什么需要模块系统

类路径(Classpath)模型有两个根本问题:

问题表现后果
全可见所有 JAR 的 public 类互相可见内部 API 被误用,重构即破坏兼容
无依赖声明依赖靠构建工具约定缺 JAR 到运行时才 NoClassDefFoundError
无版本冲突检测同名类先到先得类遮蔽(class shadowing)难排查
无法裁剪整包 JRE 一起打包制品臃肿,无法只带用到的模块
JPMS 的三个目标:
  1. 可靠的配置 —— 依赖在启动时校验,缺模块立即报错
  2. 强封装     —— 未 export 的包外部不可访问(含反射)
  3. 可裁剪     —— jlink 只打包用到的模块,生成精简运行时

一句话总结: JPMS 把「隐式全可见 + 运行时才发现缺失」换成「显式声明 + 启动即校验」,核心价值是可靠配置与强封装,jlink 只是它的附加红利。

二、module-info.java 的指令

module com.example.order {
    requires java.sql;                      // 依赖标准模块
    requires transitive com.example.common; // 传递依赖:下游也能看到
    requires static lombok;                 // 编译期需要,运行期可选

    exports com.example.order.api;          // 对外公开的包
    exports com.example.order.spi to com.example.plugin;  // 限定导出

    opens com.example.order.entity to org.hibernate.orm.core;  // 反射开放
    opens com.example.order.dto;            // 对所有模块开放反射

    uses com.example.order.spi.PaymentProvider;       // 声明服务消费者
    provides com.example.order.spi.PaymentProvider    // 声明服务提供者
        with com.example.order.impl.AlipayProvider;
}

2.1 关键指令语义

指令含义常见误用
requires依赖某模块漏写导致 module not found
requires transitive依赖且向下游传递滥用导致依赖泄漏
requires static编译期必需、运行期可选与 optional 语义混淆
exports公开包(编译+反射可见)把所有包都导出,失去封装
exports ... to只对指定模块公开限定模块名写错静默失效
opens仅反射可见(编译不可见)Spring/Hibernate 必需
uses / provides服务加载(ServiceLoader)忘记 uses 导致 SPI 找不到
exports vs opens 的区别:
  exports:编译期可 import,反射默认可访问 public 成员
  opens: 编译期不可 import,但反射可访问全部成员(含 private)
  框架(Spring/Hibernate/Jackson)大量用反射 → 需要 opens

一句话总结: exports 管「编译可见」,opens 管「反射可见」;给框架的包用 opens,给业务调用方的包用 exports,这是模块化最核心的一条区分。

三、模块路径 vs 类路径

# 类路径(传统)
java --class-path lib/*:app.jar com.example.Main

# 模块路径(JPMS)
java --module-path mods:lib --module com.example.order/com.example.order.Main

# 简写
java -p mods:lib -m com.example.order/com.example.order.Main

# 运行未命名模块(有 main 的 JAR)
java -p mods -m com.example.order
两类路径的区别:
  类路径 classpath
    - 所有 JAR 平等可见,无边界
    - 无法使用 jlink
    - 仍然可用(向后兼容)

  模块路径 module-path
    - 模块目录(含 module-info.class 或自动模块名)
    - 启动时解析依赖图,缺失立即报错
    - 可用 jlink 生成定制运行时

3.1 模块的三种类型

类型判定特性
具名模块有 module-info.class完整模块语义
自动模块(Automatic)JAR 无 module-info,但放在 module-path模块名从 JAR 文件名推导,导出全部包,可读所有模块
无名模块(Unnamed)在 class-path 上可读所有模块,但无人能 requires 它
自动模块名推导规则:
  foo-bar-1.2.3.jar  →  foo.bar
  去掉版本号,把非字母数字替换为点,去重连续点
  可用 --describe-module 查看推导结果:
    jar --describe-module --file=foo-bar-1.2.3.jar

一句话总结: 自动模块是「过渡桥梁」——把传统 JAR 放上 module-path 就能被具名模块 requires,但它导出所有包、可读一切,是迁移期的临时方案,不是终态。

四、拆分包(Split Package)冲突

同一个包出现在两个模块里,是 JPMS 最常见的报错来源:

错误示例:
  module com.example.a 导出 com.example.util
  module com.example.b 也导出 com.example.util
  → 启动报错:module com.example.a reads package com.example.util from both ...

根因:
  类路径允许同名包分散在多个 JAR(合并成一个「包空间」)
  模块路径禁止一个包被两个模块导出(模块是包的唯一起源)
常见拆分包场景:
  1. javax.annotation 分散在 JDK 与第三方 JAR
  2. 老框架把 api 与 impl 放在同一个包
  3. 工具类被复制到多个模块
  4. Spring 的 spring-core 与第三方共享 org.springframework.*

应对:
  1. 升级到已模块化的库版本
  2. 用 --patch-module 临时合并(迁移期)
  3. 把冲突包收敛到一个模块
# 迁移期临时补丁:把补丁 JAR 合并进目标模块
java --module-path mods \
     --patch-module com.example.a=patch/a-extra.jar \
     -m com.example.a/com.example.a.Main

一句话总结: 拆分包的本质是「同一个包不能有两个来源」;优先升级库,其次用 --patch-module 过渡,长期必须把冲突包归并到单一模块。

五、工具链:jdeps、jlink、jmod

5.1 jdeps:分析依赖

# 分析 JAR 的模块依赖
jdeps --module-path mods --multi-release 21 --print-module-deps app.jar

# 生成 module-info.java 草稿
jdeps --generate-module-info out --module-path mods app.jar

# 检查是否使用了 JDK 内部 API
jdeps --jdk-internals app.jar
# 输出示例
app.jar -> java.base
app.jar -> java.sql
app.jar -> java.logging

5.2 jlink:生成定制运行时

# 只打包用到的模块,生成精简 JRE
jlink --module-path $JAVA_HOME/jmods:mods \
      --add-modules com.example.order \
      --output custom-runtime \
      --strip-debug \
      --no-header-files \
      --no-man-pages \
      --compress=2

# 体积对比
du -sh $JAVA_HOME        # 完整 JDK:约 300MB
du -sh custom-runtime    # 定制运行时:约 40MB
# 用定制运行时启动(甚至可以用 jpackage 打成安装包)
custom-runtime/bin/java -m com.example.order/com.example.order.Main

# 打成可分发的原生安装包(Windows/macOS/Linux)
jpackage --runtime-image custom-runtime \
         --module com.example.order/com.example.order.Main \
         --name order-app --type deb

5.3 jmod:模块归档格式

# 查看模块内容
jmod list $JAVA_HOME/jmods/java.base.jmod | head

# 创建自定义 jmod
jmod create --class-path mods/com.example.order mymod.jmod

一句话总结: jdeps 负责「看清依赖」,jlink 负责「裁剪运行时」,jmod 是 JDK 内部的模块归档格式;jdeps --print-module-deps 的输出可以直接喂给 jlink 的 --add-modules。

六、迁移策略:从 classpath 到 module path

6.1 分阶段路线

阶段一:分析
  jdeps 扫描现有依赖,识别拆分包与 JDK 内部 API 使用

阶段二:自底向上模块化
  先模块化「叶子」库(无第三方依赖的模块),再逐层向上
  每个模块写最小 module-info:只 requires 必需,只 exports 必需

阶段三:处理第三方
  未模块化的库 → 放 module-path 当自动模块,或用 --add-modules ALL-MODULE-PATH

阶段四:收敛 opens
  给反射框架显式 opens,而非 opens 整个模块

阶段五:jlink 收尾
  生成定制运行时,接入 CI 校验依赖图
# 迁移期常用「宽进」参数,先跑通再收紧
java --module-path mods \
     --add-modules ALL-MODULE-PATH \
     --add-opens com.example.order/com.example.order.dto=org.springframework.core \
     -m com.example.order/com.example.order.Main

6.2 与构建工具集成

<!-- Maven:编译期使用 module path,且要求依赖可解析 -->
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <release>21</release>
  </configuration>
</plugin>
// Gradle:显式开启模块路径推断(Java 模块化插件)
java {
    modularity.inferModulePath.set(true)
}

一句话总结: 迁移的正确姿势是「自底向上 + 宽进严出」——先跑通再逐步收紧 exports/opens,同时把 --add-opens 当作技术债显式记账,而不是长期依赖。

七、Spring 生态与 JPMS 的现实取舍

Spring Framework 6 / Boot 3 的态度:
  1. 官方未提供 module-info,仍以自动模块形式工作
  2. 依赖大量反射 → 需要 opens,而非 exports
  3. CGLIB 动态代理需要 opens 代理目标包
  4. 常见报错:InaccessibleObjectException / IllegalAccessError
// 应用侧自建模块时,给框架开放的典型写法
module com.example.app {
    requires spring.boot;
    requires spring.context;

    opens com.example.app.config to spring.core, spring.beans;
    opens com.example.app.entity to org.hibernate.orm.core;

    exports com.example.app.api;
}
务实结论:
  1. 应用层模块化收益有限、成本高 —— 除非要做 jlink 裁剪
  2. 库/框架作者模块化收益大 —— 强封装保护内部 API
  3. 内部平台/中间件(自研)适合模块化
  4. 大量 Spring 反射的场景,JPMS 更多是「可控的 add-opens」而非纯收益

一句话总结: 在 Spring 生态里做 JPMS,收益最大的是自研库与需要 jlink 裁剪的场景,应用层更多是给框架补 opens;先想清楚动机再动手。

八、常见错误速查

报错原因修复
module not found: xxx依赖未在 module-path 或未 requires补 requires 或加 --add-modules
package xxx is not visible目标包未 exports加 exports 或 --add-exports
module reads package ... from both拆分包归并冲突包 / --patch-module
does not declare 'provides'SPI 未声明补 provides ... with ...
IllegalAccessError反射访问未 opens加 opens 或 --add-opens
class file has wrong version模块编译版本不一致统一 --release
unable to derive module descriptor自动模块名冲突/无效用 --module-name 或改 JAR 名
# 排查利器:打印模块描述符与依赖图
java --describe-module com.example.order
java --show-module-resolution -p mods -m com.example.order/com.example.order.Main

一句话总结: JPMS 的报错信息普遍明确,--describe-module 与 --show-module-resolution 是排查依赖图的两把钥匙;把报错关键词对上表即可快速定位。

小结

维度要点
核心机制module-info.java 声明 requires/exports/opens/provides
可见性exports 管编译,opens 管反射
路径module-path 有边界可校验,classpath 全可见
兼容自动模块桥接传统 JAR,无名模块兜底 classpath
工具jdeps 分析、jlink 裁剪、jmod 归档
策略自底向上、宽进严出、按需 opens

JPMS 的价值不在「一定要用」,而在「理解它之后你能判断什么时候该用」。库作者用它守护内部 API,平台团队用它裁剪运行时,应用团队则可以在 Spring 反射的现实下,把 --add-opens 从「临时补丁」升级为「显式契约」。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. Testcontainers 集成测试
  2. Micrometer 可观测性
  3. Spring Batch 批处理