敬请指正

才疏学浅,若有不对之处烦请指正

参考链接

前言

本地跑得好好的,一上生产就大量接口 500,生产又不好排查问题,真没辙。

事情是这样的:生产环境的多个 Controller 接口突然集体罢工,报错信息说“参数名拿不到,请确认编译时加了 -parameters”。但我明明加了啊?class 文件里也确实有参数名信息啊?

折腾了一圈才发现,锅在 GraalVM 上。同样的 class 文件,标准 OpenJDK 能通过反射拿到参数名,GraalVM 就是拿不到。编译没问题,运行时出了幺蛾子。

这个 Bug 最恶心的地方在于——本地几乎不可能复现,因为开发机用的是 OpenJDK,一切正常。

1. 现场还原

生产环境部分接口突然 500,多个 Controller 报同一个错。

报错信息
1
2
3
java.lang.IllegalArgumentException: Name for argument of type [java.lang.Long] not specified,
and parameter name information not available via reflection.
Ensure that the compiler uses the '-parameters' flag.
关键信息
  • 本地和 dev 环境完全正常,仅生产环境复现
  • 不是所有接口,而是 @RequestParam@PathVariable 且没有显式指定 value 的接口 才报错
  • 已经显式指定了 value 的接口(如 @RequestParam("tenantId"))和使用 @RequestBody 的接口完全正常
  • 报错发生在 Spring MVC 的参数解析阶段(AbstractNamedValueMethodArgumentResolver

有意思,同一个项目里,有的接口正常有的 500,区别就在于注解里写没写 value。先顺着报错信息查。

2. 排查过程

2.1 第一反应:编译参数缺失?

报错信息明确提示 “Ensure that the compiler uses the ‘-parameters’ flag”,第一反应肯定是检查 Maven 编译配置。

查看根 pom.xml

1
2
3
4
5
6
7
8
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>17</source>
<target>17</target>
<parameters>true</parameters> <!-- ✅ 已配置 -->
</configuration>
</plugin>

配置没问题

子模块的小插曲

子模块的 pom.xml 虽然重新声明了 compiler 插件但漏了 <parameters>true</parameters>,补上后问题依旧。不过子模块漏配本身也是隐患——如果生产部署的 jar 是在漏配期间构建的,那这些子模块的 class 文件 本身就不含参数名,和 GraalVM 无关。dev 环境是全量重新编译,可能恰好避开了旧的构建缓存,所以表现正常。

排除

编译配置不是根因,因为补上后问题没有解决。

2.2 验证 class 文件:参数名到底有没有?

既然编译配置说了不算,那就直接看 class 文件。进入生产容器,解压 jar 包,用 javap 检查:

1
2
3
4
5
6
7
# 解压 jar
mkdir -p /tmp/check
cp BOOT-INF/lib/app-facade-0.0.1-SNAPSHOT.jar /tmp/check/
cd /tmp/check && jar xf app-facade-0.0.1-SNAPSHOT.jar

# 检查参数名信息
javap -v com/example/controller/SomeController.class | grep -A5 "MethodParameters"

输出结果:

1
2
3
4
MethodParameters:
Name Flags
tenantId
keyword
确认

class 文件中 确实包含 MethodParameters 属性,参数名 tenantIdkeyword 等都在。编译完全没问题。

这就奇怪了——class 文件里有参数名,但运行时 Spring 说拿不到?

2.3 对比环境差异

排查到这里,编译没问题、class 文件没问题,那问题只能出在运行时了。对比一下各环境:

环境 JVM 是否报错
本地开发 OpenJDK 17 ❌ 正常
dev(Docker 内编译) GraalVM CE 17.0.9 ❌ 正常
生产(预编译 jar) GraalVM CE 17.0.9 ✅ 报错

生产容器的 JVM 版本:

1
2
3
openjdk version "17.0.9" 2023-10-17
OpenJDK Runtime Environment GraalVM CE 17.0.9+9.1
OpenJDK 64-Bit Server VM GraalVM CE 17.0.9+9.1 (mixed mode, sharing)
还排除了哪些可能性?
  • 没有引入 spring-boot-devtools:DevTools 会用自己的 RestartClassLoader 来加载类(还记得热部署那篇吗,类加载器不同,加载出来的类也不同),这个特殊的类加载器在某些场景下可能影响反射行为。确认没用,排除这个干扰。
  • 没有做 AOT 编译或 native-image 打包:GraalVM 的 native-image 模式会把 Java 程序提前编译成本地可执行文件,这个过程中需要你手动声明”哪些类要用反射”,没声明的就拿不到参数名。但我们用的是普通 JVM 模式(上面版本信息里的 mixed mode),不是 native-image,所以也不是这个原因。
  • Docker 基础镜像三个环境一致,排除镜像差异。

发现了吗?本地用的是 OpenJDK,生产用的是 GraalVM。dev 虽然也是 GraalVM,但它是 Docker 内全量重新编译的,和生产的”预编译 jar 部署”不一样。

2.4 定位根因:GraalVM 反射元数据加载差异

标准 OpenJDK 通过 java.lang.reflect.Parameter.getName() 读取 class 文件中的 MethodParameters 属性,这个行为是稳定可靠的。

但在 GraalVM 环境下,某些版本的反射元数据加载策略可能与标准 OpenJDK 存在细微差异——即使 class 文件中包含了 MethodParameters 属性,运行时 Parameter.getName() 仍然可能返回合成名(如 arg0arg1),导致 Spring MVC 无法匹配参数。

核心结论

class 文件编译正确 ≠ 运行时一定能通过反射拿到参数名。GraalVM 在反射元数据的加载环节存在与标准 OpenJDK 不一致的行为。

这也解释了为什么:

  • 本地正常 —— 用的是标准 OpenJDK,反射行为符合预期
  • dev 正常 —— 虽然也是 GraalVM,但 dev 是 Docker 内全量重新编译,避开了旧的构建缓存
  • 生产翻车 —— 预编译 jar 部署到 GraalVM 运行,触发了反射参数名丢失的问题

3. 问题代码

项目中大量 Controller 方法的 @RequestParam@PathVariable 注解 没有显式指定 value,完全依赖反射获取参数名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ❌ 没有显式指定 value,依赖反射获取参数名
@GetMapping("/list-all")
public Response<List<SomeVO>> listAll(
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) String keyword) {
// ...
}

