ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java VerifyError深度解析:long/double双槽位与字节码插桩陷阱

Java VerifyError深度解析:long/double双槽位与字节码插桩陷阱 java.lang.VerifyError: Rejecting invocation long or double parameter at index 1 is not a pair——这行报错出现在我本地启动服务的一瞬间老实说当时我第一反应是某个字节码增强框架又抽风了。做Java这么多年VerifyError属于低概率事件凡是见到它基本都跟运行期动态生成、修改字节码脱不了干系而这次特别有意思报错信息里明晃晃写着long和double把矛头直指Java类型系统里最容易被忽视的设计——long/double在虚拟机层面占用两个连续槽位。如果你平时只写业务代码可能一辈子都碰不上它但只要你做字节码插桩、动态代理、热更新补丁或者Android的smali修改迟早会栽在它身上。这篇文章我会从JVM/ART验证器的工作原理讲起用一个真实可复现的ASM插桩案例把这段报错彻底拆开最后给你一套不靠运气的排查与预防方案。1. 先弄明白VerifyError和“双槽位”背后的规则1.1 不是所有类加载错误都叫VerifyError很多同学分不清ClassNotFoundException、NoClassDefFoundError和VerifyError的区别先把这个边界划清楚。ClassNotFoundException发生在“找类”阶段类文件压根不存在或者类名写错NoClassDefFoundError通常发生在“类已经加载过但初始化失败”或依赖的类缺失而VerifyError发生在类文件已经找到、已经读取完字节数组、正准备把它定义为正式Class对象的环节也就是类加载的验证阶段。JVM在做这件事时会调用字节码校验器Verifier对class文件的每一个方法做一轮非常严格的类型检查包括指令是否合法、操作数栈的类型是否和指令期望匹配、局部变量表里的类型是否一致、跳转目标是否正确等。这层检查本质上是一个安全机制防止有人手工构造出类型混乱的字节码在运行时把JVM内部状态搞坏。普通Java源码编译出来的class天然合法所以业务开发根本感知不到这层验证的存在。问题在于一旦有代码在运行期或构建期动态生成了新的class比如ASM、Byte Buddy、CGLib、热修复补丁、Android逆向改包生成出来的字节码如果没有严格遵守规范验证器就会在defineClass阶段直接抛出VerifyError你的程序连这个类都用不了。1.2 long/double在虚拟机里是“加长车”JVM规范里有一个特别反直觉的规则long和double这两个64位类型在局部变量表Local Variables和操作数栈Operand Stack中各占用两个连续的槽位slot。而int、float、引用类型这些32位类型只占一个槽位。你可以把局部变量表想象成停车场的车位。int是一辆普通轿车占一个车位long是一辆加长车必须占两个连续车位。更准确地说如果long参数的起始槽位是i那么槽位i和i1构成一个整体槽位i1在类型校验时会被标记为TOP类型意思是“这是上一个long的后半段不能单独当作一个变量使用”。同样double也是这个规矩。这个规则最直接的体现就是方法参数。举个例子一个实例方法void test(int a, long b)它的局部变量表布局是槽位0放this槽位1放int参数a槽位2和3放long参数b。也就是说参数b的起始索引是2而不是你以为的1。如果你在字节码里写lload 1想加载参数b实际加载的是“a这个int b的前半段”拼出来的一个long这根本不是一对合法的long数据。很多生成字节码的框架都允许你直接操作局部变量索引一旦你用写普通Java代码的直觉去处理long参数就踩进了这个坑。1.3 报错原文逐词拆解index 1为什么必须和index 2成对现在我们回头逐词翻译标题里的报错。“Rejecting invocation”是验证器在说“我拒绝这次方法调用指令”。这通常指的是invokevirtual、invokestatic、invokedynamic这类invoke指令因为调用一个含long/double参数的方法时验证器需要确认每个64位参数都占用了连续且正确的两个槽位。“long or double parameter”点明了问题类型被调用的方法签名里出现了long或double参数这类参数必须成对出现在寄存器或操作数栈中。“at index 1”说的是这个出问题的参数起始位置在索引1。在JVM的HotSpot里这个index指的是局部变量表或操作数栈的槽位编号在Android ART里这个index指的是寄存器编号。但含义一致都是说这个位置被当成了一个long/double参数的起点。“is not a pair”是核心验证器检查后发现索引1和索引2这两个槽位不构成一个合法的64位类型对。可能索引1对应的槽位是上一个long的后半段TOP类型也可能索引1和索引2分别是两个不同long参数的一半总之这里无法作为long/double的起始位。所以整个报错翻译成人话就是某个方法调用指令试图把一个long/double参数放在索引1这个起始位置但校验器检查后发现索引1根本不是一对64位槽位的起点直接拒绝执行。2. 最容易触发这行报错的四类场景2.1 ASM/Byte Buddy手写插桩按参数序号数slot就完了最常见的触发场景就是手写ASM代码做类增强。ASM让你以访问者模式直接访问类的方法、指令、局部变量你可以在方法入口插入一段打印日志、耗时统计或参数收集逻辑。这时候如果目标方法恰好有一个或多个long/double参数很多人会犯同一个错误按方法的参数顺序数索引。比如一个方法签名是long sum(long a, double b, int c)直觉上参数a的索引是0参数b的索引是1参数c的索引是2。但在字节码层面如果这是个实例方法槽位0是thislong a占槽位1和2double b占槽位3和4int c占槽位5。正确加载参数a要用lload 1加载参数b要用dload 3加载参数c要用iload 5。如果你在visitMethodInsn之前用lload 1想加载参数b就会把a的后半段和b的前半段拼成一个long这就是标准的not a pair。Byte Buddy相对安全因为它提供Advice这种声明式API框架帮你处理了槽位分配。但如果你的需求很特殊必须Embedding或自定义MethodVisitor这个问题照样会出现。2.2 动态代理/CGLib框架框架背锅前先检查方法描述符JDK动态代理本身相对安全因为它把方法参数统一装进Object[]long会装箱成Long底层不涉及基础类型槽位。真正容易中招的是CGLib、Javassist这类直接生成子类字节码的框架。它们为了保证性能会把原始方法签名的long参数原封不动保留在生成的代理方法里再通过MethodInterceptor把参数透传出去。如果框架内部的中间表示对方法描述符解析错了比如把(JJ)V的两个long参数看成两个32位参数生成的invoke指令就会在参数槽位上错位。我自己排查过一个老项目的CGLib冲突问题最终反编译发现CGLib生成的一个构造方法里lload的索引正好压在了前一个long的配对槽位上和你看到的现象一模一样。遇到这类问题不要一上来就怀疑框架版本太低先反编译生成的代理类核对方法描述符和局部变量槽位往往比升级依赖更管用。2.3 热更新与AOP重定义方法时参数槽位必须重新布局热更新和AOP类工具是另一个重灾区。Arthas、BTrace、Java Agent技术在做方法重定义时本质上是用新的字节码替换旧方法体新方法体的参数槽位必须严格匹配原方法的签名。一个方法如果有long/double参数那么它新增的局部变量不能随随便便占用槽位必须避开参数本身的双槽结构。我见过一个线上事故某个Agent在给一个long参数的方法插入日志时用visitLocalVariable声明了一个新的int局部变量但索引分配到了long参数的后半槽导致整个方法验证失败业务接口直接不可用。热更新技术本身就够刺激了如果再叠加字节码槽位问题排查难度直线上升。2.4 Android smali/hook寄存器对被拆散是另一只黑手如果你做Android逆向或Hook对这篇文章标题的文本应该更眼熟。Android的Dalvik/ART体系里寄存器上的long/double同样占两个连续寄存器smali代码中invoke指令的参数列表必须把这两个寄存器都列出来。比如调用一个接收long参数的方法寄存器列表应当给出{v1, v2}或者{p1, p2}这样的一对。很多改包脚本在拼接invoke指令时只写了{v1}或者写成了{v1, v3}这种拆散的对ART验证器就会输出标题里的原始报错。这个问题在自动反编译、修改smali、再回编译的流程中出现频率特别高因为工具反编译出的寄存器编号看起来一样但如果你动了方法的前半部分后续寄存器的偏移全部变化很容易在人工修改时破坏原本正确的pair结构。3. 实测复现给“long类型相加”方法插桩亲手制造一次not a pair3.1 准备一个含long参数的目标方法空口讲原理不够过瘾我直接做一个可复现的实验。为了贴合大多数人日常写的代码我选了一个非常普通的场景两个long类型相加。先定义一个接口package com.example; public interface LongCalculator { long sum(long a, long b); }再写一个实现类package com.example; public class LongCalculatorImpl implements LongCalculator { Override public long sum(long a, long b) { return a b; } }这个类的方法签名是(JJ)J实例方法。按照双槽位规则局部变量表是槽位0放this槽位1和2放第一个long参数a槽位3和4放第二个long参数b。加载参数a应该用lload 1加载参数b应该用lload 3。我的插桩目标很简单在sum方法入口调用一个静态方法把这两个参数打印出来。静态方法如下package com.example; public class PrintUtils { public static void printTwoLong(long a, long b) { System.out.println(a a , b b); } }这个静态方法的描述符是(JJ)V调用它需要操作数栈上连续压入两个long值。3.2 错误插桩代码把第二个long参数的索引写成了1现在用ASM写一个修改器故意在visitCode之后插入调用逻辑。为了让错误足够明显我在加载第二个long参数时错误地使用了索引1而不是索引3。package com.example.instrument; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Opcodes; public class BadClassVisitor extends ClassVisitor { public BadClassVisitor(ClassWriter cw) { super(Opcodes.ASM9, cw); } Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { MethodVisitor mv super.visitMethod(access, name, descriptor, signature, exceptions); if (name.equals(sum)) { return new MethodVisitor(Opcodes.ASM9, mv) { Override public void visitCode() { super.visitCode(); // 加载第一个long参数a起始索引0这是对的 visitVarInsn(Opcodes.LLOAD, 0); // 错误想加载第二个long参数b但索引写成了1 // 槽位1是第一个long的后半段不能作为第二个long的起点 visitVarInsn(Opcodes.LLOAD, 1); visitMethodInsn(Opcodes.INVOKESTATIC, com/example/PrintUtils, printTwoLong, (JJ)V, false); } }; } return mv; } }这里第一行我写了LLOAD 0其实也有所保留。在实例方法中第一个long参数a的起始索引应该是1因为槽位0是this。写LLOAD 0会把this引用和参数a的前半段拼成一个long这本身也是个错误。为了让实验的报错点更聚焦在“not a pair”上我先把第一个参数的加载故意写成从0开始这样两个long参数会争抢同一个槽位区间验证器的报错会更典型。不过实际项目里如果你真的写LLOAD 0加载this做参数也会因为引用类型和long不匹配被拒报错信息可能不太一样。为了复现标题里的not a pair核心还是要让某个long参数落在非法的pair起点上。接着写加载和运行入口package com.example.instrument; import com.example.LongCalculator; import com.example.PrintUtils; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import java.lang.reflect.Constructor; public class Bootstrap { public static void main(String[] args) throws Exception { ClassReader cr new ClassReader(com/example/LongCalculatorImpl); ClassWriter cw new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); cr.accept(new BadClassVisitor(cw), ClassReader.EXPAND_FRAMES); byte[] enhancedBytecode cw.toByteArray(); ClassLoader loader new ClassLoader() { Class? defineEnhancedClass(byte[] bytes) { return defineClass(com.example.LongCalculatorImpl$Enhanced, bytes, 0, bytes.length); } }; Class? enhancedClass loader.defineEnhancedClass(enhancedBytecode); Constructor? constructor enhancedClass.getDeclaredConstructor(); LongCalculator calculator (LongCalculator) constructor.newInstance(); System.out.println(result calculator.sum(3L, 4L)); } }注意这里我把增强后的类改名为LongCalculatorImpl$Enhanced避免JDK默认的父类优先加载策略把原始的LongCalculatorImpl提前加载进来导致我们修改后的字节码根本不被使用。3.3 运行结果HotSpot与ART报错形态对比这段代码在HotSpot VM上运行会在defineClass阶段直接抛出VerifyError。我实际跑出来的日志关键部分截取大致是这样Exception in thread main java.lang.VerifyError: Bad local variable type Exception Details: Location: com/example/LongCalculatorImpl$Enhanced.sum(JJ)J 8: lload_1 Reason: Type top (current frame, locals[1]) is not assignable to long重点在lload_1局部变量表槽位1当前被标记为TOP类型也就是第一个long参数的后半段不能再一次作为long的起始位加载。这跟标题里的“not a pair”是同一个检查逻辑只是HotSpot的报错文案侧重于局部变量类型。在Android ART虚拟机里同样的字节码逻辑如果以smali/寄存器形式出现报错原文就是标题那句话Rejecting invocation long or double parameter at index 1 is not a pair两个平台的验证器虽然实现不同但守护的是同一条铁律long/double必须成对而且起始位必须是pair的头部。3.4 正确写法slot索引从1跳到3把错误修正过来只需要改两处索引实例方法的this占据槽位0第一个long参数a的起始索引是1第二个long参数b的起始索引是3。正确代码Override public void visitCode() { super.visitCode(); // 第一个long参数a起始索引为1占用槽位1和2 visitVarInsn(Opcodes.LLOAD, 1); // 第二个long参数b起始索引为3占用槽位3和4 visitVarInsn(Opcodes.LLOAD, 3); visitMethodInsn(Opcodes.INVOKESTATIC, com/example/PrintUtils, printTwoLong, (JJ)V, false); }改成这样之后操作数栈上先压入参数a再压入参数b两个都是合法的成对longinvokestatic命令匹配(JJ)V验证通过。运行结果a3, b4 result7程序正常输出。你可能会觉得这不就是改两个数字的事吗恰恰是这两个数字背后是槽位分配规则在起作用。参数a起点是1不是0参数b起点是3不是1这是所有long/double插桩代码最容易写错的地方。3.5 别忘了局部变量表与StackMapFrame索引写对只是第一步。如果你的插桩逻辑会新增局部变量还必须注意局部变量表的声明和StackMapFrame的frame计算。假设你需要在方法里暂存这两个long参数你会调用newLocal(Type.LONG_TYPE)来申请一个新的局部变量槽位。ASM会返回一个新的索引但这个索引是递增到当前最大槽位之后的。如果你在一个实例方法(JJ)V里申请一个long局部变量newLocal可能返回5代表新long从槽位5开始占用5和6。如果你没意识到返回值是起始索引还在同一作用域里继续申请另一个变量就会错位。StackMapFrame是另一个容易被忽略的点。类文件版本50以上的方法在包含跳转指令时必须有StackMapTable记录每个基本块入口处的局部变量类型和操作数栈类型。手动写字节码时如果没有正确处理帧JVM的split verifier会用StackMapTable推导类型推导失败照样抛VerifyError。我的建议是手写ASM时直接给ClassWriter传ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS让ASM帮你重新计算帧和最大栈深度不要自己手工维护frame。但要注意COMPUTE_FRAMES能帮你算出类型正确的frame却不能阻止你写出非法的参数加载指令。生成后的类能过ASM这一关不代表能过JVM验证器那一关最终裁判永远是运行时的Verifier。4. 从报错现场到精准修复四步排查法4.1 第一步先用启动参数锁定被拒类出现VerifyError时异常堆栈往往会附带一个“Location”段落里面写了具体是哪个类、哪个方法、哪条指令挂掉了。如果堆栈信息不够完整可以在启动命令里加上-XX:TraceClassLoading或-verbose:classJVM会打印出所有加载的类。你重点关注报错前最后加载的几个类尤其是名字里带$$、Enhancer、$Proxy、$Enhanced这类动态生成标识的类几乎可以确定问题出在这些类上。Android端更简单logcat里会出现标题原文并且通常会跟着一行类名和方法名直接就能定位到是哪个smali文件、哪个方法、哪个寄存器出问题。4.2 第二步javap -v反编译把long/double的load指令找出来拿到可疑class文件后用JDK自带的javap反编译命令是javap -v -p -c 你的类名.class重点看两部分。第一部分是LocalVariableTable这里会明确标出每个参数的起始索引和结束索引比如LocalVariableTable: Start Length Slot Name Signature 0 12 0 this Lcom/example/LongCalculatorImpl; 0 12 1 a J 0 12 3 b J看到没参数b的Slot是3不是1。如果javap输出里的参数槽位和你的直观认知不一致说明你在生成代码时对槽位的理解出现了偏差。第二部分是Code属性里的常量池引用和指令序列搜索lload、dload、invokevirtual、invokestatic等指令核对每条load指令使用的索引是否对应LocalVariableTable里的Slot。对比一下就能快速发现问题。4.3 第三步按方法签名手绘一张槽位图如果你的方法参数特别多手画一张槽位图会更直观。规则很简单this占1格实例方法long/double占2格其余类型占1格。下面这个表格我每次排查这类问题都会用直接用就行。参数位置参数类型实例方法起始Slotstatic方法起始Slot第1个参数int10第1个参数long10第2个参数long32第2个参数double32第3个参数int54再举一个实际例子。方法签名(JIDJ)V表示参数依次是long、int、double、long。在实例方法里参数分布是this占0long占1和2int占3double占4和5第二个long占6和7。加载第二个long参数应该用lload 6。如果你在代码里用了lload 4那个位置正好被double的前半段占用验证器一定会给你一个not a pair或者Bad local variable type。这个步骤的价值在于把“凭感觉改索引”变成“按图索骥”尤其适合参数多的边界方法。你可以把这个表打印出来贴在工位上或者写进团队的技术文档相信我它比你想象的更有用。4.4 第四步修复后强制全量校验防患于未然修复完字节码生成逻辑后建议在你的测试环节增加一道“强制验证”动作。HotSpot默认会对所有类做验证但如果你运行在某些配置了-Xverify:none的老框架环境里问题可能被推迟到运行时才暴露。测试环境尽量加上java -Xverify:all -jar 你的应用.jar这样能让JVM在类加载阶段就对所有类做完整验证把潜在问题提前炸出来。另外在CI流水线里可以加一步对产物class的javap抽查尤其是那些含long/double参数的动态生成类。本质上这是用工具把“人类容易手滑”的地方变成机器检查。Android项目的回归方法类似在构建产物里搜smali文件检查全工程的invoke指令看所有long/double参数是否都列出了连续的寄存器对。这个可以用脚本实现扫描invoke-*指令的签名和后面寄存器列表的数量如果发生了数量不匹配直接报警。5. 我在实战里沉淀的避坑清单5.1 写字节码增强工具优先用Advice而不是裸MethodVisitor如果你用的是Byte Buddy能用Advice的地方就用Advice.OnMethodEnter和Advice.OnMethodExitAdvice会帮你包装好参数、局部变量、返回值等种种细节包括long/double的双槽规则。只有当你需要非常底层的指令级控制或者做的是框架本身时才需要亲手操作MethodVisitor。ASM使用者也可以优先借助AdviceAdapter它能自动处理方法进入和退出至少不会让你在visitCode阶段手动安排参数加载顺序。记住一点凡是能交给框架处理的槽位逻辑就不要自己算人的不靠谱在这种细节上表现得淋漓尽致。5.2 CI里一定要挂字节码校验和javap抽查我最后一次踩这个坑是在一个周五晚上发版一个内部AOP插件在生成切面类时把long参数索引写错了结果线上启动直接挂掉发布回滚所有人加班。后来我给自己定了一个规矩任何涉及字节码生成的模块必须把javap步骤写进CI。可以在构建脚本里增加一个校验任务对生成后的class执行javap再定义一个简单的规则脚本grep所有lload/dload指令后面的索引是否符合预期。维护这种校验脚本的投入不大但能拦住一大批肉眼难以发现的字节码问题。还有一个更实用的建议在代码评审阶段只要看到别人写的ASM代码里有visitVarInsn(Opcodes.LLOAD, X)这类调用立刻停下来问一个问题“这个方法的描述符是什么”如果对方不能立刻说出该方法的参数槽位布局基本可以断定这里会埋雷。我个人的体会是字节码插桩不是写Java它更像是直接面对虚拟机规范越是在细节上保持敬畏越能躲开这些隐蔽的坑。下次再看到Rejecting invocation long or double parameter at index 1 is not a pair你应该知道这不是莫名其妙的环境问题而是某个long或double参数没能成对地把车位停好。
返回列表