☰
升级JDK17引发ShardingSphere反射异常:根因与三种解决策略
2026/10/12 3:43:36 网站建设 项目流程

升级 JDK17 那天晚上,我们的分库分表服务一启动就直接抛了InaccessibleObjectException,异常指向 ShardingSphere 的 Groovy 反射逻辑。当时一脸懵:JDK8 跑得好好的,为什么换到 JDK17 连 String 的私有构造器都不让碰了?

这个报错通常出现在使用 ShardingSphere 内联分片算法(比如order_id % 12这种 Groovy 表达式)的项目中,尤其是你刚从 JDK8 或 JDK11 升级到 JDK17 之后。它跟你的业务代码没有直接关系,也不是 ShardingSphere 用错了,而是 JDK 模块化对反射访问收紧了规则。

如果你正在升级 JDK17,或者在新项目里配置 ShardingSphere 分片算法时遇到了同样的异常,这篇文章应该能帮你彻底解决问题。我会从现象、根因、三种解决方案到排查技巧,完整还原这次问题的处理过程,尽量把底层原因讲清楚,让你以后再遇到类似反射问题能够自己定位,而不是只会复制网上的启动参数。

1. 现象:一次升级 JDK17 引发的反射异常

1.1 报错现场

那天的报错堆栈大致是这样的,不同版本的行号可能略有差异,但核心信息完全一致:

Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make private java.lang.String(byte[], byte[], int, int, boolean) accessible: module java.base does not "opens java.lang" to unnamed module at java.lang.reflect.ReflectAccess.checkCanSetAccessible(ReflectAccess.java:69) at java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:199) at java.lang.reflect.AccessibleObject.setAccessible(AccessibleObject.java:172) at org.codehaus.groovy.reflection.CachedMethod.invoke(CachedMethod.java:383) at org.codehaus.groovy.runtime.callsite.BytecodeDispatchedCallSite.call... at org.apache.shardingsphere.infra.algorithm.core.algorithm.ShardingSphereAlgorithmFactory... at org.apache.shardingsphere.sharding.algorithm.sharding.inline.InlineShardingAlgorithm...

核心的一句话是:

Unable to make private java.lang.String(...) accessible: module java.base does not "opens java.lang" to unnamed module

注意这里的String(...)是一个私有构造器,不是我们平时写的new String("xxx")。Groovy 在执行脚本的某个内部环节试图通过反射调用这个私有构造器,然后调用setAccessible(true)来绕过访问控制,结果被 JDK17 拦了下来。

我们的环境大致是这样的:Spring Boot 2.7.x、JDK 17.0.6、ShardingSphere 5.1.x,分片算法里写了不少 Groovy 表达式,例如:

sharding: tables: t_order: actualDataNodes: ds_$->{0..1}.t_order_$->{0..11} tableStrategy: standard: shardingColumn: order_id shardingAlgorithmName: order_id_mod shardingAlgorithms: order_id_mod: type: INLINE props: algorithm-expression: order_id % 12

在 JDK8 下这套配置跑了大半年没有问题,升级 JDK17 后,应用启动或者第一次执行分片查询时就炸了。最开始我们怀疑是 ShardingSphere 版本太老,但翻了一圈资料、做了几个实验之后发现,真正的问题出在 JVM 对反射访问的限制上。

1.2 问题触发的业务场景

为什么这么多项目会遇到这个异常?因为 ShardingSphere 的 INLINE 分片算法本质上就是用 Groovy 表达式计算路由目标。对于团队来说,这种配置方式非常直观,几行配置就能搞定按订单取模、按用户取模、按日期范围分表等需求。但 Groovy 是动态语言,它的很多魔力都建立在反射之上,而 JDK17 对反射的约束恰恰是所有版本里最严格的。

这种约束对正常业务代码影响不大,但对依赖setAccessible的第三方库就成了拦路虎。尤其像 Groovy 这种追求高性能脚本执行的框架,在字节码生成、方法缓存、构造器调用等内部路径中都会尝试使用反射来提升效率。从 JDK8 升到 JDK17,相当于原来一路绿灯的通道突然多了几道安检,行李超标的全部拦下。

虽然问题表现为 ShardingSphere 和 Groovy 的报错,但其实不只是这两个组件。任何在 JDK17 上使用反射访问 JDK 内部非公有成员的 Java 库都可能触发InaccessibleObjectException。因此在解决这个问题之前,先要搞清楚 JDK17 的模块化封装到底改了什么。

