楼层: 首页/ 软件技术/ Java 基础/ JPMS 模块系统:从 classpath 到 module-path
16

JPMS 模块系统:从 classpath 到 module-path

module-info · requires / exports / opens · jlink

JDK 9 引入的模块系统(JPMS,JSR 376)是 Java 二十多年来最大的一次架构变动。它想解决一个很朴素的问题:"classpath 上的东西全都能互相看见,这太危险了。"这一章讲清 module-info.java 的四个关键字、jlink 怎么把 200MB 的 JDK 裁成 40MB 的运行时,以及——那些让你头疼的 --add-opens 到底是怎么回事。

classpath 的三个顽疾:为什么要发明模块系统

是什么:模块(module)是一个带名字、带"对外接口声明"的包集合。声明写在模块根目录的 module-info.java 里。有 module-info.class 的 jar 就是"显式模块",没有的 jar 放到模块路径上就是"自动模块",放在类路径上则属于"未命名模块"。

为什么需要它:因为 classpath 有三个从设计上就无法解决的问题:

classpath 的三宗罪:每个用 Java 超过三年的人都至少被其中一个坑过
顽疾表现为什么 classpath 解决不了
JAR Hell两个 jar 里有同名类,谁先被扫到就用谁,不报错、不警告。classpath 是一个扁平列表,没有"版本"和"来源"的概念,"先到先得"就是它的全部规则。
Split package同一个包名被拆在两个 jar 里(典型如 javax.annotation),扫描、反射、注解处理都可能出现诡异行为。classpath 只知道"包名",不知道"这个包属于谁"。
零封装任何代码都能用反射摸到你 jar 里本不该公开的内部实现,包括 JDK 自己的 sun.misc.*。classpath 上一切都是 public,连 JDK 内部 API 都能直接调用,于是 JDK 的升级变成噩梦。

怎么用:最小的模块化项目就三个文件。

src/
└── com.example.app/                    // 目录名 = 模块名
    ├── module-info.java
    └── com/example/app/
        └── Main.java

// module-info.java
module com.example.app {
    requires java.sql;                  // 我需要 java.sql 模块(JDK 自带的模块)
    exports com.example.app;            // 我对外公开这个包
}

变引入模块后,三条规则被彻底改写了

① "能不能看见"从"在不在 classpath"变成了"模块间显式的 requires 关系"。没有 requires 就没有可见性——这从根上杜绝了"碰巧能用"。

② "能不能访问"从"是不是 public"变成了"所在的包有没有 exports"。哪怕你的类是 public,只要包没导出,别人编译期就用不了。这是强封装。

③ "反射能不能摸"从"随便摸"变成了"要 opens"。导出的包(exports)只保证编译期可读;想让框架在运行时用反射深入你的包,必须显式 opens。这就是所有 --add-opens 故事的起源。

坑:以为"上了 JDK 9+ 就自动模块化了"

不会。模块系统是"可选的":只要你的项目里没有 module-info.java,所有代码就在"未命名模块"里,行为跟 JDK 8 时代基本一样(还能用反射摸 JDK 内部 API,只是会打印警告,JDK 17 起变成直接报错)。所以现实中大量项目至今没有模块化——这不是不懂,而是权衡后的选择(本章最后一节专门讨论)。真正会让你"躲不开"的是两件事:① JDK 17 起对 JDK 内部 API 的强封装默认生效,老代码里的反射会抛 InaccessibleObjectException;② 想用 jlink 裁运行时,就必须模块化。

module-info.java 的四个关键字

是什么:module-info.java 里能写的指令并不多,核心就四条:requires(依赖谁)、exports(公开什么)、opens(允许反射什么)、uses/provides(服务发现)。把这四个搞清,模块系统就掌握了八成。

module-info.java 的全部指令:一张表就够了
指令含义
requires M;我需要模块 M。不加就没有可见性,编译都过不去。
requires transitive M;我需要 M,而且用我的人也需要。典型场景:我的公开 API 里出现了 M 的类型。作用和 Gradle 里的 api 完全一样。
requires static M;编译期必需、运行期可选。常用于"只在注解上用到"的依赖(比如编译期处理器)。运行期 M 不在也不会报错。
exports pkg;把 pkg 包公开给所有模块(编译期 + 普通运行期访问)。
exports pkg to M1, M2;限定导出:只对某几个模块公开。用于"内部 API,只给我的兄弟模块用"。
opens pkg;允许所有模块在运行时用反射访问 pkg 内部的私有成员(对应 classpath 时代的"完全开放")。
opens pkg to M;只对框架模块开反射。给 Spring/Hibernate 用时的推荐写法。
uses S;我要通过 ServiceLoader 使用服务接口 S。
provides S with Impl;我提供 S 的一个实现 Impl(这是模块化世界里替代 META-INF/services 的写法)。
open module / transitiveopen module X { } 表示整个模块对所有模块开放反射(省事但失去封装,仅适合应用模块)。

