资讯详情

JVM VerifyError深度解析:long/double槽位与字节码验证机制

📅 2026/10/2 22:06:26 | 华诺云谱 👁 阅读
JVM VerifyError深度解析:long/double槽位与字节码验证机制
先讲一个我印象里特别深的场景某天早上拉完最新分支或者顺手升级了一个依赖版本应用一启动日志里直接甩出一行长相非常学术的异常——java.lang.VerifyError: (class: com/example/OrderService, method: createOrder signature: (JLjava/lang/String;DZ)V) Rejecting invocation long or double parameter at index 1 is not a pair你去代码里搜index 1搜不到搜pair更是一头雾水。换JDK、清缓存、删掉gradle目录重建全都白搭。这玩意儿根本不是你写的Java代码能直接造成的它发生在类加载器把字节码交给HotSpot验证器复查的环节。简单说某个库、某个Agent、或者某个字节码增强工具在生成/改写class文件的时候把方法调用的参数槽位算错了JVM按规范一检查当场拒绝加载这个类。这里头的index 1不是源码行号而是方法描述符里参数的位置is not a pair说的则是long和double在虚拟机内部不是一个变量而是一对槽位。今天这篇就从这个错误消息出发把JVM验证器的脾气、long/double的槽位规则、哪些场景最容易踩雷以及定位修复的一套流程都拆开讲透。1. 先拆开这行报错验证器拒绝的不是业务逻辑而是字节码要处理这个错误第一步是别把它当成普通异常去搜怎么解决VerifyError而是先把异常消息的每个词吃透。这是一条LinkageError的子类意味着它不属于你的应用逻辑错误而是类与类之间、字节码与JVM规范之间的契约出了问题。1.1 逐词拆解异常消息拿最典型的完整消息举例java.lang.VerifyError: (class: com/example/OrderService, method: createOrder signature: (JLjava/lang/String;DZ)V) Rejecting invocation long or double parameter at index 1 is not a pair从左往右java.lang.VerifyError类加载阶段中验证Verification步骤抛出的错误。类文件已经存在字节码指令也读出来了但验证器发现指令序列违反了《Java虚拟机规范》中关于类型安全的规定。(class: com/example/OrderService, method: createOrder signature: (JLjava/lang/String;DZ)V)这是HotSpot对出错点的定位。它能告诉我们两件事——第一是哪个类被拒第二是该类的哪个方法。signature是完整的方法描述符(JLjava/lang/String;DZ)V表示参数依次为Jlong、Ljava/lang/String;字符串引用、Ddouble、Zboolean返回值是Vvoid。Rejecting invocation long or double parameter at index 1这里的index是从0开始数的参数槽位序号。注意对非static方法槽位0是this真正参数从1开始数但对static方法槽位0就是第一个参数。本例方法签名里第一个参数是Jlong按参数槽位它在index 0和1各占一格invocation long or double parameter at index 1指的是这个long参数的整体视图里包含index 1这个槽位。is not a pairlong/double属于JVM所称的category 2类型它在操作数栈和局部变量表中都占用两个连续槽位并且必须被当成一个整体pair来对待。验证器在处理方法调用指令时要求栈顶构造出来的参数分布必须和描述符完全一致。如果它发现本该是long/double pair的地方栈上的类型模拟却是别的类型就会直接给出not a pair。1.2 验证器到底在检查什么类加载过程有个环节叫验证verificationHotSpot现在默认使用类型检查型验证器type-checking verifier很多资料里也叫分裂验证器或StackMapTable验证器。它的工作方式不是看你的代码逻辑对不对而是对方法的每条字节码指令做一次抽象解释模拟操作数栈和局部变量表里每个槽位在每条指令执行前是什么类型。其中方法调用指令invokevirtual、invokestatic、invokeinterface、invokespecial是很关键的一个检查点。因为调用者必须在指令之前把目标方法的参数按顺序压栈验证器就会按方法描述符逐个检查栈顶类型发现Jlong就应该看到连续两个槽位的long pair发现引用就应该检查引用类型是否兼容。只要有一处对不上哪怕实际运行时可能恰好不触发问题验证器也会立即在加载阶段拒绝这个类——这就是为什么你会看到VerifyError而不是某个具体的NullPointerException或ClassCastException。这里有个很反直觉的地方出错的原因往往不在出错的方法内部而在调用这个方法的另一个方法里。消息虽然指向某个方法但如果一个类的方法A调用了方法B而B的参数包含long/double那么A里对B的调用指令上下文如果类型模拟出错验证器也会把这个错误归到B头上。所以排查时不能只盯着异常消息里的那个方法还要看是谁在调用它、调用点的参数推送序列是什么样的。2. long/double在JVM里的特殊待遇槽位与pair机制很多Java开发者写了好几年业务代码从来不知道long和double在字节码层面比int、String多占一倍的空间。这个一倍恰恰就是VerifyError最常见的导火索。2.1 category 1和category 2JVM的类型世界观《Java虚拟机规范》把局部变量表和操作数栈的类型分成两大类category 1int、float、byte、short、char、boolean、reference对象引用在局部变量表或操作数栈中占用1个槽位。category 2long、double占用2个连续槽位也就是所谓的一对pair。为什么这样设计早期JVM为了简单把局部变量表实现成以32位为单位的槽位数组64位的long/double天生放不下一个槽只能横跨两个槽。这个历史设计一直保留到了现在。局部变量表里如果第i个变量是long那么i和i1两个槽位都会被这个变量整体占用i1不能单独作为另一个变量的索引。数据加载指令也专门区分lload、dload负责把一对槽位当作整体压到操作数栈上而iload、fload等只会操作一个槽。2.2 方法参数到槽位的映射最常见的计算误区每个方法的局部变量表里槽位分配是从0开始的。对于非static方法slot 0被this占据对static方法没有this第一个参数从slot 0开始。参数按顺序连续分配遇到long/double就额外多占一格。举个例子有个方法void createOrder(int count, long userId, String code, double amount, boolean flag)它在字节码层面的局部变量槽位是这样的参数类型category占用的槽位thisOrderService10countint11userIdlong22, 3codeString14amountdouble25, 6flagboolean按int处理17注意参数个数是5但槽位排到了7。如果你用源码里的参数位置下标去生成字节码比如认为第4个参数是double所以dload 4那就大错特错——它实际在槽位5、6。这就是错误消息里index 1 is not a pair最经典的产生方式生成字节码的一方按每个参数占1个槽错误计算把一个long/double参数值错放到了本属于其他参数的槽位上。2.3 操作数栈上的pair调用指令如何消费参数在方法调用前调用方需要把对象引用如果是实例方法和所有实参依次压入操作数栈。invokevirtual等指令执行时虚拟机会根据方法描述符从操作数栈弹出这些值先弹出objectref然后按描述符参数顺序逆序弹出实参——也就是最后一个参数先弹第一个参数最后弹。这个过程中long/double在栈上同样是连续两个槽位pair弹出的操作必须是整体弹出。验证器在模拟这些指令时会维护一个类型栈。遇到invokevirtual它就从栈顶按描述符反向检查每检查一个参数从栈上弹出相应数量的槽位遇到category 2参数就一次弹出两个槽并且这两个槽必须被标签为一个完整的long/double。如果遇到某处栈类型与描述符不一致或者本该是一对的地方栈顶却出现了两个互不相关的类型验证器立刻停止并抛出VerifyError。这条机制解释了为什么错误信息监听长这样at index 1 is not a pair——它正在按参数的index逐个核对pair。3. 最容易撞见这个错的三类典型场景根据我的观察这个错误基本不会在正常人写的普通Java代码里凭空出现它背后一定有字节码被生成或修改的动作。下面三类场景几乎覆盖了绝大多数生产案例。3.1 自己用ASM/Agent做插桩时的槽位误算这是最能手把手复现的场景。假设你用ASM给一个方法做增强在方法开头插入一段代码把原有参数都转发给另一个代理方法。如果手写MethodVisitor的visitVarInsn时对long/double的槽位处理不对就会立刻生成非法字节码。看这个典型的错误写法// 原方法: void proxy(int count, long userId, String code) // 槽位: 0this, 1count, 2-3userId, 4code // 错误写法习惯性认为参数下标1就是槽位 mv.visitVarInsn(Opcodes.ILOAD, 1); // count正确 mv.visitVarInsn(Opcodes.LLOAD, 2); // userId正确 mv.visitVarInsn(Opcodes.ALOAD, 3); // 错误槽位3是long的高半部分应该是ALOAD 4这个错误被验证器抓住后表现正是某个long/double参数不是一对——因为ALOAD 3把long pair的高位槽当成引用类型加载了验证器在模拟ALOAD 3时就发现了异常类型。很多自己写Java Agent、Gradle Transform、APM探针的团队都会在初次接入时倒在这个坑上。正确的写法应当基于Type.getArgumentTypes(desc)扫描参数遇到category 2就推进2Type[] argTypes Type.getArgumentTypes((IJLjava/lang/String;)V); int slot (isStatic ? 0 : 1); // 非static跳过this for (Type type : argTypes) { // 按类型决定加载指令 if (type Type.LONG_TYPE) { mv.visitVarInsn(Opcodes.LLOAD, slot); slot 2; } else if (type Type.DOUBLE_TYPE) { mv.visitVarInsn(Opcodes.DLOAD, slot); slot 2; } else { // int, float, reference等统一按一个槽位推进 int opcode type.getOpcode(Opcodes.ILOAD); mv.visitVarInsn(opcode, slot); slot 1; } }3.2 Mock框架与动态代理生成的字节码第二个大类场景是Mockito、PowerMock这类框架。它们普遍依赖ByteBuddy、CGLIB在运行期用字节码生成技术改写目标类。一旦框架版本之间不兼容生成的类就可能在涉及long/double参数时出现配对错误。我记得特别清楚的一个案例是某个项目用了mockito-inline内联Mock Maker同时工程里还有一个老版本的byte-buddy依赖。在Mockito 4.x早期的某些版本里对签名包含long或double参数的方法做链式Stub时ByteBuddy在生成MockMethodInterceptor调用逻辑时会计算参数索引一旦它所在classpath里混了其他版本的byte-buddy就可能出现槽位错位最终抛出Rejecting invocation long or double parameter at index N is not a pair。排查方式很直接先看异常消息里指向的类是否带$MockitoMock$、$$EnhancerByCGLIB$$这类标记如果有优先检查Mockito、ByteBuddy、CGLIB版本组合把冲突的传递依赖用exclude剔除。3.3 多个Agent叠加或类重复增强这个场景最隐蔽也最容易被误判为玄学Bug。生产环境经常同时挂多个探针APM性能监控Agent、链路追踪Agent、自研字节码插件、甚至Lombok在编译器干的活儿不算但Spring AOP、MyBatis等框架的字节码增强也算一个维度。问题在于第一个Agent修改后的类已经是一份新的字节码第二个Agent如果基于类看起来和原始版本一模一样的假设再次改写就容易把局部变量表、操作数栈的排布改坏。比如Agent A给方法体插入了一段代码改变了某个分支的栈深Agent B没认出来这是已经被改过的类按原始方法的参数布局插入新的调用指令最终拼出来的类在验证器眼里就是一张残缺的拼图。典型特征是不挂任何Agent时一切正常挂了某个Agent组合就启动报VerifyError去掉其中任意一个Agent错误消失。排查时可以先通过-verbose:class观察加载顺序再逐个Agent排除最终确认是哪两个探针叠加导致的破坏。还有一个冷门但真实存在的场景Groovy动态编译。Groovy运行时会为元编程生成大量辅助类在Groovy 2.x到3.x过渡的某些版本组合里生成类的继承结构较复杂时偶尔也会触发验证器报这个错。如果你在用Groovy并且错误类的名字长得很奇怪包含$_之类的辅助类标记先试试升级Groovy版本或把动态调用改为静态编译。4. 定位与修复一套可以照做的实操流程遇到VerifyError后情绪上可以骂两句但操作上一定要按部就班。下面这套流程我跑过很多遍基本能在半小时内定位绝大多数此类问题。4.1 第一步准确提取出错类与方法签名绝大多数时候JVM会直接告诉我们是哪个类、哪个方法、什么签名。把异常消息完整复制下来不要只记类名。尤其要记录method:后面的完整描述符因为同一个类里可能有多个重载方法只有描述符能唯一定位。如果异常消息不完整或者你怀疑是加载某个类时被吞掉了可以加一个JVM参数强制打印类加载过程java -verbose:class -XX:TraceClassLoading YourMainClass日志会输出每个类的加载来源。配合异常消息里的类名搜索它到底是哪个jar包提供的这一步能快速缩小范围到具体依赖或Agent。4.2 第二步用javap反汇编核对槽位分配拿到出错类之后用javap反汇编该方法直接看字节码和局部变量表javap -p -v -c com/example/YourClass bytecode.txt javap -p -v -c -s com/example/YourClass重点步骤打开反汇编结果找到报错方法名对应的Code属性。观察局部变量表LocalVariableTable确认每个变量的Start PC、Length、Slot排布。long/double参数会占连续两个slot它的类型名后面会带个J或D很容易识别。沿着方法体指令逐条检查所有的lload、dload、invoke*指令看加载指令的索引是否和局部变量表一致。用上面那个void createOrder(int, long, String, double, boolean)的例子正确的反汇编里code参数应该在slot 4amount在slot 5、6。如果看到ALOAD 5这类引用加载指向了一个double参数的高位槽那问题就实锤了。顺便提一句IDE里也可以用字节码插件比如IntelliJ的ByteCode Viewer查看但命令行javap永远是普适且不依赖IDE环境的兜底方案。4.3 第三步区分三根主线路决策根据上一步的结果把问题归入三类再决定修法线索大概率原因修复方向出错类名带$MockitoMock$、$$EnhancerByCGLIB$$、$ByteBuddy$等标记Mock/动态代理框架版本冲突或字节码生成bug升级/降级框架版本检查依赖树出错类是被Agent挂载的目标类且-verbose:class显示有多个AgentAgent叠加重复增强类被二次改写调整Agent加载顺序给增强逻辑加幂等判断必要时合并Agent反汇编后发现自己代码相关类里指令栈/局部变量表明显错位自己写的ASM/Transform插桩逻辑槽位算错按描述符动态计算槽位重写插桩逻辑回归测试4.4 写一个最小复现程序验证修复为了确认修复方案我通常建议写一个小小的、可本地复现的实验类。举例如果你怀疑是ASM槽位问题可以用ASM的ClassReader加ClassWriter在本地对目标类做一次再生成然后把生成的字节码直接写回文件放到一个全新的类加载器里加载ClassReader cr new ClassReader(originalClassBytes); ClassWriter cw new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); cr.accept(cw, 0); byte[] rewritten cw.toByteArray(); // 用自定义ClassLoader加载并调用观察是否还抛VerifyError如果复现时抛错就把rewritten保存为.class文件再用javap和修改前的字节码做diff往往一眼就能看出哪个槽位变了。等你的插桩逻辑修正后再重复这个流程直到自定义ClassLoader能顺利加载并调用目标方法。这个复现手段之所以好在于它绕开了整个应用的复杂依赖把问题收敛在这一个类到底怎么被改坏了上排查起来很干净。4.5 如果元凶是第三方依赖版本管理的几个小动作遇到框架类问题修复动作通常是用gradlew dependencyInsight或者mvn dependency:tree输出相关依赖的完整树排查asm、byte-buddy、cglib是否出现多个版本。如果依赖树里同时出现多个ASM版本优先统一到和JDK版本匹配的最新版本因为ASM老版本不认新JDK的class文件版本。确定是Mockito问题可以尝试切换Mock Maker。比如从mockito-inline换成默认的subclass mockmaker或者反过来。很多诡异ByteBuddy问题在切换mock maker后直接消失。如果是Java Agent场景检查启动命令里-javaagent参数的顺序。有些场景要求先加载基础增强Agent再加载业务Agent顺序反了很可能二次改写冲突。5. 让这个错误不复发几条我沉淀下来的硬经验代码修好了事情还不算完。VerifyError这类字节码级问题的可怕之处在于它不像普通业务Bug那样在测试阶段就稳定暴露而是会在某个特殊依赖组合、某次Agent升级后突然爆发。我没有更好的办法只能把下面这些习惯坚持做。5.1 所有涉及方法槽位的计算统一用Type工具类只要你的代码里出现了手写visitVarInsn、visitMethodInsn、注解处理器生成方法、或者任何和方法参数索引有关的字节码操作都不要自己去维护一个参数序号→槽位的映射数组。官方做法是用Type.getArgumentTypes(desc)这样无论方法里有多少个long/double槽位推进逻辑永远是自动且一致的。我见过太多bug就是因为有人图省事写了个int slot argIndex 1。5.2 在Agent/插桩逻辑里加入幂等保护如果你维护的是自定义Java Agent或者负责多个团队共用一个公共增强工具强烈建议在增强逻辑里对被处理过的类做一个标记比如在类上加某个自定义注解或者在方法上加某个属性标记。增强逻辑每次处理前先检查标记已处理过就直接跳过。这个习惯能在多Agent叠加场景下直接掐断90%的重复改写问题。5.3 排查VerifyError时把异常里的方法和调用它的方法一起看这是我最想提醒的一点。开头就说过验证器报错的位置是发现类型不一致的指令所在的方法但压栈逻辑错误很可能发生在调用方方法里。比如方法main调用long calc(long x, double y)如果main里压栈时少压了一格验证器可能把错误报告在calc头上。只盯着calc反汇编会白白浪费时间。正确的做法是找到异常消息里的方法后同时反汇编所有引用它的调用方核验每个调用点压入操作数栈的类型序列是否和描述符吻合。5.4 新引入字节码类库时先做一次验证器压力测试团队里如果经常使用ASM、ByteBuddy、CGLIB这样的库我建议在新版本依赖引入时跑一个专门的小测试集覆盖方法参数包含long、double、混合类型、多个long/double连续出现的方法。哪怕只是简单的调用链测试也能提前踩出很多槽位类问题。这个测试集非常便宜但能省下后面几个通宵排障的时间。最后聊点实际的个人体会。这类VerifyError在排障过程中很容易让人心态崩掉因为它不遵循堆栈里有业务代码就能定位的直觉。但换个角度想它其实是JVM把一道安全防线守得很好的证明——验证器宁可让应用启动失败也不让一段类型不安全的字节码带病运行。你要做的不是绕过验证而是回到字节码生成的那一端把参数槽位按规范排对。一旦真搞懂了category 2类型和pair的规则再遇到这个错你反而会因为它把问题精确到方法签名而感到庆幸至少JVM已经替你划好了排查范围。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