2. 根因:JDK 模块系统不背锅,但 setAccessible 就是这个规矩

2.1 JDK 从 9 开始搞的强封装

Java 9 引入模块系统(Project Jigsaw)时,把 JDK 自带的代码拆成了很多模块,java.base是最核心的一个,包含了java.lang、java.util、java.io这些基础包。模块系统的一个关键特性叫强封装:模块内部的非public成员,默认不能被模块外的代码反射访问。

举个例子,java.lang.String类的public方法大家随便用,但它的私有构造器、私有字段,以及包私有的内部实现,都不再是“只要你反射就能碰”的。模块里的module-info.java如果没有通过opens关键字把某个包开放出去,外部代码一旦调用setAccessible(true)就会抛出InaccessibleObjectException。

JDK 9 到 JDK 16 其实还留了一个口子,就是--illegal-access=permit,默认情况下允许部分反射访问,只是会在日志中打警告。到了 JDK 17,官方彻底收紧了策略,--illegal-access启动参数被移除,默认就是 deny。也就是说,JDK 17 里你想反射访问 JDK 内部非公有成员,唯一合规的途径是使用--add-opens或--add-exports明确开放对应模块和包。

很多人一看到InaccessibleObjectException就骂框架不行,其实这个锅 JDK 不背,模块化设计是有意为之。真正的问题是 Groovy 这种历史悠久的动态语言库,还残留着早期 JDK 下依赖反射访问内部 API 的代码路径。

2.2 Groovy 为什么非要反射 String 私有构造器

Groovy 执行order_id % 12这类表达式时,并不是像我们想象的那样“解释执行”一遍就完,它内部会经历脚本解析、AST 转换、动态调用点(call site)缓存,甚至可能生成字节码。在这个过程中,Groovy 为了优化字符串的构造和拼接,会尝试调用 JDKString类里一些非公有的构造器。

以报错信息里的private java.lang.String(byte[], byte[], int, int, boolean)为例,这类私有构造器的作用是避免复制字节数组,直接从已有数组中构造字符串。在 JDK8 时代,第三方库通过反射调用这种构造器很常见,因为性能好、又不会破坏字符串语义。但 JDK17 的模块强封装要求java.base/java.lang对反射代码所在的模块开放,而 Groovy 运行在 classpath 上,属于未命名模块(unnamed module),系统默认没有开放这个包的访问权,于是setAccessible(true)直接失败。

需要注意的是,这个报错不一定每次都出在同一个构造器上,也可能出现String(char[])、StringBuilder、MethodHandle等内部类的反射访问错误。但只要你走的是 Groovy 执行路径,遇到java.base does not "opens ..." to unnamed module这类错误,思路都是一样的:找到报错指向的包,用--add-opens把它开给未命名模块。

2.3 为什么在 JDK8 没事,在 JDK17 就炸

这个问题的答案其实很直接。JDK8 没有模块系统,反射访问setAccessible(true)可以突破几乎所有访问控制,String的私有构造器想调就调。JDK9 到 JDK16 虽然有了模块系统,但默认的--illegal-access=permit还是给老库留了后门,应用大概率能跑起来,只是日志里会出现 “WARNING: An illegal reflective access operation has occurred” 这样的字眼,很多人根本没注意。到了 JDK17,后门彻底关闭,之前所有“裸奔”的反射操作一次性爆发,于是项目升级后第一天晚上就在工位上看到这个异常。

这也解释了为什么网上很多资料告诉你“升级到 JDK17 后加两行 JVM 参数就好了”,因为 JDK17 本质上是把非法反射访问从“警告”升级成了“异常”。它能通过参数放行,但根因还是代码用了非公有成员的反射操作。所以我们要么给它开绿灯,要么让它尽量不走这条路。

3. 解决方案一:加 --add-opens,快速止血

3.1 确定需要开放哪个包

在解决这个问题的第一步,先不要急着抄网上的一大堆参数。正确的做法是先看异常信息里的这一行:

module java.base does not "opens java.lang" to unnamed module

它已经很明确地告诉你:java.base模块下的java.lang包,没有向未命名模块开放。那我们只需要在启动命令里加上:

--add-opens java.base/java.lang=ALL-UNNAMED