一个"像真项目"的 module-info.java:注意每个关键字为什么这么选

/**
 * 模块名用反向域名,且必须和目录名一致
 */
module com.example.shop {
    // ── 依赖 ──
    requires java.base;                 // 其实不用写:java.base 是所有模块的隐式依赖
    requires java.sql;
    requires transitive com.example.common;   // 我的公开 API 里出现了它的类型 -> 传递出去
    requires static org.jetbrains.annotations; // 只在编译期用到的注解

    // ── 对外开放 ──
    exports com.example.shop.api;             // 公开的 API 包
    exports com.example.shop.internal to com.example.shop.test;  // 只给测试模块用

    // ── 允许反射 ──
    // 我的实体类要给 Jackson/Hibernate 用反射读写字段,所以必须 opens
    opens com.example.shop.model to com.fasterxml.jackson.databind;

    // ── 服务 ──
    uses com.example.common.PaymentProvider;
    provides com.example.common.PaymentProvider
        with com.example.shop.impl.AlipayProvider;
}
坑:requires transitive 用多了,模块图会退化成一团乱麻

requires transitive 意味着"用我的人自动获得这个依赖",用多了等价于把 classpath 时代那种"到处都能看见"的局面重新造出来——只不过这次是通过模块系统实现的。判断标准和 Gradle 的 api 一模一样:"如果我把这个依赖换掉,下游会不会编译不过?会,才用 transitive;不会,一律用普通 requires。"反过来,如果你只是"顺手想让它传递"而用 transitive,下游就会莫名获得一堆它不需要也不理解的模块,最后谁都不敢删任何一行 requires。

exports 和 opens 的区别:编译期可见 ≠ 反射可用

是什么:这是整个模块系统里最容易被搞混、也最容易引发线上问题的一对概念。一句话记法:exports 管的是"编译期和普通调用能不能用",opens 管的是"运行时反射能不能深入"。

三个层次的可见性:从最严到最松
写法别人能 import 吗能普通调用吗能反射访问私有成员吗
什么都不写❌ 不能❌ 不能❌ 不能
exports pkg;✅ 能✅ 能❌ 不能(只能反射 public 成员,且需可访问)
opens pkg;❌ 不能❌ 不能✅ 能(含 private 字段/方法)
两个都写✅ 能✅ 能✅ 能

为什么会有 opens 这个东西:因为它面对的是一个现实:Spring、Hibernate、Jackson、JUnit 这些框架,全都靠反射读写你类里的私有字段(注入 @Autowired 的字段、读写 JPA 实体、序列化对象……)。在 classpath 时代这一切都理所当然;引入模块系统后,强封装默认把这条路堵上了,所以必须由你显式告诉 JVM:"这个包允许反射。"

# 现象一:启动直接报错,堆栈里出现 InaccessibleObjectException
# java.lang.reflect.InaccessibleObjectException: Unable to make field private ... accessible:
#     module java.base does not "opens java.lang" to unnamed module @1234abcd
# 这是 JDK 17+ 最常见的"升级 JDK 就起不来"的报错。

# 解法一(临时、命令行):用 --add-opens 打开对应的包
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar
java --add-opens java.base/java.util=ALL-UNNAMED -jar app.jar
#   语法:--add-opens 模块名/包名=目标模块
#   ALL-UNNAMED = 所有在 classpath 上的代码(也就是绝大多数传统应用)

# 解法二(模块化应用内):在 module-info.java 里写 opens
#   opens com.example.model to com.fasterxml.jackson.databind;

# 排查工具:找出到底是谁在用 JDK 内部 API
jdeps --jdk-internals app.jar

# 注意:老教程里的 --illegal-access=permit 已经在 JDK 17 被彻底移除
#      JDK 16 起默认值就是 deny,所以"加个参数放开一切"的时代已经结束了
坑:加了一堆 --add-opens,但不知道哪条是必要的

