1. 项目背景
业务场景:某企业内部框架团队维护着一个"通用工具包"(common-utils.jar),该 jar 深度依赖了 sun.misc.Unsafe、com.sun.rowset.*、javax.xml.bind.* 等 JDK 内部 API。框架被全公司 80+ 个微服务使用,已经稳定运行了 5 年。当公司决定将 JDK 从 8 升级到 17 时,CI 流水线炸出了一片红色——所有微服务的启动日志里都充斥着 java.lang.IllegalAccessError 和 java.lang.NoClassDefFoundError。
痛点:
- JDK 内部 API 的"大清洗":Java 9 开始,
javax.xml.bind(JAXB)、javax.activation、javax.annotation等被移出 JDK(变成可选模块或彻底移除)。sun.misc.*和com.sun.*内部包被强封装——反射访问直接报IllegalAccessError。 - 类路径 vs 模块路径的兼容性深坑:JDK 9+ 允许类路径(classpath)和模块路径(module path)共存,但两者的交互规则极其微妙——类路径上的代码处于"未命名模块"(Unnamed Module),它的行为边界与命名模块完全不同。
- "拆分包"(Split Package)问题:同一个包里的类分散在两个 jar 中——JDK 8 下编译和运行都 OK,JDK 9+ 模块路径下直接拒绝加载。
本章从 module-info.java 的最小声明出发,理解 requires、exports、opens、provides...with 四大指令,最后把第 3 章那个"胖 JAR + 反射"的示例改造成最小模块化应用,并输出一份 JDK 8→17 迁移 checklist。
2. 项目设计
(CI 全红,小胖的 IDE 里几百个红色波浪线——javax.xml.bind 突然"不存在"了。)
小胖:大师,我的 import javax.xml.bind.JAXBContext; 怎么红了?JDK 8 还好好的,升级 17 就说"找不到类"?!难道 JDK 的 XML 解析能力被删了?
大师(看了一眼代码):不是删了,是"搬家"了。Java 9 开始,JDK 进行了有史以来最大的一次"瘦身"——把那些不是 Java SE 核心的 API 从基础模块中移出。JAXB、JAX-WS、JavaBeans Activation Framework 等被标记为"待删除"(在 JDK 11 正式移除)。
清单如下:
| 被移除/封装的 JDK 内部区域 | 影响 | 替换方案 |
|---|---|---|
javax.xml.bind (JAXB) |
JDK 11 起移除 | 单独加 jaxb-runtime 依赖 |
javax.activation |
JDK 11 起移除 | 单独加 javax.activation 依赖 |
javax.annotation.* |
JDK 11 起移除 | 加 javax.annotation-api 依赖 |
sun.misc.Unsafe |
JDK 9+ 强封装 | --add-opens java.base/jdk.internal.misc=ALL-UNNAMED |
com.sun.rowset.* |
JDK 9+ 强封装 | 用 javax.sql.rowset 标准 API |
jdk.internal.reflect.* |
JDK 9+ 强封装 | 用 java.lang.invoke 或 java.lang.reflect |
技术映射:JDK 8 的内部 API ↔ 老小区的地下室(居民可以随便用,但实际上产权不归属主);JDK 9+ ↔ 新物业把地下室封了(要么通过正规渠道付费即引用外部 jar,要么跟物业申请=加 --add-opens)。
小胖:等一下,什么叫"强封装"?不就是以前能用反射,现在不能了吗?
大师:不仅仅是反射。JPMS 的核心思想是——一个 Java 库不仅要声明"我依赖什么"(requires),还要声明"我对外提供什么"(exports)。这就像一个公司:每个部门就是一个模块——财务部需要依赖人力部(requires),但它不会把自己内部的工资明细随便给其他部门看(不 exports 的内部包)。
// module-info.java —— 模块的"身份声明"
module com.example.myservice { // 模块名
requires java.base; // 依赖(始终隐式依赖 java.base)
requires java.logging; // 显式依赖
requires spring.boot; // 依赖 Spring Boot
exports com.example.myservice.api; // 对外暴露 API 包
exports com.example.myservice.dto; // 对外暴露 DTO
opens com.example.myservice.entity to hibernate.core; // 只对 Hibernate 开放反射
provides com.example.spi.Plugin // 提供服务
with com.example.myservice.PluginImpl;
}
四大指令详解:
| 指令 | 作用 | 类比 |
|---|---|---|
requires |
声明依赖另一个模块 | 我的部门需要另一个部门的协作 |
exports |
对外暴露某些包中的public类 | 对外公开的"服务窗口" |
opens |
允许反射访问某些包(含private成员) | 允许特定单位"进场检查"内部设施 |
provides...with |
服务加载机制(SPI) | 在招聘网站上声明"我们有这个岗位" |
技术映射:exports ↔ 餐厅的"对外菜单"(客人只能点菜单上的菜),opens ↔ 给卫生局开了"后厨参观权"(可以看内部操作但只能看不能改),requires ↔ 食材采购合同的供应商名单。
小白:但我听说很多项目根本不用 module-info.java,也跑得好好的——为什么?是不是不用学 JPMS?
大师:因为那些项目跑在类路径(classpath)上,而不是模块路径(module path)上。JDK 9+ 为了兼容历史代码,做了精心设计:
- 模块路径上的代码:被当作"命名模块"(Named Module),受 JPMS 规则约束——必须声明
module-info.java。 - 类路径上的代码:被归入"未命名模块"(Unnamed Module)——这个"模块"可以读所有命名模块 exports 的包,但它的内部包不暴露给其他命名模块。
这就是为什么不写 module-info.java 也可以跑——你用的是类路径,JVM 把你放在"特权区"(未命名模块),但也意味着你的代码对其他命名模块是"隐形的"。
但这有一个重要的坑——"拆分包"(Split Package):
jar-a/sun/foo/Util.class # 同一个包在 jar-a 和 jar-b 中都存在
jar-b/sun/foo/Helper.class # JDK 9+ 模块路径上 → 拒绝加载
在类路径上两个 jar 可以和平共存(classpath 就是一条线,类加载顺序决定谁被用到)。但在模块路径上——每个模块必须独占自己的包,不能和别的模块"分享"同一个包。
技术映射:类路径 ↔ 大集市摆摊(随便摆,挨在一起也行),模块路径 ↔ 商场的固定店铺(每家店铺必须有独立门牌号,不能两个人共用一个铺位)。
3. 项目实战
3.1 环境准备
| 组件 | 版本 | 用途 |
|---|---|---|
| JDK | OpenJDK 21 | 支持完整 JPMS |
| 构建工具 | 仅 JDK 命令(javac/jar/java) | 展示模块化编译流程 |
| 可选 | Maven/Gradle 3.6+ | 自动化模块化构建 |
3.2 分步实现
步骤一:写一个最简模块化应用
目标:创建两个模块——calculator.api(接口)和 calculator.impl(实现),展示 requires + exports + provides...with。
目录结构:
module-demo/
├── calculator.api/
│ ├── module-info.java
│ └── calculator/api/
│ ├── Calculator.java (接口)
│ └── CalculatorProvider.java (SPI接口)
├── calculator.impl/
│ ├── module-info.java
│ └── calculator/impl/
│ └── CalculatorImpl.java (实现)
└── mainapp/
├── module-info.java
└── mainapp/
└── Main.java (入口)
// calculator.api/module-info.java
module calculator.api {
exports calculator.api; // 对外暴露接口和 SPI
uses calculator.api.CalculatorProvider; // 声明"我要用这个 SPI 服务"
}
// calculator.api/calculator/api/Calculator.java
package calculator.api;
public interface Calculator {
int add(int a, int b);
}
// calculator.api/calculator/api/CalculatorProvider.java
package calculator.api;
// calculator.impl/module-info.java
module calculator.impl {
requires calculator.api;
provides calculator.api.CalculatorProvider
with calculator.impl.CalculatorImpl;
}
// calculator.impl/calculator/impl/CalculatorImpl.java
package calculator.impl;
import calculator.api.*;
public class CalculatorImpl implements CalculatorProvider {
public Calculator create() { return (a, b) -> a + b; }
}
// mainapp/module-info.java
module mainapp {
requires calculator.api;
uses calculator.api.CalculatorProvider;
}
// mainapp/mainapp/Main.java
package mainapp;
import calculator.api.*;
import java.util.ServiceLoader;
public class Main {
public static void main(String[] args) {
ServiceLoader<CalculatorProvider> loader = ServiceLoader.load(CalculatorProvider.class);
CalculatorProvider provider = loader.findFirst().orElseThrow();
Calculator calc = provider.create();
System.out.println("3 + 5 = " + calc.add(3, 5));
}
}
编译与运行:
# 编译各模块(模块路径编译)
javac -d out/calculator.api \
calculator.api/module-info.java calculator.api/calculator/api/*.java
javac --module-path out \
-d out/calculator.impl \
calculator.impl/module-info.java calculator.impl/calculator/impl/*.java
javac --module-path out \
-d out/mainapp \
mainapp/module-info.java mainapp/mainapp/*.java
# 运行
java --module-path out -m mainapp/mainapp.Main
# 输出: 3 + 5 = 8
步骤二:演示模块封装下的非法反射
目标:证明模块路径上不能对非 opens 的包做反射。
// module-info.java (反射模块)
module reflectapp {
requires java.base; // 隐式
// 没有 opens——不从任何模块获得反射权限
}
// reflectapp/ReflectAttack.java
package reflectapp;
import java.lang.reflect.Field;
public class ReflectAttack {
public static void main(String[] args) throws Exception {
// 尝试反射访问 String 的 private value 字段
Field f = String.class.getDeclaredField("value");
f.setAccessible(true); // ← 抛出 InaccessibleObjectException!
System.out.println("访问成功!");
}
}
# 模块路径编译
javac -d out/reflectapp reflectapp/module-info.java reflectapp/*.java
# 模块路径运行 → 反射被拦截
java --module-path out -m reflectapp/reflectapp.ReflectAttack
# 输出: InaccessibleObjectException: Unable to make field private final
# byte[] java.lang.String.value accessible
# 对比:类路径运行 → 反射成功(未命名模块有特权,但仍需 --add-opens)
java -cp out/reflectapp reflectapp.ReflectAttack
# JDK 17+ 同样失败——即使类路径也需要 --add-opens
步骤三:JDK 8 → 17 迁移 checklist 实操
## JDK 8 → 17 迁移 Checklist
### 1. 依赖检查
- [ ] `javax.xml.bind` → 添加 `jakarta.xml.bind-api` + `jaxb-runtime`
- [ ] `javax.activation` → 添加 `jakarta.activation-api`
- [ ] `javax.annotation` → 添加 `jakarta.annotation-api` 或 `com.google.code.findbugs:jsr305`
- [ ] `sun.misc.Unsafe` → 评估改用 `VarHandle` / 加 `--add-opens`
- [ ] `sun.reflect.Reflection` → 改用 `java.lang.StackWalker` (JDK 9+)
### 2. JVM 参数检查
- [ ] `-verbose:class` → 改为 `-Xlog:class+load=info`
- [ ] `-XX:+PrintGCDetails` → 改为 `-Xlog:gc*`
- [ ] `-XX:MaxPermSize` → 改为 `-XX:MaxMetaspaceSize`
### 3. --add-opens 需求清单
常见的受封装的 JDK 内部包及配套参数:
- [ ] `java.lang` → `--add-opens java.base/java.lang=ALL-UNNAMED`
- [ ] `java.util` → `--add-opens java.base/java.util=ALL-UNNAMED`
- [ ] `sun.misc` → `--add-opens java.base/sun.misc=ALL-UNNAMED`
步骤四:用 jdeps 分析项目依赖的内部 API
# jdeps 可以扫描 jar/war/class 找出对 JDK 内部 API 的依赖
jdeps --jdk-internals target/myapp.jar
# 输出示例:
# myapp.jar -> jdk.unsupported
# com.example.MyService -> sun.misc.Unsafe jdk.unsupported
# -> 警告: JDK internal API (jdk.unsupported)
# 建议替换: java.lang.invoke.VarHandle (JDK 9+)
# 生成模块依赖图
jdeps --module-path lib/ -s target/myapp.jar
# 输出模块依赖摘要
# 完整的迁移前分析命令
echo "=== 内部 API 依赖分析 ==="
jdeps --jdk-internals --multi-release 17 target/*.jar 2>&1
echo ""
echo "=== 模块依赖摘要 ==="
jdeps --module-path lib/ --list-deps target/*.jar 2>&1
echo ""
echo "=== 建议的 --add-opens ==="
# 自动生成建议(来自 jdeps 输出)
jdeps --jdk-internals target/*.jar 2>&1 | grep "suggested"
可能遇到的坑:
- Maven/Gradle 的模块路径编译:大多数 Maven 项目默认不生成
module-info.java,Gradle 的java-library插件也默认不开启 JPMS。如果项目已经有module-info.java,确保maven-compiler-plugin版本 ≥ 3.8。 - Lombok + JPMS 的死角:Lombok 通过注解处理器(APT)修改 AST,而不是编译后修改字节码——在模块路径上 APT 的行为可能与类路径不同。确保 Lombok 版本 ≥ 1.18.22 且在 module-info 中声明。
- 自动模块(Automatic Module)的命名:类路径上的 jar(无
module-info.class)被当作"自动模块"——模块名从 jar 文件名推断(如guava-30.1.jar→guava)。但文件名中的-、版本号可能导致非法模块名。用jar --describe-module --file=xxx.jar查看。 --add-exportsvs--add-opens混用:--add-exports只让命名模块能访问目标包的 public 类;--add-opens允许反射(含 private 成员)。大多数框架需要的是--add-opens。
3.3 测试验证
| 验证点 | 方法 | 预期结果 |
|---|---|---|
| requires/exports | 编译多模块应用 | 模块间可正确引用接口 |
| provides...with | 运行 ServiceLoader 加载 | CalculatorImpl 被自动发现 |
| 模块封装拦截 | 模块路径上反射 String.value | InaccessibleObjectException |
| 类路径相对宽松 | 类路径上反射(带 --add-opens) | 访问成功 |
| jdeps 检测内部 API | jdeps --jdk-internals |
列出所有对 sun.misc 的依赖 |
#!/bin/bash
echo "=== 1. 模块编译测试 ==="
javac --module-path out -d out/calculator.impl calculator.impl/module-info.java calculator.impl/calculator/impl/*.java
echo "编译成功"
echo ""
echo "=== 2. SPI 服务加载 ==="
java --module-path out -m mainapp/mainapp.Main
# 预期: 3 + 5 = 8
echo ""
echo "=== 3. 反射拦截验证 ==="
java --module-path out -m reflectapp/reflectapp.ReflectAttack 2>&1 | grep -E "Exception|成功"
# 预期: InaccessibleObjectException
echo ""
echo "=== 4. jdeps 分析 ==="
jdeps --jdk-internals out/reflectapp/reflectapp/*.class 2>&1 | head -10
4. 项目总结
4.1 优点与缺点
| 维度 | 优点 | 缺点 |
|---|---|---|
| 强封装 | 杜绝了对 JDK 内部 API 的随意依赖——减少版本升级时的断裂风险 | 历史代码的迁移成本高——大量 --add-opens 参数维护负担 |
| 显式依赖 | module-info.java 让依赖关系显式化,编译期就能发现缺失 |
增加了编译配置的复杂度(模块路径 vs 类路径的不同构建方式) |
| 服务加载 | provides...with 标准化了 SPI 机制,编译期可被 jlink 裁剪 |
与传统的 META-INF/services 机制不完全兼容 |
| 精简 JDK | jlink 可以裁剪出仅含所需模块的"袖珍 JRE"——适合容器和 IoT 场景 |
需要事先了解所有依赖的模块列表 |
| 更好的安全性 | 模块封装堵住了反射攻击的入口(如序列化漏洞的反射利用) | 对依赖框架(如 Spring, Hibernate)的 "反射魔法" 有影响——框架需要适配 |
| 对比维度 | 无 module-info (类路径) | 有 module-info (模块路径) |
|---|---|---|
| 编译要求 | 无额外文件 | 需要 module-info.java |
| 反射控制 | 靠 --add-opens | exports/opens 精确控制 |
| JDK 内部 API | 完全自由访问 | 编译期就报错 |
| jar 大小 | 无额外元数据 | META-INF/module-info.class 增加约 100 字节 |
| 适用项目 | 内部单体、不升级 JDK | 公共库、长远维护的项目 |
4.2 适用场景
- 公共基础库/框架:写一个"干净"的
module-info.java定义公共 API,明确对内和对外的边界。 - 容器化小镜像:用
jlink --add-modules裁剪出一个 30MB 的袖珍 JRE,只含必要模块。 - 多模块的大型单体应用:通过模块边界强制执行依赖规则——禁止"反向依赖"和"循环依赖"。
- 安全敏感场景:金融、政府等对"反射攻击"敏感的项目,利用强封装限制反射面。
- JDK 版本升级前置分析:用
jdeps提前扫描依赖,生成升级前的内部 API 使用清单。
不适用场景:
- 纯内部系统、永不升级 JDK 的项目——模块化收益几乎为零。
- 微服务架构中每个服务都独立 jar——模块化的"依赖隔离"价值被容器化替代。
- 重度依赖反射的框架(字节码增强、AOP)且版本未适配 JPMS——硬上加
--add-opens可以跑但不如等框架适配。
4.3 注意事项
| 类型 | 详细说明 |
|---|---|
| 自动模块命名 | jar 文件名中的 -、. 会被转为 _,版本号被移除——my-app-1.2.jar → 模块名 my.app。但规则在不同 JDK 版本略有差异 |
| unnamed module 的特权 | 类路径上的未命名模块可以读所有命名模块 exports 的包——但反过来不行!命名模块不能读未命名模块的类——很多"找不到类"的问题源于此 |
opens vs exports |
exports 只允许编译期访问和普通反射访问 public 成员;opens 允许深层反射(private + protected)——Hibernate/Jackson 的实体类需要用 opens |
jlink 的限制 |
只能链接"命名模块"——类路径上的 jar(自动模块)不能用于 jlink |
4.4 常见踩坑经验
案例 1:Log4j2 的"拆分包"导致模块化失败
某项目将 Log4j2 从类路径迁移到模块路径,启动报 java.lang.module.ResolutionException。根因:log4j-api 和 log4j-core 两个 jar 共享了同一个包 org.apache.logging.log4j(拆分包)——模块路径不允许。修复:升级到 Log4j 2.14+(已将共享包拆分为各自的子包)。
案例 2:Spring Boot 的类路径启动 vs 模块路径启动
某团队尝试用模块路径(--module-path)启动 Spring Boot 应用,启动失败——报找不到 org.springframework.boot.SpringApplication。根因:Spring Boot 的 jar 虽然包含 module-info.class,但设计上仍然是类路径优先——官方推荐用类路径或直接 java -jar 启动。修复:保持类路径启动方式,仅对内部的公共库做模块化。
案例 3:javax.annotation.Generated 被删除导致生成代码编译失败
升级 JDK 11 后,所有包含 @javax.annotation.Generated 注解的自动生成代码编译失败。根因:javax.annotation 模块在 JDK 11 中被移除——@Generated 注解随之消失。修复:将自动生成的代码改用 @javax.annotation.processing.Generated(JDK 9+)或引入 jakarta.annotation-api 依赖。
4.5 思考题
-
进阶题:
jdeps生成的依赖分析报告中区分了"requires transitive"和"requires static"两种依赖。请解释两者区别:如果不写transitive,依赖链下游的模块是否能访问上游模块 exports 的包?请设计一个三模块实验来验证。 -
实战题:你的团队有一个 10 万行的单体应用,准备从 JDK 8 升级到 JDK 21。
jdeps --jdk-internals报告显示了 47 处sun.misc.*的内部 API 调用。请设计一个分阶段迁移策略(不要求一次性改完),并说明每一步的验证标准。
答案提示:思考题 1 答案见
java.lang.module.ModuleDescriptor.Requires.Modifier的 Javadoc(transitive ↔ 传递性依赖,static ↔ 编译时依赖运行时可选);思考题 2 答案见本章迁移 checklist + 第 11 章方法句柄替代方案。
下一章预告:第 15 章将聚焦我们每天写代码最常用也最容易踩坑的部分——ArrayList/HashMap/ConcurrentHashMap 底层原理与选型,并针对四类高频场景给出选型表。
延伸阅读与资源
Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
Nacos 3.x 注册配置中心实战修炼:从入门到源码扩展
Elasticsearch从入门到进阶的实战之旅
MySQL Server 9从入门到进阶的实战之旅
RabbitMQ从入门到进阶的实战之旅:从单机到大促高可用架构
Celery 入门到进阶之路:从异步任务到自研调度平台
LangGraph 生产级实战进阶:从零到生产级Agent工作流开发
Dify 从入门到源码:LLM 应用平台实战修炼
从零到生产级:FastAPI 异步高并发、源码与 SRE 实战
实战SQLAlchemy 2.0: 从 CRUD 到生产级架构
从零打造企业级 AI 助手:LangChain RAG、Agent 与生产实战
后端工程师 AI 转型课:Ollama 私有化大模型从入门到生产
MongoDB 实战进阶与内核修炼
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化/性能调优)
Milvus向量数据库实战修炼:从 0 到 1 精通向量检索与生产落地
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号