news 2026/8/9 14:36:56

第14章:JDK模块系统(JPMS)基础与迁移策略---Java模块化迁移实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
第14章:JDK模块系统(JPMS)基础与迁移策略---Java模块化迁移实战指南

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.activationJDK 11 起移除单独加 javax.activation 依赖
javax.annotation.*JDK 11 起移除加 javax.annotation-api 依赖
sun.misc.UnsafeJDK 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 环境准备

组件版本用途
JDKOpenJDK 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"

可能遇到的坑

  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-exportsvs--add-opens混用--add-exports只让命名模块能访问目标包的 public 类;--add-opens允许反射(含 private 成员)。大多数框架需要的是--add-opens

3.3 测试验证

验证点方法预期结果
requires/exports编译多模块应用模块间可正确引用接口
provides…with运行 ServiceLoader 加载CalculatorImpl 被自动发现
模块封装拦截模块路径上反射 String.valueInaccessibleObjectException
类路径相对宽松类路径上反射(带 --add-opens)访问成功
jdeps 检测内部 APIjdeps --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-10

4. 项目总结

4.1 优点与缺点

维度优点缺点
强封装杜绝了对 JDK 内部 API 的随意依赖——减少版本升级时的断裂风险历史代码的迁移成本高——大量--add-opens参数维护负担
显式依赖module-info.java让依赖关系显式化,编译期就能发现缺失增加了编译配置的复杂度(模块路径 vs 类路径的不同构建方式)
服务加载provides...with标准化了 SPI 机制,编译期可被 jlink 裁剪与传统的META-INF/services机制不完全兼容
精简 JDKjlink可以裁剪出仅含所需模块的"袖珍 JRE"——适合容器和 IoT 场景需要事先了解所有依赖的模块列表
更好的安全性模块封装堵住了反射攻击的入口(如序列化漏洞的反射利用)对依赖框架(如 Spring, Hibernate)的 “反射魔法” 有影响——框架需要适配
对比维度无 module-info (类路径)有 module-info (模块路径)
编译要求无额外文件需要 module-info.java
反射控制靠 --add-opensexports/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 的包——但反过来不行!命名模块不能读未命名模块的类——很多"找不到类"的问题源于此
opensvsexportsexports只允许编译期访问和普通反射访问 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 底层原理与选型,并针对四类高频场景给出选型表。

延伸阅读与资源

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 实战修炼与源码剖析

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/9 14:33:21

简单三步,让老Mac重获新生:OpenCore Legacy Patcher完整指南

简单三步&#xff0c;让老Mac重获新生&#xff1a;OpenCore Legacy Patcher完整指南 【免费下载链接】OpenCore-Legacy-Patcher Experience macOS just like before 项目地址: https://gitcode.com/GitHub_Trending/op/OpenCore-Legacy-Patcher 嘿&#xff0c;朋友&…

作者头像 李华
网站建设 2026/8/9 14:30:28

毕业设计系统结构图制作指南与工具推荐

1. 毕业设计系统结构图的核心价值毕业设计系统结构图是计算机相关专业学生展示项目架构的核心文档之一。一张规范、专业的系统结构图能够清晰传达你的设计思路&#xff0c;让评审老师快速理解你的系统组成和模块关系。在实际答辩过程中&#xff0c;我发现90%的优秀毕业设计都具…

作者头像 李华
网站建设 2026/8/9 14:28:21

终极指南:如何在macOS上轻松运行Windows应用和游戏

终极指南&#xff1a;如何在macOS上轻松运行Windows应用和游戏 【免费下载链接】Whisky A modern Wine wrapper for macOS built with SwiftUI 项目地址: https://gitcode.com/gh_mirrors/wh/Whisky 你是否曾经因为某个重要软件只有Windows版本而烦恼&#xff1f;想象一…

作者头像 李华
网站建设 2026/8/9 14:23:56

解锁iOS设备新途径:applera1n激活锁绕过完整指南

解锁iOS设备新途径&#xff1a;applera1n激活锁绕过完整指南 【免费下载链接】applera1n icloud bypass for ios 15-16 项目地址: https://gitcode.com/gh_mirrors/ap/applera1n 你是否曾经面对一台被激活锁困住的iPhone&#xff0c;束手无策&#xff1f;或者购买二手设…

作者头像 李华
网站建设 2026/8/9 14:23:28

5分钟极速上手ChromaDB:从语义搜索到RAG实战

1. 项目概述&#xff1a;为什么向量数据库突然火了&#xff1f;如果你最近关注AI和LLM&#xff08;大语言模型&#xff09;的动向&#xff0c;一定对“向量数据库”这个词不陌生。它听起来很高深&#xff0c;像是只有大厂架构师才需要关心的东西。但今天&#xff0c;我想带你用…

作者头像 李华