这个参数的意思是:把java.base模块里的java.lang包,通过opens的方式,授权给所有未命名模块(也就是运行在 classpath 上的代码,包括你的应用和第三方依赖)。这样一来,Groovy 再想反射访问String的私有构造器,就不会被模块系统拦截了。

添加参数后的启动命令长这样:

java --add-opens java.base/java.lang=ALL-UNNAMED \ -jar order-service.jar

注意--add-opens是 JVM 启动参数,不是应用参数,必须放在-jar之前。如果你把参数写到-jar后面,Spring Boot 会把它当成应用参数,JVM 根本不会读取,问题自然还在。

3.2 不同环境的落地方式

实际操作中,我们很少直接用手敲命令启动 Java 服务,通常是在 IDE、Shell 脚本、Docker 或者 Kubernetes 里配置。每种环境的写法稍微有点区别,但底层逻辑完全一样。

IDEA 中配置

在 IDEA 里运行 Spring Boot 应用时,打开 Run Configuration,找到 VM options 一栏,填入:

--add-opens java.base/java.lang=ALL-UNNAMED

然后保存重新启动。这里最容易踩的坑是有人把它填到了 Program arguments 里。这两个字段作用完全不一样,Program arguments 是传给main方法的,VM options 才是传给 JVM 的。

Shell 启动脚本

如果你的服务是通过 Shell 脚本启动的,可以把参数放到JAVA_OPTS环境变量里:

export JAVA_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED" java $JAVA_OPTS -jar order-service.jar

这样做的好处是将来要增加其他--add-opens参数,只需要修环境变量,不用动启动脚本。

Docker 容器

在 Dockerfile 中,建议通过环境变量传入:

ENV JAVA_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED" ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/order-service.jar"]

有些精简镜像不用 Shell 作为入口,而是直接用java -jar启动,那你在镜像里定义环境变量后,JVM 是读不到JAVA_OPTS的,必须让 Shell 做一次解析。这里我们采用sh -c的方式,就是为了让环境变量能正常展开。

Kubernetes 部署

在 Kubernetes 中,最常见的做法是通过env注入JAVA_OPTS:

env: - name: JAVA_OPTS value: "--add-opens java.base/java.lang=ALL-UNNAMED"

然后在镜像的启动命令中,和上面 Docker 示例一样,用 Shell 去解析JAVA_OPTS。如果你用的是云厂商自带的启动脚本,通常已经做了这个处理,但部署后建议先看一眼 Pod 的启动命令,确认参数真的传到了 java 进程上。

3.3 能否只对目标包 opens,而不是 ALL-UNNAMED

理论上,--add-opens语法中目标模块可以写具体的模块名,比如:

--add-opens java.base/java.lang=com.example.mymodule

但这种情况要求你的代码已经模块化,运行在 modulepath 而不是 classpath 上。绝大多数 Spring Boot 应用都是平铺的 classpath,第三方依赖和你自己的代码都属于未命名模块,没有具体模块名可写,所以实际场景中几乎都用ALL-UNNAMED。

需要注意的是,开放包意味着同一个模块里的所有未命名代码都可以通过反射访问该包的非公有成员。在安全敏感的场景下,这种开放范围确实变大了。但短期的止血目标是为了让服务能在 JDK17 上跑起来,这个代价通常可以接受。如果你实在不放心,可以等应用稳定后,用后面要讲的 Java 自定义分片算法方案逐步替换掉 Groovy 脚本,减少对 JVM 参数的依赖。

4. 解决方案二:升级依赖版本,根上解决

4.1 框架版本的迭代方向

加--add-opens只是让 Groovy 能绕过模块系统的检查,并没有消灭问题源头。如果团队不愿意长期维护一堆 JVM 参数,可以考虑升级依赖版本。ShardingSphere 从早期版本开始,一直在适配新 JDK,尤其是 JDK17 发布后,社区对模块化带来的反射限制做了不少调整。

如果你当前用的是比较老的版本,可以先尝试升级到较新的稳定版,然后看发布说明里有没有提到 JDK17 兼容性相关内容。具体升级目标版本,以你在用的产品线为准,我这边不推荐盲目追最新版,因为升级框架本身有回归风险。你可以先在一个独立的测试环境验证,启动时看是否还出现InaccessibleObjectException。

不过说实话,依赖版本升级并不总是能彻底解决问题,因为 ShardingSphere 内部下层的 Groovy 版本也可能需要联动升级,框架本身未必能完全屏蔽底层脚本库的反射行为。因此升级版本通常只能缩小问题范围,未必能让你完全不加任何 JVM 参数。

