1. 项目背景

业务场景:某企业内部框架团队维护着一个"通用工具包"(common-utils.jar),该 jar 深度依赖了 sun.misc.Unsafecom.sun.rowset.*javax.xml.bind.* 等 JDK 内部 API。框架被全公司 80+ 个微服务使用,已经稳定运行了 5 年。当公司决定将 JDK 从 8 升级到 17 时,CI 流水线炸出了一片红色——所有微服务的启动日志里都充斥着 java.lang.IllegalAccessErrorjava.lang.NoClassDefFoundError

痛点:

  1. JDK 内部 API 的"大清洗":Java 9 开始,javax.xml.bind(JAXB)、javax.activationjavax.annotation 等被移出 JDK(变成可选模块或彻底移除)。sun.misc.*com.sun.* 内部包被强封装——反射访问直接报 IllegalAccessError
  2. 类路径 vs 模块路径的兼容性深坑:JDK 9+ 允许类路径(classpath)和模块路径(module path)共存,但两者的交互规则极其微妙——类路径上的代码处于"未命名模块"(Unnamed Module),它的行为边界与命名模块完全不同。
  3. "拆分包"(Split Package)问题:同一个包里的类分散在两个 jar 中——JDK 8 下编译和运行都 OK,JDK 9+ 模块路径下直接拒绝加载。

本章从 module-info.java 的最小声明出发,理解 requiresexportsopensprovides...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"

可能遇到的坑

  1. Maven/Gradle 的模块路径编译:大多数 Maven 项目默认不生成 module-info.java,Gradle 的 java-library 插件也默认不开启 JPMS。如果项目已经有 module-info.java,确保 maven-compiler-plugin 版本 ≥ 3.8。
  2. Lombok + JPMS 的死角:Lombok 通过注解处理器(APT)修改 AST,而不是编译后修改字节码——在模块路径上 APT 的行为可能与类路径不同。确保 Lombok 版本 ≥ 1.18.22 且在 module-info 中声明。
  3. 自动模块(Automatic Module)的命名:类路径上的 jar(无 module-info.class)被当作"自动模块"——模块名从 jar 文件名推断(如 guava-30.1.jarguava)。但文件名中的 -、版本号可能导致非法模块名。用 jar --describe-module --file=xxx.jar 查看。
  4. --add-exports vs --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 适用场景

  1. 公共基础库/框架:写一个"干净"的 module-info.java 定义公共 API,明确对内和对外的边界。
  2. 容器化小镜像:用 jlink --add-modules 裁剪出一个 30MB 的袖珍 JRE,只含必要模块。
  3. 多模块的大型单体应用:通过模块边界强制执行依赖规则——禁止"反向依赖"和"循环依赖"。
  4. 安全敏感场景:金融、政府等对"反射攻击"敏感的项目,利用强封装限制反射面。
  5. 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-apilog4j-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 思考题

  1. 进阶题jdeps 生成的依赖分析报告中区分了"requires transitive"和"requires static"两种依赖。请解释两者区别:如果不写 transitive,依赖链下游的模块是否能访问上游模块 exports 的包?请设计一个三模块实验来验证。

  2. 实战题:你的团队有一个 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的网络实战圣经

posted on 2026-09-22 21:13  一天不进步,就是退步  阅读(4)  评论(0)    收藏  举报