JPMS 实战:用 Java 模块系统收敛依赖与强封装

很多 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 开始,让边界自己说话。

上一篇 Bun 实战:用更快的 JavaScript 运行时提速 Node 项目
下一篇 Meilisearch 实战:用 Rust 打造毫秒级即时搜索
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]