前言最近很多读者私信我升级SpringBoot3和JDK17的时候遇到一大堆java.lang.reflect.InaccessibleObjectException报错查资料都是说要加--add-opens参数但不知道为什么要加也不知道有没有更优雅的解决方案。其实这些问题的根源都是Java 9引入的模块化系统JPMSJava Platform Module System很多开发者对这个特性了解甚少导致升级的时候踩了无数坑。JPMS是JDK历史上最大的架构变更之一彻底解决了困扰Java开发者二十多年的Jar Hell问题同时大幅提升了Java的封装性、安全性和部署灵活性是JDK17和SpringBoot3生态的基础特性。今天这篇文章就从核心原理、基础实操、生产避坑、SpringBoot3适配四个维度带你彻底搞懂JPMS让你升级JDK17再也不慌。一、为什么需要JPMS传统ClassPath的痛点在Java 9之前Java的类加载完全依赖ClassPath机制这个机制设计得非常简单但在复杂项目中会暴露出很多无法解决的痛点1. Jar Hell问题ClassPath的类加载遵循“先到先得”的原则如果项目中引入了两个不同版本的同名Jar包比如commons-lang3 3.12和3.8类加载器只会加载第一个找到的类运行期会出现各种诡异的NoSuchMethodError、ClassCastException排查成本极高。而且没有任何机制能在启动前就检测到这种冲突只能靠开发者人肉排查依赖树。2. 封装性完全失效JDK内部的很多API比如sun.misc.Unsafe、jdk.internal.misc.Unsafe本来是JDK内部使用的但是因为ClassPath没有访问控制开发者可以随意调用这些内部API导致JDK升级的时候兼容性极差很多老项目只能停留在JDK8不敢升级。3. 运行时冗余严重传统的JRE包含了所有Java标准模块的实现哪怕你的项目只用到了基础的集合和IO能力也要带上几百M的完整JRE容器化部署的时候镜像体积非常大浪费存储和带宽资源。JPMS的出现就是为了彻底解决这些痛点它把Java的代码组织粒度从Jar包提升到了“模块”给Java增加了类似OSGi的模块化能力但比OSGi更轻量、更简单是JDK原生支持的特性。二、JPMS核心概念详解1. 什么是模块模块是JPMS中代码组织的最小单元一个模块就是一组包含了module-info.java描述文件的包集合编译后会生成module-info.class文件放在Jar包的根目录下。和传统Jar包相比模块明确声明了自己的依赖、对外暴露的包、运行时开放的包等元信息JVM在启动的时候就会校验这些元信息提前发现问题。2. 核心指令详解module-info.java是模块的描述文件里面通过几个核心指令定义模块的行为 | 指令 | 作用 | 访问范围 | | --- | --- | --- | |exports 包名| 导出指定包给其他模块其他模块可以在编译期访问这个包下的public类和成员 | 编译期可见运行期反射访问非public成员会报错 | |exports 包名 to 模块1,模块2| 定向导出包只有指定的模块可以访问这个包 | 编译期定向可见 | |opens 包名| 开放指定包给其他模块其他模块可以在运行期反射访问这个包下的所有成员包括private | 运行期反射可见 | |opens 包名 to 模块1,模块2| 定向开放包只有指定的模块可以反射访问这个包 | 运行期定向反射可见 | |requires 模块名| 声明当前模块依赖的其他模块 | 依赖的模块必须存在否则启动失败 | |requires transitive 模块名| 声明传递依赖其他模块依赖当前模块时会自动继承这个依赖 | 传递给上层模块 | |uses 接口全类名| 声明当前模块使用的服务接口配合ServiceLoader使用 | 服务发现 | |provides 接口全类名 with 实现类全类名| 声明当前模块提供的服务实现 | 服务注册 |三、JPMS基础实操从零搭建模块化项目我们通过一个简单的多模块项目来演示JPMS的基础用法项目包含两个模块工具模块com.example.util和应用模块com.example.app。1. 项目结构jpms-demo ├── pom.xml # 父pom ├── com.example.util # 工具模块 │ ├── pom.xml │ └── src │ └── main │ └── java │ ├── com │ │ └── example │ │ └── util │ │ └── StringUtils.java │ └── module-info.java └── com.example.app # 应用模块 ├── pom.xml └── src └── main └── java ├── com │ └── example │ └── app │ └── Main.java └── module-info.java2. 父pom配置父pom统一管理JDK版本和插件版本需要使用支持JPMS的maven-compiler-plugin 3.8.0以上版本?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdjpms-demo/artifactId version1.0-SNAPSHOT/version packagingpom/packaging modules modulecom.example.util/module modulecom.example.app/module /modules properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId version3.11.0/version configuration release17/release /configuration /plugin /plugins /build /project3. 工具模块实现首先编写com.example.util模块的module-info.java导出工具类所在的包module com.example.util { // 导出util包给其他模块编译期访问 exports com.example.util; }然后编写工具类StringUtilspackage com.example.util; public class StringUtils { public static boolean isEmpty(String str) { return str null || str.trim().isEmpty(); } // 内部私有方法默认模块外不可访问 private static void internalCheck() { System.out.println(执行内部校验逻辑); } }4. 应用模块实现编写com.example.app模块的module-info.java声明对util模块的依赖module com.example.app { requires com.example.util; }编写启动类Main调用StringUtils的方法package com.example.app; import com.example.util.StringUtils; import java.lang.reflect.Method; public class Main { public static void main(String[] args) throws Exception { String testStr hello JPMS; System.out.println(字符串是否为空 StringUtils.isEmpty(testStr)); // 尝试反射调用私有方法没有开放包的话会报错 Method method StringUtils.class.getDeclaredMethod(internalCheck); method.setAccessible(true); method.invoke(null); } }5. 运行测试此时直接运行Main类会报错java.lang.IllegalAccessException: class com.example.app.Main (in module com.example.app) cannot access class com.example.util.StringUtils (in module com.example.util) because module com.example.util does not open com.example.util to module com.example.app。这是因为我们只exports了包没有opens包给app模块反射访问修改util模块的module-info.javamodule com.example.util { exports com.example.util; // 定向开放util包给app模块反射访问 opens com.example.util to com.example.app; }重新运行就可以看到正常输出字符串是否为空false 执行内部校验逻辑6. 传递依赖演示如果util模块依赖了Guava我们可以用requires transitive让app模块自动继承这个依赖修改util模块的module-info.javamodule com.example.util { exports com.example.util; opens com.example.util to com.example.app; // 传递依赖Guava上层模块不需要再显式依赖 requires transitive com.google.common; }此时app模块不需要再声明对Guava的依赖就可以直接使用Guava的类// Main类中直接使用Guava的工具类 System.out.println(com.google.common.base.Strings.isNullOrEmpty(testStr));四、生产环境JPMS常见踩坑指南1. JDK内部类反射访问报错这是升级JDK17最常见的报错比如java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not opens java.lang to unnamed module 12345678产生原因你的代码或者依赖的框架反射访问了JDK内部模块的类JDK的模块默认没有开放这些包给外部访问。解决方案优先升级依赖框架到最新适配JDK17的版本比如MyBatis 3.5.10、Jackson 2.15都已经原生适配JPMS不需要额外配置。如果框架暂时没有适配可以加JVM参数开放对应的包--add-opens 模块名/包名目标模块名如果是未命名模块没有module-info的项目目标模块名填ALL-UNNAMED比如--add-opens java.base/java.langALL-UNNAMED。2. 未命名模块问题很多老旧的Jar包没有module-info.class文件会被JVM自动放入未命名模块未命名模块的特性可以访问所有已命名模块的导出包已命名模块不能访问未命名模块的任何类解决方案如果你的项目是模块化项目依赖了老旧的非模块化Jar包可以加JVM参数--add-modulesALL-MODULE-PATH把所有Jar包当做模块加载或者暂时不把自己的项目改为模块化留在未命名模块中。3. jlink裁剪Runtime实战JPMS带来的最大收益之一就是可以用jlink工具自定义裁剪JRE大幅减小部署包体积。比如我们的项目只用到了java.base和java.net.http两个模块可以用如下命令生成自定义JREjlink --module-path $JAVA_HOME/jmods \ --add-modules java.base,java.net.http \ --output custom-jre \ --compress 2 \ --no-header-files \ --no-man-pages生成的custom-jre大小只有30M左右比传统JRE的200M小了85%非常适合容器化部署。配合Docker多阶段构建可以把SpringBoot镜像的体积控制在100M以内。五、SpringBoot3适配JPMS完整实战SpringBoot3已经原生支持JPMS我们可以快速搭建一个模块化的SpringBoot3项目1. 模块描述文件编写新建SpringBoot3项目在src/main/java下创建module-info.javamodule com.example.springbootjpms { // 依赖Spring核心模块 requires spring.core; requires spring.context; requires spring.boot; requires spring.boot.autoconfigure; requires spring.web; requires com.fasterxml.jackson.databind; // 开放项目包给Spring反射扫描必须配置否则Spring扫描不到Bean opens com.example.springbootjpms to spring.core, spring.context, spring.beans, spring.boot; opens com.example.springbootjpms.controller to spring.web; // 导出Controller包给Spring Web访问 exports com.example.springbootjpms.controller; }2. 业务代码编写启动类package com.example.springbootjpms; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class JpmsSpringBootApplication { public static void main(String[] args) { SpringApplication.run(JpmsSpringBootApplication.class, args); } }测试Controllerpackage com.example.springbootjpms.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; RestController public class HelloController { GetMapping(/hello) public String hello() { return Hello JPMS SpringBoot3!; } }3. 打包运行pom.xml中使用spring-boot-maven-plugin 3.0版本打包plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId version3.1.5/version executions execution goals goalrepackage/goal /goals /execution /executions /plugin运行mvn package生成Jar包直接用java -jar命令运行即可访问http://localhost:8080/hello可以正常得到返回结果。六、总结JPMS是Java生态未来的基础特性随着JDK17成为LTS版本和SpringBoot3的普及JPMS会越来越多地出现在我们的项目中。对于开发者来说新的JDK17项目优先考虑适配JPMS从一开始就做好代码封装避免Jar Hell问题。老项目升级JDK17可以先不使用模块化通过JVM参数解决反射报错逐步适配。容器化部署的项目一定要尝试用jlink裁剪Runtime大幅降低镜像体积。如果你在适配JPMS的过程中遇到任何问题欢迎在评论区留言交流。
Java模块化系统(JPMS)全指南:从核心原理到SpringBoot3生产适配避坑实战
前言最近很多读者私信我升级SpringBoot3和JDK17的时候遇到一大堆java.lang.reflect.InaccessibleObjectException报错查资料都是说要加--add-opens参数但不知道为什么要加也不知道有没有更优雅的解决方案。其实这些问题的根源都是Java 9引入的模块化系统JPMSJava Platform Module System很多开发者对这个特性了解甚少导致升级的时候踩了无数坑。JPMS是JDK历史上最大的架构变更之一彻底解决了困扰Java开发者二十多年的Jar Hell问题同时大幅提升了Java的封装性、安全性和部署灵活性是JDK17和SpringBoot3生态的基础特性。今天这篇文章就从核心原理、基础实操、生产避坑、SpringBoot3适配四个维度带你彻底搞懂JPMS让你升级JDK17再也不慌。一、为什么需要JPMS传统ClassPath的痛点在Java 9之前Java的类加载完全依赖ClassPath机制这个机制设计得非常简单但在复杂项目中会暴露出很多无法解决的痛点1. Jar Hell问题ClassPath的类加载遵循“先到先得”的原则如果项目中引入了两个不同版本的同名Jar包比如commons-lang3 3.12和3.8类加载器只会加载第一个找到的类运行期会出现各种诡异的NoSuchMethodError、ClassCastException排查成本极高。而且没有任何机制能在启动前就检测到这种冲突只能靠开发者人肉排查依赖树。2. 封装性完全失效JDK内部的很多API比如sun.misc.Unsafe、jdk.internal.misc.Unsafe本来是JDK内部使用的但是因为ClassPath没有访问控制开发者可以随意调用这些内部API导致JDK升级的时候兼容性极差很多老项目只能停留在JDK8不敢升级。3. 运行时冗余严重传统的JRE包含了所有Java标准模块的实现哪怕你的项目只用到了基础的集合和IO能力也要带上几百M的完整JRE容器化部署的时候镜像体积非常大浪费存储和带宽资源。JPMS的出现就是为了彻底解决这些痛点它把Java的代码组织粒度从Jar包提升到了“模块”给Java增加了类似OSGi的模块化能力但比OSGi更轻量、更简单是JDK原生支持的特性。二、JPMS核心概念详解1. 什么是模块模块是JPMS中代码组织的最小单元一个模块就是一组包含了module-info.java描述文件的包集合编译后会生成module-info.class文件放在Jar包的根目录下。和传统Jar包相比模块明确声明了自己的依赖、对外暴露的包、运行时开放的包等元信息JVM在启动的时候就会校验这些元信息提前发现问题。2. 核心指令详解module-info.java是模块的描述文件里面通过几个核心指令定义模块的行为 | 指令 | 作用 | 访问范围 | | --- | --- | --- | |exports 包名| 导出指定包给其他模块其他模块可以在编译期访问这个包下的public类和成员 | 编译期可见运行期反射访问非public成员会报错 | |exports 包名 to 模块1,模块2| 定向导出包只有指定的模块可以访问这个包 | 编译期定向可见 | |opens 包名| 开放指定包给其他模块其他模块可以在运行期反射访问这个包下的所有成员包括private | 运行期反射可见 | |opens 包名 to 模块1,模块2| 定向开放包只有指定的模块可以反射访问这个包 | 运行期定向反射可见 | |requires 模块名| 声明当前模块依赖的其他模块 | 依赖的模块必须存在否则启动失败 | |requires transitive 模块名| 声明传递依赖其他模块依赖当前模块时会自动继承这个依赖 | 传递给上层模块 | |uses 接口全类名| 声明当前模块使用的服务接口配合ServiceLoader使用 | 服务发现 | |provides 接口全类名 with 实现类全类名| 声明当前模块提供的服务实现 | 服务注册 |三、JPMS基础实操从零搭建模块化项目我们通过一个简单的多模块项目来演示JPMS的基础用法项目包含两个模块工具模块com.example.util和应用模块com.example.app。1. 项目结构jpms-demo ├── pom.xml # 父pom ├── com.example.util # 工具模块 │ ├── pom.xml │ └── src │ └── main │ └── java │ ├── com │ │ └── example │ │ └── util │ │ └── StringUtils.java │ └── module-info.java └── com.example.app # 应用模块 ├── pom.xml └── src └── main └── java ├── com │ └── example │ └── app │ └── Main.java └── module-info.java2. 父pom配置父pom统一管理JDK版本和插件版本需要使用支持JPMS的maven-compiler-plugin 3.8.0以上版本?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdjpms-demo/artifactId version1.0-SNAPSHOT/version packagingpom/packaging modules modulecom.example.util/module modulecom.example.app/module /modules properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId version3.11.0/version configuration release17/release /configuration /plugin /plugins /build /project3. 工具模块实现首先编写com.example.util模块的module-info.java导出工具类所在的包module com.example.util { // 导出util包给其他模块编译期访问 exports com.example.util; }然后编写工具类StringUtilspackage com.example.util; public class StringUtils { public static boolean isEmpty(String str) { return str null || str.trim().isEmpty(); } // 内部私有方法默认模块外不可访问 private static void internalCheck() { System.out.println(执行内部校验逻辑); } }4. 应用模块实现编写com.example.app模块的module-info.java声明对util模块的依赖module com.example.app { requires com.example.util; }编写启动类Main调用StringUtils的方法package com.example.app; import com.example.util.StringUtils; import java.lang.reflect.Method; public class Main { public static void main(String[] args) throws Exception { String testStr hello JPMS; System.out.println(字符串是否为空 StringUtils.isEmpty(testStr)); // 尝试反射调用私有方法没有开放包的话会报错 Method method StringUtils.class.getDeclaredMethod(internalCheck); method.setAccessible(true); method.invoke(null); } }5. 运行测试此时直接运行Main类会报错java.lang.IllegalAccessException: class com.example.app.Main (in module com.example.app) cannot access class com.example.util.StringUtils (in module com.example.util) because module com.example.util does not open com.example.util to module com.example.app。这是因为我们只exports了包没有opens包给app模块反射访问修改util模块的module-info.javamodule com.example.util { exports com.example.util; // 定向开放util包给app模块反射访问 opens com.example.util to com.example.app; }重新运行就可以看到正常输出字符串是否为空false 执行内部校验逻辑6. 传递依赖演示如果util模块依赖了Guava我们可以用requires transitive让app模块自动继承这个依赖修改util模块的module-info.javamodule com.example.util { exports com.example.util; opens com.example.util to com.example.app; // 传递依赖Guava上层模块不需要再显式依赖 requires transitive com.google.common; }此时app模块不需要再声明对Guava的依赖就可以直接使用Guava的类// Main类中直接使用Guava的工具类 System.out.println(com.google.common.base.Strings.isNullOrEmpty(testStr));四、生产环境JPMS常见踩坑指南1. JDK内部类反射访问报错这是升级JDK17最常见的报错比如java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not opens java.lang to unnamed module 12345678产生原因你的代码或者依赖的框架反射访问了JDK内部模块的类JDK的模块默认没有开放这些包给外部访问。解决方案优先升级依赖框架到最新适配JDK17的版本比如MyBatis 3.5.10、Jackson 2.15都已经原生适配JPMS不需要额外配置。如果框架暂时没有适配可以加JVM参数开放对应的包--add-opens 模块名/包名目标模块名如果是未命名模块没有module-info的项目目标模块名填ALL-UNNAMED比如--add-opens java.base/java.langALL-UNNAMED。2. 未命名模块问题很多老旧的Jar包没有module-info.class文件会被JVM自动放入未命名模块未命名模块的特性可以访问所有已命名模块的导出包已命名模块不能访问未命名模块的任何类解决方案如果你的项目是模块化项目依赖了老旧的非模块化Jar包可以加JVM参数--add-modulesALL-MODULE-PATH把所有Jar包当做模块加载或者暂时不把自己的项目改为模块化留在未命名模块中。3. jlink裁剪Runtime实战JPMS带来的最大收益之一就是可以用jlink工具自定义裁剪JRE大幅减小部署包体积。比如我们的项目只用到了java.base和java.net.http两个模块可以用如下命令生成自定义JREjlink --module-path $JAVA_HOME/jmods \ --add-modules java.base,java.net.http \ --output custom-jre \ --compress 2 \ --no-header-files \ --no-man-pages生成的custom-jre大小只有30M左右比传统JRE的200M小了85%非常适合容器化部署。配合Docker多阶段构建可以把SpringBoot镜像的体积控制在100M以内。五、SpringBoot3适配JPMS完整实战SpringBoot3已经原生支持JPMS我们可以快速搭建一个模块化的SpringBoot3项目1. 模块描述文件编写新建SpringBoot3项目在src/main/java下创建module-info.javamodule com.example.springbootjpms { // 依赖Spring核心模块 requires spring.core; requires spring.context; requires spring.boot; requires spring.boot.autoconfigure; requires spring.web; requires com.fasterxml.jackson.databind; // 开放项目包给Spring反射扫描必须配置否则Spring扫描不到Bean opens com.example.springbootjpms to spring.core, spring.context, spring.beans, spring.boot; opens com.example.springbootjpms.controller to spring.web; // 导出Controller包给Spring Web访问 exports com.example.springbootjpms.controller; }2. 业务代码编写启动类package com.example.springbootjpms; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class JpmsSpringBootApplication { public static void main(String[] args) { SpringApplication.run(JpmsSpringBootApplication.class, args); } }测试Controllerpackage com.example.springbootjpms.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; RestController public class HelloController { GetMapping(/hello) public String hello() { return Hello JPMS SpringBoot3!; } }3. 打包运行pom.xml中使用spring-boot-maven-plugin 3.0版本打包plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId version3.1.5/version executions execution goals goalrepackage/goal /goals /execution /executions /plugin运行mvn package生成Jar包直接用java -jar命令运行即可访问http://localhost:8080/hello可以正常得到返回结果。六、总结JPMS是Java生态未来的基础特性随着JDK17成为LTS版本和SpringBoot3的普及JPMS会越来越多地出现在我们的项目中。对于开发者来说新的JDK17项目优先考虑适配JPMS从一开始就做好代码封装避免Jar Hell问题。老项目升级JDK17可以先不使用模块化通过JVM参数解决反射报错逐步适配。容器化部署的项目一定要尝试用jlink裁剪Runtime大幅降低镜像体积。如果你在适配JPMS的过程中遇到任何问题欢迎在评论区留言交流。