Java 模块系统 JPMS 实战——真的有必要用吗?我的答案是"看情况"
Java 模块系统 JPMS 实战——真的有必要用吗?我的答案是"看情况"
适读人群:想了解 JPMS 但不确定是否值得投入的 Java 工程师 | 阅读时长:约12分钟 | 核心价值:JPMS 的真实应用场景评估,帮你做出正确的选择
Java 9 发布已经很多年了,但我身边用 JPMS(Java Platform Module System,也叫 Project Jigsaw)的项目真的不多。
这不是因为大家不知道,而是大部分人研究了一番之后,做出了一个理性的判断:投入产出比不高。
我也经历过这个过程。今天把我的结论和分析写出来,帮你少走弯路。
一、JPMS 想解决什么问题
Java 在 9 之前的 classpath 机制有几个已知的痛点:
1. JAR 地狱(JAR Hell):不同库依赖同一个类库的不同版本,运行时可能加载到错误的版本,出现莫名其妙的 bug。
2. 没有真正的封装:你用 public 修饰的类,classpath 上所有人都能访问。你想要"只在模块内部公开,对外隐藏",以前做不到。
3. JDK 本身太臃肿:整个 JDK 打包在一起,你只是个简单的 CLI 工具,但带着完整的 JDK,包括 Swing、AWT、XML 处理器……
JPMS 的解决方案:
- 每个模块通过
module-info.java显式声明自己的依赖和公开的 API - 模块之间的访问受到严格控制
- 可以用
jlink打包只包含需要模块的自定义 JRE
二、基础语法
// module-info.java,放在 src 根目录
module com.example.myapp {
// 声明依赖的模块
requires spring.core;
requires spring.context;
requires com.fasterxml.jackson.databind;
// 声明对外公开的包(只有这些包里的 public 类才能被外部模块访问)
exports com.example.myapp.api;
exports com.example.myapp.dto;
// 有些包只对特定模块开放
exports com.example.myapp.internal to com.example.test;
// 对反射开放(Spring 需要反射访问你的类)
opens com.example.myapp.config to spring.core;
opens com.example.myapp.domain to com.fasterxml.jackson.databind;
}理论上,这解决了"谁能访问谁"的问题,让包的边界更清晰。
三、实际上踩的坑
我在一个项目里认真尝试过完整地使用 JPMS,以下是我遇到的问题:
问题1:第三方库支持参差不齐
很多流行的库还没有完整的 JPMS 支持(截至我写这篇文章时)。你加了 module-info.java,然后发现某个你用的库的 JAR 没有 module-info.java,它会被作为"自动模块"处理,但这种处理方式有很多隐患。
Spring 框架的 JPMS 支持是逐渐完善的,但如果你的项目依赖了很多其他库,可能会有各种兼容性问题。
问题2:反射大量失效
Spring、Hibernate、Jackson 这些框架大量使用反射。一旦用了 JPMS,这些反射访问都需要你显式用 opens 声明允许,不然运行时会报 InaccessibleObjectException。
这意味着你要把所有 Spring 需要扫描的包都 opens 给 spring.core,所有 Jackson 要序列化的包都 opens 给 Jackson……
module com.example.myapp {
opens com.example.myapp.config to spring.core, spring.beans, spring.context;
opens com.example.myapp.controller to spring.web, spring.webmvc;
opens com.example.myapp.service to spring.beans, spring.core;
opens com.example.myapp.repository to spring.data.jpa;
opens com.example.myapp.domain to com.fasterxml.jackson.databind, hibernate.core;
// ...每加一个新包都要记得来这里声明
}这个维护成本很高,而且很容易漏。
问题3:module-info.java 需要精确的模块名
当你写 requires spring.core,这里的 spring.core 是 Spring Framework JAR 的模块名。如果某个库的模块名不规则,或者你用的版本还没正式模块化,这里就得用"自动模块名",比较混乱。
问题4:IDE 和构建工具支持
Maven 和 Gradle 对 JPMS 的支持都有,但配置比较繁琐,而且有一些已知的边缘问题。我当时在配置测试的模块路径时,花了挺长时间才弄好。
四、JPMS 真正有价值的场景
说了这么多问题,JPMS 是不是一无是处?不是,它在特定场景下是有价值的:
场景1:构建瘦 JRE(jlink)
这是 JPMS 我觉得最实用的能力:用 jlink 打包一个只包含你的应用需要的 Java 模块的自定义 JRE,不需要携带完整 JDK。
# 先找出你的应用依赖了哪些 JDK 模块
jdeps --module-path libs --print-module-deps app.jar
# 输出可能是:java.base,java.logging,java.net.http
# 打包自定义 JRE
jlink \
--module-path $JAVA_HOME/jmods \
--add-modules java.base,java.logging,java.net.http \
--output custom-jre \
--compress=2 \
--no-header-files \
--no-man-pages一个完整的 JDK 大约 300-400MB,用 jlink 打包的精简 JRE 可能只有 30-50MB。对于 Docker 镜像来说,这个差异很显著。
场景2:框架/中间件开发
如果你在做一个框架或者底层中间件,给别人用的,JPMS 让你可以精确控制哪些 API 是公开的,哪些是内部实现不应该被外部依赖。
这对框架维护者来说有价值——你可以重构内部实现,只要不改公开 API,不会影响用户。
场景3:团队纪律的技术执行
如果你的团队有明确的模块边界规定(比如 service 层不能直接访问其他 service 的 repository),JPMS 可以在编译器层面强制这个规定,而不只是靠 code review。
五、实战:用 jlink 打瘦 Docker 镜像
JPMS 的 jlink 工具我觉得是最值得掌握的实用技能,不需要给整个应用模块化,只要你的应用能运行,就能用 jdeps + jlink 打精简 JRE。
# 第一步:分析你的 fat jar 依赖了哪些 JDK 模块
jdeps \
--class-path 'libs/*' \ # 依赖的 jar
--multi-release 21 \
--ignore-missing-deps \
--print-module-deps \
app.jar
# 输出示例:
# java.base,java.logging,java.management,java.naming,java.net.http,java.security.jgss,java.sql,jdk.unsupported
# 第二步:用 jlink 打包自定义 JRE
jlink \
--module-path $JAVA_HOME/jmods \
--add-modules java.base,java.logging,java.management,java.naming,java.net.http,java.security.jgss,java.sql,jdk.unsupported \
--output /custom-jre \
--compress=2 \ # 压缩 class 文件
--no-header-files \ # 不包含头文件
--no-man-pages \ # 不包含 man 页
--strip-debug # 去掉调试信息(生产环境可选)Dockerfile 示例:
FROM eclipse-temurin:21 AS builder
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
# 分析依赖模块
RUN jar xf target/app.jar && \
jdeps --class-path 'BOOT-INF/lib/*' \
--multi-release 21 \
--ignore-missing-deps \
--print-module-deps \
target/app.jar > /tmp/modules.txt
# 打 jlink
RUN jlink \
--module-path $JAVA_HOME/jmods \
--add-modules $(cat /tmp/modules.txt) \
--output /custom-jre \
--compress=2 \
--no-header-files \
--no-man-pages
FROM debian:12-slim
COPY --from=builder /custom-jre /custom-jre
COPY --from=builder /app/target/app.jar /app/app.jar
ENTRYPOINT ["/custom-jre/bin/java", "-jar", "/app/app.jar"]我在一个 Spring Boot 3 + WebFlux 的服务上试过这个方法:
| 阶段 | 基础镜像 | 镜像大小 |
|---|---|---|
| 原始 | eclipse-temurin:21 | ~485MB |
| 换基础镜像 | eclipse-temurin:21-jre | ~192MB |
| 用 jlink 精简 JRE | debian:12-slim | ~98MB |
从485MB降到98MB,这个收益在大规模部署时非常显著。
注意:这个方案不需要你的应用代码使用 JPMS,只是利用了 jdeps 分析工具和 jlink 打包工具,两者都在标准 JDK 里自带。
六、我的实际选择
回到最初的问题:普通业务项目有必要用 JPMS 吗?
我的答案是:大多数情况下,不值得。
理由:
- 主流业务项目用的库(Spring、Hibernate、MyBatis等)对 JPMS 的支持不完整,引入 JPMS 会带来大量额外配置和潜在问题
- JPMS 能提供的"模块隔离",通过包命名规范+代码审查,基本上也能达到,虽然不是编译期强制
- 维护
module-info.java的成本,在快速迭代的业务项目里不低
值得用的情况:
- 你在做 CLI 工具或者独立应用,想通过
jlink缩减分发包大小 - 你在做框架或底层库,需要严格的 API 隔离
- 你的团队规模很大,模块边界容易被意外打破,需要编译期强制
对于大多数在做 Spring Boot 业务服务的工程师,我的建议是:了解 JPMS 是怎么工作的,但不要轻易在生产项目里强推它。 现阶段成本大于收益。
这个判断可能在几年后随着生态成熟而改变,到时候再重新评估。
下一篇写 Java 文本块和模式匹配这些 Java 14-21 的语法糖,这些比 JPMS 实用得多,用起来也简单。