@GetMapping("/{id}")
public Response<SomeDTO> detail(@PathVariable Long id) {
// ...
}

@GetMapping("/detail")
public Response<SomeVO> detail(@RequestParam Long id) {
// ...
}
Spring MVC 解析这些参数的流程
  1. 检查注解是否有 value / name 属性 → 没有
  2. 尝试通过 Parameter.getName() 反射获取参数名 → GraalVM 返回 arg0
  3. 无法匹配请求参数 → 抛出 IllegalArgumentException

所以显式指定了 value 的接口不受影响——Spring 在第一步就拿到名字了,根本不走反射。

4. 修复方案

修复方案

给所有 @RequestParam@PathVariable 显式指定 value,彻底不依赖反射获取参数名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ✅ 显式指定 value,不依赖反射
@GetMapping("/list-all")
public Response<List<SomeVO>> listAll(
@RequestParam(value = "tenantId", required = false) Long tenantId,
@RequestParam(value = "keyword", required = false) String keyword) {
// ...
}

@GetMapping("/{id}")
public Response<SomeDTO> detail(@PathVariable("id") Long id) {
// ...
}

@GetMapping("/detail")
public Response<SomeVO> detail(@RequestParam("id") Long id) {
// ...
}

简单粗暴,但最可靠。不管底层 JVM 怎么折腾,显式声明的 value 永远不会丢。

5. 为什么本地复现不了?

这个 Bug 最恶心的地方在于 本地几乎不可能复现,聊聊为什么:

四个原因
  1. 开发机用的是标准 OpenJDK,反射获取参数名完全正常
  2. -parameters 编译参数确实配了,class 文件里确实有参数名信息
  3. 单元测试不会走 Spring MVC 的参数解析,MockMvc 测试也可能因为 JVM 不同而表现不一致
  4. 只有在 GraalVM + 特定反射元数据加载策略 下才会触发
关于 Spring Boot 3.2+ 的额外说明

如果项目使用 Spring Boot 3.2+,这个问题更容易出现——Spring Boot 3.2 起不再支持通过字节码解析(LocalVariableTable)获取参数名,强制依赖 -parameters 反射,对 JVM 反射能力的依赖变强了。

也就是说,以前 Spring 还有个”兜底方案”能从字节码的调试信息里扒参数名,3.2 之后这条路被堵死了,只剩反射这一条路。GraalVM 在这条路上又出了问题,那就彻底没辙了。

6. 总结

根因链

1
2
3
4
5
6
GraalVM CE 17.0.9 的反射元数据加载策略与标准 OpenJDK 存在差异
↓ Parameter.getName() 返回合成名 arg0/arg1
↓ Spring MVC 无法匹配 @RequestParam/@PathVariable 的参数名
↓ 抛出 IllegalArgumentException
↓ 未显式指定 value 的 @RequestParam/@PathVariable 接口返回 500
↓ 已显式指定 value 的接口和 @RequestBody 接口不受影响

经验教训

  1. @RequestParam@PathVariable 始终显式指定 value。不依赖反射,任何 JVM 下都稳。
  2. CI/CD 和生产用同一个 JVM。本地 OpenJDK 没问题不代表生产 GraalVM 也没问题,尽早暴露兼容性差异。
一句话总结

显式声明 > 隐式依赖,尤其是跨 JVM 实现的时候。