模块系统
从这一章开始,我们进入 第八阶段:现代 Java 特性。这一阶段要讲的,是 Java 在 2017 年之后陆续引入的一批”现代化”语法和 API——模块系统、Records、Sealed Classes、Pattern Matching、HttpClient、Text Blocks 等。它们让 Java 在保持向后兼容的同时,终于追上了 Kotlin、Scala 这些”后起之秀”的语法表达力。
我们从一个争议最大、影响最深、也最难用的特性开始——Java 平台模块系统(Java Platform Module System,JPMS),也就是 JSR 376、JEP 261,在 JDK 9 正式发布。它把”乐高积木”的理念带进了 Java——每个模块是一块独立的积木,有清晰的接口和隐藏的内部,积木之间通过明确的契约拼接。
一、为什么需要模块化:jar hell 的痛苦
在讲”是什么”之前,先感受”为什么”。
1.1 jar hell:依赖地狱的 Java 版
在没有模块系统的年代,Java 把所有 jar 包都丢到 classpath 上,问题层出不穷:
- 版本冲突——
A.jar依赖commons-lang3:3.8,B.jar依赖commons-lang3:3.12。classpath 只认类名不认版本,谁排前面就用谁,结果可能运行时崩在某个不存在的方法上。 - 可见性失控——
public就是”全宇宙可见”。你想让internal包只给自己的模块用,做不到。Spring、Guava 都被逼着把内部 API 标public,结果被用户大量误用,想改都改不动。 - classpath 是一锅粥——classpath 上的所有类互相可见,没有边界。一个 jar 缺了依赖,编译时不报错,运行时才
NoClassDefFoundError。 - JDK 自己也太胖——
rt.jar几千个类,写个 HelloWorld 也要拖上 AWT、Swing、CORBA。嵌入式设备、云原生镜像都嫌它大。
1.2 模块化要解决什么
JPMS 的目标:
- 可靠的配置——模块声明自己需要什么、提供什么,启动时就检查依赖完整性,而不是运行时崩。
- 强封装——
public不再”全宇宙可见”,只对exports出去的包可见,内部包真正隐藏。 - 可组合的平台——JDK 自己拆成几十个小模块,应用只带需要的,支持定制 JRE(
jlink)。
二、module-info.java:模块的身份证
每个模块在根目录有一个 module-info.java 文件,它就是模块的”身份证”。
2.1 最简单的模块
myapp/
├── module-info.java
└── com/example/
└── Main.java
// module-info.java
module myapp {
requires java.sql; // 依赖 java.sql 模块
exports com.example; // 对外暴露 com.example 包
}
// com/example/Main.java
package com.example;
import java.sql.Connection;
public class Main {
public static void main(String[] args) {
System.out.println("模块 myapp 启动");
}
}
2.2 五个关键关键字
module-info.java 里能用到的关键字不多,但每一个都重要:
| 关键字 | 作用 | 示例 |
|---|---|---|
requires | 声明依赖某模块 | requires java.sql; |
requires transitive | 传递依赖(使用者也能读到) | requires transitive java.logging; |
requires static | 编译时必需,运行时可选 | requires static lombok; |
exports | 对外暴露某包 | exports com.example.api; |
exports ... to | 只对指定模块暴露 | exports com.example.internal to mylib; |
opens | 运行时开放(用于反射) | opens com.example.dto; |
opens ... to | 只对指定模块开放反射 | opens com.example.dto to myframework; |
uses | 声明使用某 ServiceLoader 服务 | uses com.example.Driver; |
provides ... with | 提供某服务的实现 | provides com.example.Driver with com.example.MyDriver; |
exports vs opens 的区别——这是初学者最容易混淆的点:
exports:编译时和运行时都可见——别的模块能import你的类、直接调用。opens:只在运行时通过反射可见——编译时不能 import,但反射能访问。典型用于 Spring、Hibernate 这种靠反射扫字段的框架。
如果某个包既 exports 又 opens,那就是既能直接用又能反射。
2.3 uses/provides:服务机制
JPMS 内建了一套类似 SPI(Service Provider Interface)的机制。一个模块声明 uses 某接口,另一个模块 provides 实现,运行时 ServiceLoader 自动发现。
// 框架模块
module myframework {
exports com.fw.api;
uses com.fw.api.Plugin; // 我要找 Plugin 的实现
}
// 插件模块
module myplugin {
requires myframework;
provides com.fw.api.Plugin with com.plugin.MyPlugin; // 我提供实现
}
// 框架代码
ServiceLoader<Plugin> loader = ServiceLoader.load(Plugin.class);
for (Plugin p : loader) {
p.run();
}
这就是 JDBC 4.0+ 自动发现驱动的原理——java.sql 模块 uses java.sql.Driver,各驱动 jar provides java.sql.Driver with ...。
三、模块路径 vs 类路径
模块系统引入了模块路径(module path),和传统的类路径(class path)并存。
| 维度 | class path | module path |
|---|---|---|
| 单元 | jar/目录 | 模块化 jar/目录 |
| 可见性 | 所有类互相可见(公共) | 按 exports/requires 控制 |
| 检查时机 | 运行时(崩了才知道) | 启动时(启动失败) |
| 包冲突 | 同名包可能加载两次(不会报错,行为未定义) | 同名包严格禁止(split package 错误) |
关键规则:JPMS 严禁分裂包(split package)——同一个包不能出现在两个模块里。这是模块化迁移时最常踩的坑。
3.1 unnamed module:类路径上的代码
类路径上的所有类,会被自动放进一个叫**未命名模块(unnamed module)**的容器里。它有几个特点:
- 自动
requires所有模块——能读到模块路径上所有exports的包。 - 不
exports任何包——模块路径上的代码看不到类路径上的类(除非反射)。 - 每个类加载器一个 unnamed module。
这就是为什么”老代码放 classpath,新模块放 module path”能共存—— unnamed module 是个”二等公民”,只能被模块用反射访问,不能被模块直接 import。
3.2 automatic module:jar 的过渡身份
一个普通的 jar(没有 module-info.class)放到模块路径上,会自动变成自动模块(automatic module)。它的特点:
- 模块名取自 jar 名(
foo-bar-1.0.jar→ 模块名foo.bar,可由Automatic-Module-Name: foo.bar在 MANIFEST 显式指定)。 requires所有模块——能读所有exports。exports所有包——所有包自动对外可见。
这是迁移的”过渡桥梁”——你不用一次性把所有 jar 都模块化,先把核心模块化,依赖的 jar 当 automatic module 用。
四、jdeps:依赖分析利器
模块化改造最大的工作量是搞清楚”谁依赖谁”。JDK 自带的 jdeps 工具就是干这个的。
# 分析一个 jar 依赖了哪些 JDK 模块
jdeps --jdk-internals myapp.jar
# 分析 jar 之间的依赖
jdeps -recursive -module-path libs myapp.jar
# 推荐生成的 module-info.java
jdeps --generate-module-info ./out myapp.jar
jdeps --jdk-internals 还会警告你用了 JDK 内部 API(如 sun.misc.Unsafe)——JDK 17 起这些内部 API 大多被强封装,模块化迁移必须先扫一遍。
五、模块化迁移策略:自底向上
把一个老项目迁移到完全模块化,是个”渐进”过程。社区总结出两条路径:
5.1 自底向上(Bottom-Up)
- 底层库先模块化——那些不被项目内部依赖的 jar 优先。
- 中间层逐步跟进——等依赖的库都模块化了,再加
module-info。 - 应用层最后做。
每一步都把已模块化的 jar 放 module path,未模块化的放 class path 或当 automatic module。
5.2 自顶向下(Top-Down)
如果底层库不归你管、第三方还没模块化:
- 应用代码先模块化,依赖的 jar 当 automatic module。
- automatic module 的包名当模块名用,先凑合。
- 等第三方出模块化版本再换。
5.3 实战示例:演示模块路径与反射
下面的例子演示在单个文件里通过反射访问未导出包,以及 Module API 的基本用法——这是在不分多模块项目结构时最容易跑起来的演示方式。
观察重点:
- 当前类在 unnamed module(
isNamed()=false)——因为我们没写module-info.java。sun.misc.Unsafe无法直接加载——JDK 17 起内部包强封装,除非加--add-opens启动参数。ModuleAPI 可以运行时查询模块的导出/开放状态——框架做反射扫描时常用。ModuleDescriptor.newModule可以编程式构造模块描述符——动态代理、字节码增强库会用。
六、jlink:定制 JRE
模块化的”红利”之一是 jlink——把你的应用 + 它依赖的 JDK 模块打包成一个定制 JRE,不带 JVM 的几余模块。
jlink --module-path out:JAVA_HOME/jmods \
--add-modules myapp \
--launcher launch=myapp/com.example.Main \
--output myapp-image
产物是一个完整目录,包含 JVM、应用模块、启动脚本——不需要用户装 Java。Docker 镜像可以从几百 MB 缩到几十 MB。
七、本章小结
| 概念 | 核心要点 |
|---|---|
| jar hell | 版本冲突、可见性失控、依赖运行时才崩 |
| module-info.java | 模块的身份证,声明 requires/exports/opens/uses/provides |
requires | 依赖某模块;transitive 传递;static 运行时可选 |
exports | 编译+运行时可见;exports to 限定目标 |
opens | 运行时反射可见;用于框架反射访问 |
uses/provides | ServiceLoader 服务机制 |
| 模块路径 | 按 exports/requires 严格控制可见性 |
| 类路径 | 所有类互相可见,进入 unnamed module |
| automatic module | 普通 jar 放模块路径,自动模块化,exports 全部包 |
| split package | 同包禁止出现在多个模块 |
| jdeps | 依赖分析、生成 module-info 草稿、扫内部 API |
| jlink | 定制 JRE,云原生镜像友好 |
记忆口诀:
- 模块像乐高积木——有凸起(exports)有凹槽(requires),契合才能拼。
exports是门牌——挂出去的包,外人能进;没挂的,门锁着。opens是后门钥匙——反射能进,编译器不让 import。unnamed是过渡区——老代码的避难所。automatic是临时身份——jar 还没改造前的过渡装。- 分裂包是禁忌——同包不能跨模块。
jdeps是侦察兵——迁移前先派它探路。
结语:模块化的现实
JPMS 是 Java 历史上最艰难的一次升级——从 2011 年立项到 2017 年发布,争吵了 6 年,连 IBM、Red Hat 都曾公开反对。发布后社区反应冷淡:Spring、Tomcat 至今没完全模块化,绝大多数应用还在用 classpath。
但模块化的价值是真实的——强封装让库作者终于敢重构内部 API,jlink 让云原生镜像变小,JDK 自身模块化让平台可裁剪。即便你不写 module-info.java,模块系统也在底层默默运行——你用的每个 JDK 类都在某个模块里。
下一章我们看一个更”亲民”的现代特性——Records 记录类。它是 Java 14 引入、Java 16 转正的语法糖,一行代码干掉过去几十行的 POJO 样板。如果模块系统是”重型工程”,Records 就是”轻量甜品”——我们下一章见。