现实中很常见的场景是:升级 JDK 后应用起不来,于是从网上抄来一长串 --add-opens,能跑就完事。这有三个后果:① 安全上把 JDK 内部彻底敞开(等价于回到 JDK 8 的封装水平);② 无法判断哪条还需要——因为升级依赖后可能已经不需要了;③ 一旦某条真的移除了,你会连"为什么需要它"都不知道。
正确做法:从报错堆栈里读出具体是哪个模块的哪个包(module java.base does not "opens java.lang" 已经告诉你了),只加那一条;同时用 jdeps --jdk-internals 找出是哪个依赖在用内部 API,优先升级那个依赖,而不是给它开洞。另外提醒一句:--add-opens 只影响"深反射",如果你的问题是 IllegalAccessError(编译期可见性问题),那需要的是 --add-exports 或 --add-reads,两者不是一回事。

实战:编译、运行、再裁出一个 40MB 的运行时

怎么用:模块化项目的编译和运行只需要多两个参数(--module-path 和 -m)。真正让人眼前一亮的是 jlink——它能把你的模块和它实际用到的 JDK 模块打包成一个定制运行时,丢掉整个 JDK 里你用不到的部分。

# ── 假设项目结构 ──
# src/com.example.app/module-info.java
# src/com.example.app/com/example/app/Main.java

# ── ① 编译 ──
javac -d out/modules/com.example.app \
      src/com.example.app/module-info.java \
      src/com.example.app/com/example/app/Main.java

# ── ② 运行(注意模块名和主类名用 "/" 分隔) ──
java --module-path out/modules -m com.example.app/com.example.app.Main
# 等价写法:java --module-path out/modules --module com.example.app/com.example.app.Main

# ── ③ 看清 JDK 自带了哪些模块(JDK 本身就是一堆模块) ──
java --list-modules | head -20        # 列出所有可用的系统模块
java --list-modules | grep jdk.crypto # 找加密相关模块(裁镜像时经常要手动补)
java --describe-module java.sql       # 看某个模块依赖谁、导出什么
java --describe-module com.example.app --module-path out/modules

jlink:把 200MB 的 JDK 裁成一个只属于你的运行时

jlink --module-path out/modules:$JAVA_HOME/jmods \
      --add-modules com.example.app \
      --output myruntime \
      --strip-debug \
      --no-header-files \
      --no-man-pages \
      --compress=zip-6

# 各参数:
#   --module-path      你的模块 + JDK 的 jmods(系统模块的来源)
#   --add-modules      以哪个模块为根,自动带上它 requires 的全部模块
#   --output           输出目录(不是一个文件,是一整个目录树)
#   --strip-debug      去掉调试信息(省最多空间)
#   --no-header-files  去掉 C 头文件(只有 JNI 开发才需要)
#   --no-man-pages     去掉 man 手册
#   --compress=zip-6   压缩级别;JDK 21 起推荐 zip-0~zip-9,老的 0/1/2 已废弃

# ── 用它跑你的程序 ──
./myruntime/bin/java -m com.example.app/com.example.app.Main

# ── 对比体积(典型结果) ──
du -sh $JAVA_HOME       # 完整 JDK:约 300MB 左右
du -sh myruntime        # 裁出来的运行时:通常 40~60MB
# 注意:如果程序用到 HTTPS、时区、字符集,记得补上 jdk.crypto.ec、jdk.localedata 等模块,
#      否则会出现"在我机器上能跑,进容器就报找不到加密算法"这类问题
坑:jlink 用不了"自动模块"

如果你项目依赖的第三方 jar 是放在模块路径上自动变成"自动模块"的(没有 module-info.class),jlink 会直接拒绝——因为自动模块依赖"整个 classpath 都在"这个前提,而裁出来的镜像里可能什么第三方 jar 都没有,jlink 无法判断该带什么。解决办法:① 依赖的库如果已经模块化(很多现代库都提供了 module-info),让上游更新;② 用 jdeps --generate-module-info 生成一份 module-info.java 给那些还没模块化的库(能解决一部分,但不是万能);③ 放弃 jlink,改用 "jpackage + 完整 JRE" 或容器镜像分层的方案。这也是"模块化 vs 用 jlink"经常被绑在一起讨论的原因——jlink 的所有好处,都以"整个依赖链都模块化"为前提。

模块路径 vs 类路径:三种"模块身份"的规则

是什么:同一个 jar,放在哪条路径上,身份完全不同。这决定了"它能看见谁、谁又能看见它"。

