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 —— 模块的"身份声明"modulecom.example.myservice{// 模块名requiresjava.base;// 依赖(始终隐式依赖 java.base)requiresjava.logging;// 显式依赖requiresspring.boot;// 依赖 Spring Bootexportscom.example.myservice.api;// 对外暴露 API 包exportscom.example.myservice.dto;// 对外暴露 DTOopenscom.example.myservice.entitytohibernate.core;// 只对 Hibernate 开放反射providescom.example.spi.Plugin// 提供服务withcom.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.javamodulecalculator.api{exportscalculator.api;// 对外暴露接口和 SPIusescalculator.api.CalculatorProvider;// 声明"我要用这个 SPI 服务"}// calculator.api/calculator/api/Calculator.javapackagecalculator.api;publicinterfaceCalculator{intadd(inta,intb);}// calculator.api/calculator/api/CalculatorProvider.javapackagecalculator.api;// calculator.impl/module-info.javamodulecalculator.impl{requirescalculator.api;providescalculator.api.CalculatorProviderwithcalculator.impl.CalculatorImpl;}// calculator.impl/calculator/impl/CalculatorImpl.javapackagecalculator.impl;importcalculator.api.*;publicclassCalculatorImplimplementsCalculatorProvider{publicCalculatorcreate(){return(a,b)->a+b;}}// mainapp/module-info.javamodulemainapp{requirescalculator.api;usescalculator.api.CalculatorProvider;}// mainapp/mainapp/Main.javapackagemainapp;importcalculator.api.*;importjava.util.ServiceLoader;publicclassMain{publicstaticvoidmain(String[]args){ServiceLoader<CalculatorProvider>loader=ServiceLoader.load(CalculatorProvider.class);CalculatorProviderprovider=loader.findFirst().orElseThrow();Calculatorcalc=provider.create();System.out.println("3 + 5 = "+calc.add(3,5));}}编译与运行:
# 编译各模块(模块路径编译)javac-dout/calculator.api\calculator.api/module-info.java calculator.api/calculator/api/*.java javac --module-path out\-dout/calculator.impl\calculator.impl/module-info.java calculator.impl/calculator/impl/*.java javac --module-path out\-dout/mainapp\mainapp/module-info.java mainapp/mainapp/*.java# 运行java--module-path out-mmainapp/mainapp.Main# 输出: 3 + 5 = 8步骤二:演示模块封装下的非法反射
目标:证明模块路径上不能对非opens的包做反射。
// module-info.java (反射模块)modulereflectapp{requiresjava.base;// 隐式// 没有 opens——不从任何模块获得反射权限}// reflectapp/ReflectAttack.javapackagereflectapp;importjava.lang.reflect.Field;publicclassReflectAttack{publicstaticvoidmain(String[]args)throwsException{// 尝试反射访问 String 的 private value 字段Fieldf=String.class.getDeclaredField("value");f.setAccessible(true);// ← 抛出 InaccessibleObjectException!System.out.println("访问成功!");}}# 模块路径编译javac-dout/reflectapp reflectapp/module-info.java reflectapp/*.java# 模块路径运行 → 反射被拦截java--module-path out-mreflectapp/reflectapp.ReflectAttack# 输出: InaccessibleObjectException: Unable to make field private final# byte[] java.lang.String.value accessible# 对比:类路径运行 → 反射成功(未命名模块有特权,但仍需 --add-opens)java-cpout/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/-starget/myapp.jar# 输出模块依赖摘要# 完整的迁移前分析命令echo"=== 内部 API 依赖分析 ==="jdeps --jdk-internals --multi-release17target/*.jar2>&1echo""echo"=== 模块依赖摘要 ==="jdeps --module-path lib/ --list-deps target/*.jar2>&1echo""echo"=== 建议的 --add-opens ==="# 自动生成建议(来自 jdeps 输出)jdeps --jdk-internals target/*.jar2>&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/bashecho"=== 1. 模块编译测试 ==="javac --module-path out-dout/calculator.impl calculator.impl/module-info.java calculator.impl/calculator/impl/*.javaecho"编译成功"echo""echo"=== 2. SPI 服务加载 ==="java--module-path out-mmainapp/mainapp.Main# 预期: 3 + 5 = 8echo""echo"=== 3. 反射拦截验证 ==="java--module-path out-mreflectapp/reflectapp.ReflectAttack2>&1|grep-E"Exception|成功"# 预期: InaccessibleObjectExceptionecho""echo"=== 4. jdeps 分析 ==="jdeps --jdk-internals out/reflectapp/reflectapp/*.class2>&1|head-104. 项目总结
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 的包——但反过来不行!命名模块不能读未命名模块的类——很多"找不到类"的问题源于此 |
opensvsexports | 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 底层原理与选型,并针对四类高频场景给出选型表。
延伸阅读与资源
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Redis 实战修炼与原理进阶
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
MongoDB 实战进阶与内核修炼
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析