4.2 Groovy 版本的调整

ShardingSphere 的 INLINE 分片算法依赖 Groovy 来解析表达式。Groovy 自身对 JDK17 的适配情况直接影响这个异常的触发概率。Groovy 3.0 之后的版本对 JDK9+ 的模块系统支持逐步增强,到 Groovy 4.0 以后,对 JDK17 的兼容性明显更好。如果你的项目是 Maven 管理,可以用依赖树命令看看当前实际使用的 Groovy 版本:

mvn dependency:tree -Dincludes=org.codehaus.groovy:groovy

如果发现某个老版本的 Groovy 是罪魁祸首,可以尝试在 POM 中显式覆盖版本。比如手动引入更高版本的 Groovy:

<dependency> <groupId>org.codehaus.groovy</groupId> <artifactId>groovy</artifactId> <version>4.0.24</version> </dependency>

但这里必须提醒你:跨版本替换 Groovy 要非常谨慎,因为 ShardingSphere 可能依赖 Groovy 的某些内部行为,新版不一定 100% 兼容。改完以后一定要回归测试分片算法、脱敏算法、鉴权等所有用到脚本的功能模块。

4.3 升级后的回归检查

无论升级 ShardingSphere 还是 Groovy,都不能只看应用能不能启动。分片算法的逻辑正确性直接关系到数据路由,稍有不慎,订单数据就可能被写到错误的表里。建议回归时至少覆盖以下几点:

  • 启动阶段:算法工厂能否正常加载,所有配置的 INLINE 表达式有没有报错。
  • 分片正确性:准备一组覆盖所有分片键边界值的数据,比如订单 ID 为 0、1、11、12、999,核对路由到的实际数据节点是否符合预期。
  • 查询验证:分别测试插入、查询、批量查询、跨分片查询,确认结果集正确。
  • 压力测试:用和生产相近的 QPS 压一下,观察是否因为 Groovy 版本变化带来额外的性能损耗。

升级版本不是简单换坐标,而是一次完整的兼容性验证。如果升级后问题解决了,那当然是好事;如果问题依旧,再回头找 JVM 参数也不迟。

5. 解决方案三:绕开 Groovy 脚本,改用 Java 代码分片

5.1 为什么可以绕开

在经历过这次报错之后,我认真思考了一个问题:项目里用了那么多分片算法,真正需要 Groovy 这种动态表达能力的有几个?其实大部分场景不过是取模、哈希、时间范围,这些用 Java 写一个类也就是几十行代码的事。与其被 JVM 参数和框架版本绑着手脚,不如直接把分片算法改成 Java 代码,从根上避开反射问题。

ShardingSphere 支持自定义分片算法,你可以定义一个类,实现相应的算法接口,然后在分片配置中通过类名引用。这样 Groovy 就不再参与分片表达式的执行,自然不会再碰String私有构造器的反射路径。

5.2 示例代码:自定义分片算法

下面这个例子按订单 ID 取模路由到 12 张表。不同版本的 ShardingSphere 接口略有差异,我这里以常见版本为参考,你落地时以自己使用的版本 API 为准。

public class OrderIdModShardingAlgorithm implements StandardShardingAlgorithm<Long> { @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) { long orderId = shardingValue.getValue(); long index = orderId % availableTargetNames.size(); for (String targetName : availableTargetNames) { if (targetName.endsWith("_" + index)) { return targetName; } } throw new IllegalArgumentException( "未找到可用的分片目标, orderId=" + orderId ); } }

然后在配置中指定该算法:

shardingAlgorithms: order_id_mod: type: CLASS_BASED props: strategy: standard algorithmClassName: com.example.demo.OrderIdModShardingAlgorithm

表策略保持原来的引用即可:

tableStrategy: standard: shardingColumn: order_id shardingAlgorithmName: order_id_mod

这段代码的关键在于从availableTargetNames中筛选出后缀匹配_0到_11的数据节点,返回真实存在的表名。如果你的物理表命名不是t_order_0这种规则,只要把命名规则对齐,算法逻辑是不变的。

5.3 对比 Groovy 内联表达式与 Java 编码的取舍

在团队里推动用 Java 替换 Groovy 之前,最好先做一次系统对比,避免架构评审时被挑战。我自己梳理过一张对照表:

