最近把一个服务从JDK 8直接升到JDK 17,启动第一分钟就被java.lang.reflect.InaccessibleObjectException拍脸。我猜你现在搜到这,十有八九也是被这一串异常信息折腾得够呛。这篇就把这个异常彻底讲透:它为什么会冒出来、怎么从堆栈里快速定位、用哪几种方式能压下去,最后再配上几个我实际踩过的案例。
JDK 17本身不是问题,真正的问题是很多人还在用JDK 8时代的老框架和老写法,反射依然在背后访问JDK内部类,到了JDK 17这个动作就不被允许了。这个异常最适合正在做JDK版本升级、或者新项目启动就遇到反射报错的同学,看完至少能自己解决八成场景。
1. 异常本质解析:JDK 17的强封装到底是怎么回事
1.1 从一段最典型的异常堆栈说起
先看一个我在Spring Boot项目里经常碰到的原始报错:
java.lang.reflect.InaccessibleObjectException: Unable to make field private static final sun.misc.Unsafe theUnsafe accessible: module jdk.unsupported does not "opens sun.misc" to unnamed module @0x7f5f2b5e at java.base/java.lang.reflect.Field.setAccessible(Field.java:693) at io.netty.util.internal.PlatformDependent0.<clinit>(PlatformDependent0.java:89) at io.netty.util.internal.PlatformDependent.<clinit>(PlatformDependent.java:165) ...注意看第一行异常类的全限定名:java.lang.reflect.InaccessibleObjectException。它本身是RuntimeException的子类,专门在反射操作Field、Method、Constructor的setAccessible(true)时被抛出来,表示“你想访问的那个对象不属于你”。
很多人第一次看到这个异常会以为是自己代码里setAccessible(true)写错了,其实不是。就算你反射的目标代码完全合法,只要目标是JDK内部包里的非public成员,JDK 17就直接拒绝。
1.2 根本原因:JDK模块系统下的强封装
JDK 9开始引入模块化架构,原本一个大而全的rt.jar被拆成了java.base、java.sql、java.xml等一堆模块。模块系统有一个核心规则:每个模块可以声明哪些包对外“开放”(opens)。只有开放的包,才允许其他模块通过反射去访问其中的非public成员。
JDK 8时代反射几乎想干嘛就干嘛,能直接透过setAccessible(true)读到很多JDK内部的私有字段。JDK 9到JDK 16之间还有一个过渡期,很多内部包虽然默认不开放,但JVM给了--permit-illegal-access这类宽松参数,或者只是打印警告。到了JDK 17,强封装成为默认且不可绕过,非法访问直接抛异常,留给你用参数解锁的空间仅限于手动指定的--add-opens。
我一般给不太理解模块化的同事打个比方:JDK内部类像小区里的业主楼层,以前(JDK 8)任何快递员都能直接上楼(反射随意访问),JDK 9开始小区装了门禁,但保安还会偶尔放人进去并提醒一下(告警),JDK 17是直接把门禁全面启用,没在访客名单里(没有配--add-opens)的人一概不许进。
1.3 哪些框架和场景最容易触发
不是我危言耸听,只要你的项目满足以下几个条件之一,遇到这个异常的概率非常高:
- Spring Boot 2.x 项目直接跑在JDK 17上,尤其是用到CGLIB代理、
@Configuration类代理时 - Netty框架低版本,例如4.1.x早期版本启动时访问
sun.misc.Unsafe - 使用Lombok的旧版本,编译期或运行期反射访问JDK内部类
- 用到了Hibernate、MapStruct、ByteBuddy、Mockito等字节码/反射库
- 自己的代码里用
getDeclaredField去拿java.base模块里的内部字段
我把常见的求助现场整理成了一张表,方便你对号入座:
| 常见触发方 | 被访问的模块 | 典型内部包 | 典型触发点 |
|---|---|---|---|
| Spring Boot 2.x / CGLIB | java.base | java.lang, java.lang.reflect | 动态代理生成类 |
| Netty 4.1.x | jdk.unsupported | sun.misc | Unsafe、DirectBuffer相关 |
| ByteBuddy / Mockito | java.base | java.lang, java.util | 子类生成与Mock |
| Hibernate 5.x | java.base | java.lang, java.util | 字节码增强、懒加载代理 |
| 自研工具类 | java.base | sun.nio.ch, jdk.internal.misc | 反射强行访问内部实现 |
看到自己项目的影子也别慌,这个异常本质上不是代码逻辑错误,而是“运行环境与依赖不兼容”的提示。换句话说,它告诉你:你的JDK和你的依赖之间需要一次版本对齐,或者需要给JVM开一扇门。
2. 空降现场:快速定位异常根源的实操方法
2.1 别再只看异常第一行,核心在Caused by和完整堆栈
遇到这个异常,很多人习惯把第一行贴到搜索引擎,但我建议你先看完整的堆栈,尤其是Caused by那一行和最初的触发入口。解释清楚是什么操作触发的,比背下异常信息有用得多。
比如下面这段:
Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make protected final java.lang.ClassLoader.defineClass accessible: module java.base does not "opens java.lang" to unnamed module @0x6d6f8f94 at java.base/java.lang.reflect.Method.setAccessible(Method.java:693) at org.springframework.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:398) ...这句话其实已经把所有信息都抛给你了:
Unable to make ... accessible:具体是哪个字段或方法没权限module java.base:宿主模块是谁does not "opens java.lang":缺少对哪个包的开放to unnamed module:谁想访问,通常就是你的应用本身(未命名模块)
这时候你已经清楚知道要打开什么门了:java.base模块里的java.lang包。对应的JVM参数就是:
--add-opens java.base/java.lang=ALL-UNNAMED这个公式几乎适用所有情况,把报错里“module X does not opens Y”原样搬过来就行。
2.2 常见的JVM参数组合速查表
一次性只加一个参数太被动,实际项目里要开的门常常不止一扇。我把自己在多个项目里用过的参数组合整理成了表格,遇到异常直接挑需要的加:
| 异常信息中出现的模块/包 | 需要添加的参数 |
|---|---|
| java.base does not "opens java.lang" | --add-opens java.base/java.lang=ALL-UNNAMED |
| java.base does not "opens java.util" | --add-opens java.base/java.util=ALL-UNNAMED |
| java.base does not "opens java.net" | --add-opens java.base/java.net=ALL-UNNAMED |
| java.base does not "opens java.text" | --add-opens java.base/java.text=ALL-UNNAMED |
| java.base does not "opens java.time" | --add-opens java.base/java.time=ALL-UNNAMED |
| java.base does not "opens java.lang.reflect" | --add-opens java.base/java.lang.reflect=ALL-UNNAMED |
| java.base does not "opens sun.nio.ch" | --add-opens java.base/sun.nio.ch=ALL-UNNAMED |
| jdk.unsupported does not "opens sun.misc" | --add-opens jdk.unsupported/sun.misc=ALL-UNNAMED |
这里ALL-UNNAMED指的是“未命名模块”,说白了就是你的classpath上的所有普通代码。只要你不是自己写了什么模块化module-info.java的项目,基本都是这个值。
2.3 判断到底是开发环境还是生产环境出的问题
同一个异常在不同环境下排查侧重点不一样。开发环境里IDEA跑Spring Boot,异常经常出现在启动前几秒,而且堆栈里通常带着CGLIB或ByteBuddy字样,这时候直接在IDE的VM options里加参数最快。
生产环境如果是用java -jar启动的服务,报错堆栈往往会带上部署脚本里没有的JVM参数缺失信息。这种时候一定要先确认JAVA_OPTS有没有正确传递到进程。我遇到过很多次Dockerfile里写了ENV JAVA_OPTS="--add-opens ...",但启动命令没有引用这个变量,等于白写。
# 错误示范 CMD ["java", "-jar", "app.jar"] # 正确示范 CMD ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]排查生产环境时,还有一个比较狠但高效的命令:用jcmd或jinfo看当前进程实际生效的JVM参数,确认你加的--add-opens到底有没有进入运行时。
3. 三种解决套路:升级依赖、JVM参数、代码改造
3.1 首选方案:把依赖升级到支持JDK 17的版本
说到底,InaccessibleObjectException是“旧依赖在新JDK上水土不服”的典型症状,所以治本思路是升级依赖,而不是给JVM开一堆后门。举个例子,Spring Boot 2.x在JDK 17上支持得比较勉强,Spring Boot 3.0直接要求JDK 17,整体适配就顺畅很多。如果你的项目能升级,优先升依赖。
具体版本我列一下实际可用的最低版本,大家心里有个底:
- Spring Boot:建议至少2.7.9+,更好的是直接Spring Boot 3.x
- Netty:4.1.68+开始兼容JDK 17,推荐4.1.86+
- ByteBuddy:1.12.0+,推荐1.12.9+
- Lombok:1.18.22+
- Mockito:4.x+,推荐4.8.0+
- Hibernate:5.6.x+
升级之后原先很多反射访问JDK内部类的逻辑被官方重写或屏蔽,不需要你再手动开放。这里有一个实在的建议:升级依赖后务必把单元测试跑一遍,因为有些依赖换了实现方式,行为有细微差别,特别是框架底层涉及Unsafe的地方。
3.2 兜底方案:用--add-opens参数给JVM开一扇门
如果项目暂时不能升依赖,或者升级成本太高,退而求其次就是在JVM层面给受影响的模块包手动开放。这个方案我几乎每天都在用,足够应付绝大多数场景。
先看命令行方式。假设你的应用是自己写的,直接用--add-opens,完整命令行长这样:
java \ --add-opens java.base/java.lang=ALL-UNNAMED \ --add-opens java.base/java.util=ALL-UNNAMED \ --add-opens java.base/java.net=ALL-UNNAMED \ --add-opens java.base/sun.nio.ch=ALL-UNNAMED \ --add-opens jdk.unsupported/sun.misc=ALL-UNNAMED \ -jar app.jar如果你用的是IDEA,打开Run/Debug Configurations,在VM options里输入同样的参数即可。注意,IDEA里填这串参数时一定不要加java前缀,直接写--add-opens开头的参数就行,这个细节有同事栽过。
Docker环境里建议用环境变量统一传,这样Dockerfile和启动脚本解耦:
ENV JAVA_TOOL_OPTIONS="--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED"JAVA_TOOL_OPTIONS这个环境变量的好处是JVM启动时会自动读取它,不管启动命令怎么写都能生效,特别适合部署平台统一注入。但也正因为它是全局的,如果一台机器上跑多个Java应用,会影响所有应用,所以生产环境我通常只在应用专用镜像里设置。
3.3 别把--add-opens和--add-exports搞混
做JDK模块化排障时,经常还会遇到另一个相似异常,提示的是Package <包名> not exported,而不是does not "opens"。这时候要用的参数是--add-exports,不是--add-opens。
我简单说一下两个参数的本质区别:
--add-exports:让某个包可以被其他模块在编译期引用,解决的是“看不见”的问题--add-opens:让某个包可以被其他模块在运行期反射访问,解决的是“反射访问不到”的问题
因为InaccessibleObjectException是运行期反射导致的,所以绝大多数情况下你需要的是--add-opens。只有在你看到ClassNotFoundException或者Package x not exported这类编译/加载期错误时,才考虑--add-exports。这个点很多人会绕进去,我特意写出来提醒一下。
还有一点要注意:JDK 17里--permit-illegal-access参数已经被移除了,网上搜到旧文章让你加这个的,直接忽略,加了JVM会报“Unrecognized option”,反而影响启动。
4. 实战案例:三个典型场景的完整排查过程
4.1 Spring Boot 2.7在JDK 17启动即崩
我维护的一个内部管理系统,从JDK 8升到JDK 17,启动不到三秒就抛异常。完整堆栈关键部分如下:
Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make protected final java.lang.ClassLoader.defineClass accessible: module java.base does not "opens java.lang" to unnamed module @0x1b7d6c3c at java.base/java.lang.reflect.Method.setAccessible(Method.java:693) at org.springframework.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:398) at org.springframework.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:319) ...定位过程其实很快:异常发生在Spring的CGLIB动态代理生成代理类时,它反射调用了ClassLoader.defineClass这个受保护方法。JDK 17强封装下这段反射路径被禁了。
我的处理方式分两步。第一步先试升级Spring Boot,从2.7.2升到2.7.9,结果发现还是有类似问题,因为CGLIB在2.7.x里依然是默认的代理实现,只是部分修复了。第二步加参数兜底:
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar启动恢复正常。不过这里要提醒一句:Spring Boot 2.7加这一个参数不一定够,运行过程中如果又冒出别的异常,比如java.lang.reflect.InaccessibleObjectException: Unable to make field ... accessible,就再照着第2章的速查表补即可。
4.2 ARM Linux服务器上Netty离线安装后的报错
有段时间我在一台ARM架构的Linux服务器上离线安装了JDK 17,然后部署一个Netty服务。离线安装本身倒没踩坑,真正难受的是服务一启动,Netty内部访问sun.misc.Unsafe的代码直接触发了同样的异常:
java.lang.reflect.InaccessibleObjectException: Unable to make field private static final sun.misc.Unsafe theUnsafe accessible: module jdk.unsupported does not "opens sun.misc" to unnamed module @0x8f3b52a at java.base/java.lang.reflect.Field.setAccessible(Field.java:693) at io.netty.util.internal.PlatformDependent0.<clinit>(PlatformDependent0.java:89)因为Netty在初始化时会尝试通过反射获取Unsafe实例,用于直接内存分配等操作。我参照速查表,在启动脚本里加入了:
JAVA_OPTS="$JAVA_OPTS --add-opens jdk.unsupported/sun.misc=ALL-UNNAMED" java $JAVA_OPTS -jar netty-server.jar对,就是这个参数,Netty顺利跑起来了。之后我又顺手在JAVA_TOOL_OPTIONS里加了同一个参数,这样不管谁用哪个脚本启动,都会自动带上,省得那天同事换了个启动方式又把异常带出来。
4.3 自己写的反射代码碰了JDK内部包
有的项目里同事为了拿Unsafe或者其他内部类,会写类似这样的代码:
Field f = sun.misc.Unsafe.class.getDeclaredField("theUnsafe"); f.setAccessible(true); Unsafe unsafe = (Unsafe) f.get(null);在JDK 8下没问题,JDK 17一跑立刻炸。这个场景我给出的建议很直接:如果是为了写框架底层,或者学习验证,可以临时加--add-opens jdk.unsupported/sun.misc=ALL-UNNAMED;但如果是正式业务代码,强烈建议改用公开API或者通过MethodHandles来操作。
为什么?因为JDK官方已经把很多内部实现关闭了,今天你加参数能开,下次升级JDK可能连sun.misc这个包都换到别处,代码直接废掉。对于想拿Unsafe做高性能操作的场景,可以看看jdk.internal.misc.Unsafe,但那个包的访问门槛更高,也需要--add-opens,我一般不建议碰。业务代码就老实点用ByteBuffer.allocateDirect()这类标准API,性能差不了多少,但维护成本低得多。
5. 常见问题速查表与避坑心得
5.1 典型问题排查表
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 加了--add-opens还是报错 | 参数位置不对或没传到JVM | 用java -jar时参数必须放在-jar之前,或在JAVA_TOOL_OPTIONS里设置 |
| IDEA里配置了VM options但没生效 | 配置在错误的运行配置上 | 确认当前启动类是哪个配置,直接在目标配置里修改 |
| 同一个异常在测试环境好了生产又出 | 部署脚本没把JAVA_OPTS传进启动命令 | 用jps -lvm或jinfo看实际JVM参数 |
| 报错说Package xxx not exported | 需要的是--add-exports而不是--add-opens | 换成--add-exports对应模块包 |
| 升级依赖后应用行为变化 | 新版本框架重写了底层反射逻辑 | 跑一遍集成测试,重点验证代理、序列化、缓存相关功能 |
| JDK 17启动时提示Unrecognized option: --permit-illegal-access | 旧参数已移除 | 删掉该参数,改用--add-opens |
5.2 我自己的几个避坑习惯
第一,遇到InaccessibleObjectException,我第一件事不是搜索,而是复制完整堆栈到本地文件里,用grep把“module”和“opens”关键字全部提取出来。这两个关键字后面就是你要加的包名,比对着屏幕猜效率高得多。
第二,能用--add-opens解决的问题,我不会为了图稳定去给所有内部包都开一遍。比如只报java.lang就只开java.lang,不要一上来就全开十个参数,否则线上出了问题排查范围更大。
第三,也是我个人最大的体会:这个异常本质上是“时代切换”的信号——你的代码和依赖还在上一个时代。临时开参数是给项目争取改造时间,不是终极方案。有条件的话还是安排一次依赖升级专项,把支持JDK 17的新版本框架、库统一拉齐。
最后分享一个小技巧:如果你经常要在不同JDK版本间切换测试,可以在~/.bashrc或~/.zshrc里把常用的--add-opens组合定义成一个变量,比如export JAVA17_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED --add-opens ...",每次启动直接java $JAVA17_OPTS -jar xxx.jar,省得来回复制。我那几个天天折腾JDK版本的项目,基本都靠这个习惯省下了不少时间。