三种身份:判断依据是"jar 里有没有 module-info.class"和"你把它放在哪条路径上"
身份怎么来的它能看见谁谁它能看见它
显式模块jar 里有 module-info.class,且放在 --module-path 上。只有它 requires 的模块。只有 requires 了它的模块,才能用(且仅限它 exports 的包)。
自动模块jar 里没有 module-info.class,但放在 --module-path 上。能看见所有其他模块(相当于全开)。能被 requires;它的名字由 jar 文件名推断(commons-lang3-3.14.jar → commons.lang3)。
未命名模块所有在 -cp / classpath 上的东西(包括你的应用如果没模块化)。能看见所有显式模块导出的包 + 所有 classpath 内容。没有任何模块能 requires 它(因为它没有名字)。

为什么这些规则值得记:因为它们决定了两件很实际的事:① 你的传统应用(未命名模块)可以通过反射访问 JDK 内部 API,只是要被 --add-opens 管;② 一旦某个库上了模块路径变成自动模块,它就会"看见一切",这可能让原本隐藏的 split package 问题突然暴露成启动错误。

# 自动模块的名字怎么来的?
#   guava-33.4.0-jre.jar       -> guava
#   commons-lang3-3.14.0.jar   -> commons.lang3        (连字符变成点,版本号被去掉)
#   库作者的正确做法:在 MANIFEST.MF 里写一行
#     Automatic-Module-Name: com.example.mylib
#   这样无论 jar 文件怎么改名,模块名都稳定不变。**自己发的库建议都加上这一行。**

# split package 会以启动错误的形式出现:
#   java.lang.module.ResolutionException: Modules A and B export package p to module C
# 这就是 classpath 时代"同一个包被两个 jar 瓜分"的问题,在模块化后变成了硬错误。
# 修法:把其中一个依赖从模块路径挪回 classpath,或者换掉冲突的库。

到底要不要上 JPMS:一份诚实的决策表

为什么需要这一节:因为网上关于模块系统的文章大多在讲"它多好",却很少讲"它什么时候不值得"。

按"你是谁"来选,答案很不一样
你的角色建议
写库 / 中间件值得上。显式声明"我依赖什么、公开什么"能让下游受益,也顺便能逼自己把内部实现藏好。哪怕不写完整 module-info,也至少应在 MANIFEST 里加 Automatic-Module-Name(成本一行,收益很大)。
写业务应用(Spring Boot 等)多数情况下不必写 module-info。框架大量用反射 + 动态代理,模块化后你要写一堆 opens,收益却有限。但你必须懂模块系统——因为升级 JDK 17/21 时那些 --add-opens 报错逃不掉。
要做极致小镜像 / 快速启动值得上。jlink 把运行时从几百 MB 裁到几十 MB,在容器和边缘设备上是实打实的收益(前提:整条依赖链都能模块化)。
老项目升级 JDK不要顺手做模块化。升级 JDK 和引入模块系统是两件事,同时做会把问题混在一起,排错成本翻倍。先升 JDK 用 --add-opens 过渡,稳定后再考虑模块化。
模块化之后的额外收益与代价(工具与工程层面)
能力 / 成本说明
✅ jdeps 静态分析jdeps --jdk-internals app.jar 找出谁在用 JDK 内部 API;jdeps --generate-module-info 帮你起草 module-info,这是迁移最好的起点。
✅ 强封装防误用内部类再也不会被同事"顺手 import",API 边界变成编译期约束。
✅ 更小的运行时jlink 裁镜像,容器镜像体积和攻击面都下降。
⚠️ 测试配置变复杂测试代码通常在同一模块里编译,需要 --add-reads/--patch-module;Maven 的 surefire 要设 <useModulePath>false</useModulePath>,Gradle 有 java.modularity.inferModulePath 相关配置——这是很多人觉得"模块化很烦"的真正来源。
⚠️ 依赖链要求高只要有自动模块,jlink 就用不了;有 split package,启动直接失败。
⚠️ 反射要显式 opens所有动态框架(Spring、Hibernate、Jackson、JUnit、Mockito)都需要你配合开包。
记
本章小结

① 模块 = 包 + module-info.java;它解决的是 classpath 的三个设计缺陷——JAR Hell、split package、零封装。

② 记住四组关键字的语义:requires(看得见才能用)、requires transitive(等于 Gradle 的 api)、exports(编译期可读)、opens(运行期可反射)。InaccessibleObjectException 加 --add-opens,IllegalAccessError 加 --add-exports,别搞混。

③ 实用主义结论:写库时值得模块化(至少加 Automatic-Module-Name),写业务应用多数不必写 module-info,但必须懂它——因为 --add-opens 和 jlink 这两件事,一个你逃不掉,一个很有用。