尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

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

第14章:JDK模块系统(JPMS)基础与迁移策略---Java模块化迁移实战指南 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.bindJAXB、javax.activation、javax.annotation等被移出 JDK变成可选模块或彻底移除。sun.misc.*和com.sun.*内部包被强封装——反射访问直接报IllegalAccessError。类路径 vs 模块路径的兼容性深坑JDK 9 允许类路径classpath和模块路径module path共存但两者的交互规则极其微妙——类路径上的代码处于未命名模块Unnamed Module它的行为边界与命名模块完全不同。“拆分包”Split Package问题同一个包里的类分散在两个 jar 中——JDK 8 下编译和运行都 OKJDK 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.activationJDK 11 起移除单独加 javax.activation 依赖javax.annotation.*JDK 11 起移除加 javax.annotation-api 依赖sun.misc.UnsafeJDK 9 强封装--add-opens java.base/jdk.internal.miscALL-UNNAMEDcom.sun.rowset.*JDK 9 强封装用 javax.sql.rowset 标准 APIjdk.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.baserequiresjava.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 Packagejar-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实现展示requiresexportsprovides...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)-ab;}}// mainapp/module-info.javamodulemainapp{requirescalculator.api;usescalculator.api.CalculatorProvider;}// mainapp/mainapp/Main.javapackagemainapp;importcalculator.api.*;importjava.util.ServiceLoader;publicclassMain{publicstaticvoidmain(String[]args){ServiceLoaderCalculatorProviderloaderServiceLoader.load(CalculatorProvider.class);CalculatorProviderproviderloader.findFirst().orElseThrow();Calculatorcalcprovider.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 字段FieldfString.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-opensjava-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:classloadinfo - [ ] -XX:PrintGCDetails → 改为 -Xlog:gc* - [ ] -XX:MaxPermSize → 改为 -XX:MaxMetaspaceSize ### 3. --add-opens 需求清单 常见的受封装的 JDK 内部包及配套参数 - [ ] java.lang → --add-opens java.base/java.langALL-UNNAMED - [ ] java.util → --add-opens java.base/java.utilALL-UNNAMED - [ ] sun.misc → --add-opens java.base/sun.miscALL-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/*.jar21echoecho 模块依赖摘要 jdeps --module-path lib/ --list-deps target/*.jar21echoecho 建议的 --add-opens # 自动生成建议来自 jdeps 输出jdeps --jdk-internals target/*.jar21|grepsuggested可能遇到的坑Maven/Gradle 的模块路径编译大多数 Maven 项目默认不生成module-info.javaGradle 的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 --filexxx.jar查看。--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编译成功echoecho 2. SPI 服务加载 java--module-path out-mmainapp/mainapp.Main# 预期: 3 5 8echoecho 3. 反射拦截验证 java--module-path out-mreflectapp/reflectapp.ReflectAttack21|grep-EException|成功# 预期: InaccessibleObjectExceptionechoecho 4. jdeps 分析 jdeps --jdk-internals out/reflectapp/reflectapp/*.class21|head-104. 项目总结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 适用场景公共基础库/框架写一个干净的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 的包——但反过来不行命名模块不能读未命名模块的类——很多找不到类的问题源于此opensvsexportsexports只允许编译期访问和普通反射访问 public 成员opens允许深层反射private protected——Hibernate/Jackson 的实体类需要用opensjlink的限制只能链接命名模块——类路径上的 jar自动模块不能用于 jlink4.4 常见踩坑经验案例 1Log4j2 的拆分包导致模块化失败某项目将 Log4j2 从类路径迁移到模块路径启动报java.lang.module.ResolutionException。根因log4j-api和log4j-core两个 jar 共享了同一个包org.apache.logging.log4j拆分包——模块路径不允许。修复升级到 Log4j 2.14已将共享包拆分为各自的子包。案例 2Spring Boot 的类路径启动 vs 模块路径启动某团队尝试用模块路径--module-path启动 Spring Boot 应用启动失败——报找不到org.springframework.boot.SpringApplication。根因Spring Boot 的 jar 虽然包含module-info.class但设计上仍然是类路径优先——官方推荐用类路径或直接java -jar启动。修复保持类路径启动方式仅对内部的公共库做模块化。案例 3javax.annotation.Generated被删除导致生成代码编译失败升级 JDK 11 后所有包含javax.annotation.Generated注解的自动生成代码编译失败。根因javax.annotation模块在 JDK 11 中被移除——Generated注解随之消失。修复将自动生成的代码改用javax.annotation.processing.GeneratedJDK 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的 Javadoctransitive ↔ 传递性依赖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 实战修炼与源码剖析
返回列表