维度Groovy 内联表达式Java 自定义算法
开发效率配置一行,非常快需要写类、编译、部署
运行性能脚本解析 + 反射调用纯方法调用,无额外开销
JDK17 兼容需要 JVM 参数或新版 Groovy天然兼容,无反射限制
可维护性配置散落在 YAML 中,难测试类代码,容易单测和 Code Review
动态场景支持复杂动态表达式,灵活改逻辑需要重新发版

从这个表格能看出来,Groovy 的优势更多在“简单快捷”上,Java 自定义算法的优势在“稳定可控”上。如果你的分片逻辑几十年如一日的简单,比如就是id % N,我建议直接 Java。如果确实需要频繁调整复杂规则,且团队能接受维护这些 JVM 参数,继续用 Groovy 也未尝不可。

6. 踩坑记录与排查技巧

6.1 一个参数不够,还报其他 InaccessibleObjectException

第一次解决这个问题时,我只加了--add-opens java.base/java.lang=ALL-UNNAMED,应用确实跑过了String私有构造器那个坎,但没多久又在别的地方炸了出来,报错变成了:

module java.base does not "opens java.util" to unnamed module

后来又陆续遇到了java.lang.reflect、java.text、java.math这几个包的问题。原因很简单:Groovy 脚本执行涉及的反射面非常广,不只String构造器一个点。随着应用运行到不同代码路径,其他包的反射访问也会被模块系统拦截。

网上常见的组合参数长这样:

--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.text=ALL-UNNAMED --add-opens java.base/java.math=ALL-UNNAMED

但我不建议一上来就加全套,而是要看实际报错信息精准添加。每次报错都会明确告诉你哪个包没开,你就加哪个包,加到应用稳定为止。这样虽然多迭代几次,但分析过程和最终结论会是清晰的,以后出了问题也能快速定位。

6.2 检查 JVM 参数是否生效

经常有同事跑来问我:“我明明加了--add-opens,怎么还是报一样的错?”每次我让他先看一眼 Java 进程实际收到的启动参数,大部分情况下都能发现问题出在参数位置或者部署脚本覆盖上。

检查当前 Java 进程参数,最直接的方式是使用jcmd:

jcmd <进程PID> VM.command_line

如果是本地调试,也可以在应用启动后追踪系统属性。你可以在 Spring Boot 启动类里临时加一段,把实际生效的 JVM 参数打出来:

System.out.println(ManagementFactory.getRuntimeMXBean().getInputArguments());

这两个方法能帮你确认参数到底有没有真正传给 JVM。还有一个常见的坑是容器平台在启动时会拼接自己的JAVA_OPTS,可能把你自定义的参数覆盖掉,这时候只能到 Pod 的启动命令或容器日志里排查。

6.3 安全横评:不要为了省事开放所有包

网上确实有一些“万能脚本”,让你把所有包全部 open 一遍。方便是方便,但会对安全边界造成不必要的破坏。JDK 模块化的目的之一就是防止第三方库随意反射 JDK 内部状态。如果你放开所有包,等于把这道门彻底拆了,万一某个依赖被攻击,攻击者就有机会通过反射操作 JDK 内部对象,风险等级完全不一样。

我的建议是:能少开就少开,能不开就不开。--add-opens是解决兼容性问题的临时工具,不是常规配置。在正式环境里,应该对启动参数做审计,明确记录每一项开放的模块和包是用来解决什么问题的。这样将来某天升级了依赖,某个参数不再需要了,可以及时清理掉,避免长期暴露不必要的风险面。

另外,加参数可以解决 Groovy 的问题,但项目里如果还有其他依赖也在做类似反射操作,它们的问题不会自动消失。所以最好的策略是一边用参数止血,一边推进版本升级和代码改造,多管齐下,而不是把希望全寄托在那一行命令上。


最后再分享一个实战体会:我现在已经习惯把所有 JVM 参数集中放到配置中心管理,然后在发布流程里增加一步参数快照比对。每次发版后自动对比这次启动参数和上一次的差异,一旦发现某个--add-opens被吞掉或者新增了一个奇怪的 open 路径,就能立刻发现。踩过几次坑之后,我觉得这套机制比让每个开发人肉记忆 JVM 参数要靠谱得多。JDK17 是个转折点,认真对待反射兼容问题,比加一百个临时参数都更实际。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询