模块系统解决什么
Java 应用长期依赖 classpath 加载类,所有 jar 都在同一个大口袋里,谁依赖谁、谁暴露谁完全靠约定。类冲突、版本漂移、找不到类这些问题,往往到运行期才爆发。
JPMS(Java Platform Module System,模块系统)从 Java 9 开始引入,给代码加上了「边界」:每个模块声明自己依赖什么(requires)、向谁开放什么(exports)。违反边界的引用在编译期就会被拦下。
模块化带来两个直接收益:一是依赖关系显式化,架构边界由编译器强制,而不是靠代码评审提醒;二是配合 jlink 可以只打包运行所需的模块,产出一个精简的定制运行时。
模块是 Java 里写得最清楚的一份架构文档,因为它能被执行。
- module-info模块声明文件。
- requires依赖声明。
- exports导出控制。
- jlink精简运行时打包。
module-info.java 基础
每个模块的根目录放一个 module-info.java,它就是模块的「身份证」:声明模块名、依赖、导出、打开的服务。模块名建议沿用反向域名惯例,保证全局唯一。
module com.jiqun.cloud.order {
requires java.sql;
requires spring.context;
exports com.jiqun.cloud.order.api;
}
一旦给项目加上 module-info.java,整个项目就进入了模块化模式,classpath 上的所有依赖都必须能找到对应模块。这也是迁移时最花力气的一步。
模块化项目的入口类编译和运行都要加 `--module-path`,不再用 classpath。IDE 和新版 Maven/Gradle 已经很好地支持模块路径,手动命令行的习惯要调整。
requires 与 exports 控制
requires 声明「我需要谁」,exports 声明「我开放什么」。一个包如果没有被 exports,模块外的代码就无法引用它——内部实现细节从此真正私有。
| 指令 | 作用 |
|---|---|
| requires | 声明对另一个模块的依赖 |
| requires transitive | 传递依赖,下游自动获得 |
| exports | 导出包,对外开放 |
| exports … to | 限定导出给指定模块 |
| opens | 打开包供反射访问 |
module com.jiqun.cloud.user {
requires transitive com.jiqun.cloud.common;
exports com.jiqun.cloud.user.api;
opens com.jiqun.cloud.user.model to com.fasterxml.jackson.databind;
}
requires transitive 在门面模块里很常用:调用方依赖门面模块,就自动拿到它的传递依赖,不必层层声明。opens 是反射(如 JSON 序列化、依赖注入)需要的出口,反射无法穿透未打开的包。
服务加载与 SPI
模块系统还强化了 ServiceLoader 机制:模块可以声明 provides(提供实现)和 uses(消费服务),让「接口与实现解耦」在模块层面显式化。
// 接口模块
module com.jiqun.cloud.spi {
exports com.jiqun.cloud.spi;
}
// 实现模块
module com.jiqun.cloud.impl {
requires com.jiqun.cloud.spi;
provides com.jiqun.cloud.spi.Greeter
with com.jiqun.cloud.impl.CnGreeter;
}
// 消费方
ServiceLoader.load(Greeter.class)
.stream()
.map(ServiceLoader.Provider::get)
.forEach(g -> g.sayHello());
运行期用 `--add-modules` 把实现模块加进模块图,ServiceLoader 就能找到实现。插件化的系统用这套机制可以做到「加一个 jar 就多一个能力」,不需要改主程序代码。
既有项目模块化迁移
把老项目整体模块化是重活,业界常见的路径是「自底向上」:先给底层、无依赖的模块加 module-info,再逐层向上,直到最顶层的应用模块。每一层模块化后都能独立编译验证。
第三方的 jar 大多数已经是自动模块(Automatic Module),模块名从 Manifest 或文件名推导。遇到没有模块名的老 jar,可以用 `--add-modules` 和 `--add-exports` 临时放行,先把主线跑通。
迁移期间可以使用未命名模块兼容:部分模块化、部分留在 classpath,逐步推进。模块化不是一次性革命,而是一段可以边走边看的旅程。
jlink 精简运行时
JDK 自带一百多个模块,普通应用只用其中一小部分。jlink 根据应用的模块依赖,生成一个只包含所需模块的精简运行时,体积从几百 MB 降到几十 MB,还能配合 `--strip-debug` 进一步瘦身。
jlink --module-path out --add-modules com.jiqun.cloud.app \
--output dist/jre \
--strip-debug --no-header-files --no-man-pages \
--compress=2
生成的自定义运行时可以直接分发,目标机器不需要安装 JDK。容器镜像里放精简运行时,镜像体积显著下降,启动也会更快。
jlink 还支持 `--launcher` 生成启动脚本,用户双击即可运行,体验接近原生应用。
模块化实践清单
- 用反向域名做模块名,保证全局唯一。
- 内部实现包不导出,让私有真正私有。
- 反射需要的包用 opens 打开,别整包盲目导出。
- 门面模块用 requires transitive 传递依赖。
- 插件化场景用 provides/uses 声明服务。
- 发布时用 jlink 生成精简运行时。
模块边界画得越清楚,架构腐化得越慢。