很多 Java 团队还在用”类路径(classpath)”一把梭:把所有 jar 丢进去,能跑就行。但类路径有三个老毛病——同名类冲突、内部 API 被随意反射调用、依赖关系全靠约定。JPMS(Java Platform Module System,Java 平台模块系统)从 JDK 9 引入,用 module-info.java 把”强封装”和”显式依赖”钉死在编译期和运行期,让大型项目的边界真正清晰。本文用可复制的示例,演示如何把一个普通 Maven 项目改造成模块化应用。
一、类路径的痛点:为什么需要模块系统
在 classpath 模式下,只要两个 jar 里出现同名全限定类,JVM 就按加载顺序”先到先得”,后面那个被静默遮蔽。更危险的是,任何代码都能 setAccessible(true) 穿透私有成员,连 sun.misc.Unsafe 这类 JDK 内部 API 都被滥用。JDK 9 之后这些内部 API 默认被强封装,Spring Boot 3 升级踩坑里大量报错就源于此——老代码直接访问 jdk.internal.* 会被运行时拒绝。
模块系统把”你能看见什么、依赖谁”从文档约定提升为编译器强制约束:未显式导出的包,外部模块连 public 类型都访问不到,反射也无能为力。这把边界写进了字节码,而不是人心。
一个真实的代价场景:某支付服务升级 JDK 后,凌晨批量对账突然报 NoClassDefFoundError。排查发现是两个中间件各自打包了一份不同版本的同一工具类,classpath 顺序在容器重启后翻转,旧版本遮蔽了新版本。如果当初用模块系统显式 requires,编译器在构建期就会直接拒绝这种歧义,而不是让故障在运行期随机爆发。这就是”把错误左移”的价值——能在本地编译报错的问题,绝不留到线上凌晨告警。
二之一、可读性(readability)与可访问性(accessibility)
JPMS 有两个常被忽略但关键的概念。可读性指”我 requires 的模块,是否它自己所需的模块也都可见”——加了 transitive 的依赖会自动传导给下游,省去每个消费方重复声明。可访问性则是运行期访问控制:即便类型可见,若包未 opens,深层反射依旧被拒。理解这两层,你就能解释为什么很多老框架在模块化项目里需要额外 --add-opens:它不是”没导出”,而是”导出了但没对反射开放”。
二、module-info.java 核心语法
每个模块由根目录的 module-info.java 声明。下面是最常用的指令对照:
| 指令 | 作用 |
|---|---|
| requires | 声明本模块依赖的其他模块;加 transitive 表示传递导出 |
| exports | 导出包,只有被导出的包外部模块才能访问其中的 public 类型 |
| opens | 开放包给反射;opens pkg to M 可限定仅对模块 M 开放 |
| provides…with | 声明 SPI 实现,配合 uses 实现运行时服务发现 |
| uses | 声明本模块消费的服务接口,由 ServiceLoader 加载 |
一个最小可运行的模块声明如下:
module com.example.order {
requires transitive com.example.common;
requires java.sql;
exports com.example.order.api;
opens com.example.order.dto to com.fasterxml.jackson.databind;
provides com.example.spi.NotificationService
with com.example.order.SmsNotification;
}
三、实战:把一个普通项目改造成模块化
假设我们有两个模块:common 提供工具类,order 订单服务依赖它和 commons-lang3。先补上模块声明:
// 模块 A:common
module com.example.common {
exports com.example.common.util;
}
// 模块 B:order(依赖 common + 第三方 jar)
module com.example.order {
requires com.example.common;
requires org.apache.commons.lang3; // 自动模块名由 Manifest 推断
exports com.example.order.api;
}
编译与运行改用模块路径(module path),不再是扁平的 classpath:
# 编译两个模块
javac -d out/common $(find common/src -name '*.java')
javac -d out/order --module-path out/common $(find order/src -name '*.java')
# 运行(用 -m 指定模块/主类)
java --module-path out/common:out/order \
-m com.example.order/com.example.order.Main
如果第三方 jar 没有 Automatic-Module-Name,JDK 会按文件名推导一个”自动模块名”(如 commons.lang3),编译期能用,但建议让对方在 Manifest 里显式声明,避免名字带版本号导致迁移抖动。
用构建工具时不用手写 javac。Maven 多模块项目把每个子模块的 <packaging>jar</packaging> 照常,module-info.java 放进 src/main/java 根目录即可,moditect 插件还能帮你给没有模块声明的第三方库自动生成补丁模块;Gradle 则可开启 org.gradle.java.compile-module 实验特性,由它自动推导模块路径。实践中建议”自顶向下”渐进:先把最稳定的底层模块模块化,应用入口最后改,避免一上来全员改造的连锁编译失败。
四、强封装与反射边界
JPMS 的”强封装”意味着:未 exports/opens 的包,外部模块连 public 类型都看不到,反射也拿不到。很多框架靠反射注入字段,所以 Spring、Jackson 这类需要你用 opens ... to 指定模块,而不是一股脑 opens 给所有人。这和 Java 虚拟线程 一样,都属于 JDK 现代化、让默认更安全的重要一步。
强封装还顺手解决了”JDK 内部 API 被偷用”的历史包袱。过去不少库直接调用 jdk.internal.misc 或 sun.* 拿性能优势,JDK 9 之后这些包默认不可读,升级时集中爆雷。在模块化项目里,这类越界在编译期就会被标红,逼迫你改用标准 API(如用 VarHandle 替代 Unsafe 的字段操作),长期看反而降低了版本升级的恐惧。这也正是 Spring Boot 3 升级 时最该提前梳理的一类风险。
// 只对需要的框架模块开放反射,缩小攻击面
opens com.example.order.dto to com.fasterxml.jackson.databind,
org.springframework.core;
五、服务化模块:provides/uses 实现 SPI
传统 SPI 靠 META-INF/services 文件做服务注册,JPMS 把它收编进模块声明,省去易漏的文件:
// 消费方:声明要消费的服务接口
module com.example.app {
uses com.example.spi.NotificationService;
}
// 提供方:声明实现
module com.example.sms {
provides com.example.spi.NotificationService
with com.example.sms.SmsImpl;
}
// 运行时加载(无需 classpath 扫描)
ServiceLoader.load(NotificationService.class)
.findFirst()
.ifPresent(s -> s.send("订单已创建"));
六、生产落地:jlink 裁剪与 jpackage 打包
模块化最大的红利是 jlink:只把真正用到的 JDK 模块拼成最小运行时镜像,体积从 200MB+ 降到几十 MB,特别适合容器镜像瘦身。涉及本地调用的场景可参考 Java FFM API 实战,它同样受益于一处明确的模块边界。
在我们的订单服务上,全量 JDK 镜像是 198MB,jlink 后只保留 java.base、java.sql、java.logging 等用到的模块,运行时降到 41MB。配合多阶段 Dockerfile,最终业务镜像从 320MB 缩到 78MB,冷启动拉取时间近乎减半。更妙的是,被裁剪掉的模块(如 java.desktop)连类都不存在,等于从运行时层面消灭了一整类攻击面,这也是很多安全基线把”最小化运行时”列为硬性要求的原因。在 CI 里把 jlink 作为发布产物的一环,每次提交都产出确定性的小镜像,比事后瘦身脚本可靠得多。
# 用 jlink 拼出最小运行时
jlink --module-path $JAVA_HOME/jmods:out \
--add-modules com.example.order \
--output dist/runtime \
--compress=2 --no-header-files --no-man-pages
# 再结合 jpackage 生成原生安装包
jpackage --runtime-image dist/runtime \
--module com.example.order/com.example.order.Main \
--name order-service --type pkg
七、迁移避坑清单
| 现象 | 原因 | 解决 |
|---|---|---|
| 拆分包(split package)冲突 | 两个模块含同名包 | 重命名包或合并为一个模块 |
| 自动模块名带版本号 | 第三方 jar 无 Automatic-Module-Name | 推动对方在 Manifest 显式声明 |
| 反射报 IllegalAccessError | 包未 opens | 加 opens ... to 框架模块 |
| 启动参数一长串 –add-exports | 强依赖 JDK 内部 API | 优先改用标准 API,必要时 –add-exports 兜底 |
八、什么时候该上 JPMS:一张决策表
| 场景 | 建议 | 理由 |
|---|---|---|
| 个人小工具 / 脚本 | 暂不上 | classpath 够用,模块化收益不抵改造成本 |
| 多团队共享的 SDK / 框架 | 强烈建议 | 强封装保护内部实现,避免被外部误用 |
| 微服务且追求小镜像 | 建议 | jlink 裁剪运行时,显著减小体积与攻击面 |
| 重度依赖反射的老系统 | 谨慎渐进 | 需逐个 opens,优先改造稳定底层 |
决策的核心就一句话:边界越需要被”保护”,模块系统的回报越高。SDK 作者最该用——你辛苦封装的内部类,不该被调用方一句反射扒光;业务应用则可以”底层先模块、入口后模块”地滚动改造,不必一次性all-in。别被”JDK 9 就出了”的错觉吓退,它今天生态已经成熟,构建工具、主流框架都给出了平滑路径。
九、小结
JPMS 不是银弹,小项目上 classpath 依然省心;但当依赖膨胀、团队变大,强封装和显式依赖能显著降低”能编译却运行期爆炸”的概率。它和 JVM 调优、Full GC 排查 共同构成 Java 工程化的基本功。从今天给新模块写上 module-info.java 开始,让边界自己说话。




