尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

ASM字节码插桩实战:从方法耗时统计到VerifyError排查

ASM字节码插桩实战:从方法耗时统计到VerifyError排查 1. 当 javap 不够用的时候ASM 站在哪一层去年排查一个接口的性能问题日志里干干净净监控大盘上只有一条平均耗时抬到三倍的红线。最后定位到的原因是某个公共方法里多了一次正则编译调用方分散在七个模块里谁都没觉得自己有问题。那次之后我养成了一个习惯凡是公共方法、框架回调、外部 SDK 的入口一律用字节码插桩把进入和退出的时间戳打进日志不依赖业务代码自觉埋点。做这件事的工具就是 ASM——一个把 .class 文件当成可读写数据流的 Java 字节码操作类库。如果你只写业务代码ASM 大概率不会出现在你的依赖树里。但你每天用的 Spring、MyBatis、Lombok、Mockito、各类 APM 探针、热部署工具几乎都在用它。它处在 Java 技术栈里一个很特殊的位置往上是你能读懂的 Java 源码往下是 JVM 真正消费的字节码指令序列ASM 就是那道门上的把手。拧开它你能看清编译器在背后做了什么优化、注解处理器生成的代码到底长什么样、一个try-finally被改写成几条指令、字符串拼接从 Java 9 之后变成了什么形态。这篇文章不打算把 ASM 的全部 API 铺一遍那属于官方文档的活儿。我想讲的是一个平时写业务的 Java 开发者在什么情况下真的需要动字节码动的时候这条装配线上有哪几个部件插完桩为什么 JVM 会甩给你一个VerifyError以及怎么把这些东西稳稳当当地跑进生产环境。文中所有示例都围绕方法耗时统计这个最小可用场景展开因为它足够简单能让你把注意力放在机制上而不是业务逻辑上。1.1 三个真实场景逼着你去看字节码第一个场景是无侵入埋点。业务方要求给一批遗留接口加耗时统计这些接口分散在十几个 Jar 包里源码不归你管改一行都要走跨团队评审。此时唯一干净的做法是在类加载阶段拦截字节码在方法入口和出口各插一段计时指令。改的是字节码业务代码一行不动。第二个场景是框架层的统一增强。比如你写了一个内部 RPC 框架想让所有被RemoteService标注的接口实现类自动带上参数日志、异常包装、超时控制。用动态代理只能拦接口方法遇到final类、静态方法、构造器就束手无策。字节码增强没有这些限制它面对的是类本身。第三个场景是排查源码和运行时不一致的疑难杂症。你明明改了代码、明明重新打了包线上行为还是旧的。这时候把线上的 class 文件拉下来用javap -v或者 ASM 的TraceClassVisitor打一份指令清单出来一眼就能看到是不是依赖冲突加载了旧版本是不是注解处理器生成了意料之外的桥接方法是不是某个编译器插件偷偷改了方法体。这三个场景的共同点是问题发生在源码之下、JVM 之上那一层。你在这一层看不到变量名只有槽位编号看不到if-else只有跳转指令和行号表看不到泛型只有擦除之后的描述符。习惯这套视角需要一点时间但一旦建立起来很多玄学问题会立刻变成确定性推理。1.2 ASM 与 Javassist、ByteBuddy 的定位差异市面上的字节码工具大致分三档。最上面是 ByteBuddy它提供了接近自然语言的 DSL写一个拦截器可能就几行代码代价是抽象层厚、启动开销大、出问题时排查链路长。中间是 Javassist用类似源码的字符串来写要插入的代码编译期解析写起来直观但它对 Java 新版本语法的跟进总是慢半拍而且内部同样要做一层转换。最下面就是 ASM直接操作指令没有任何语法糖。这个分层决定了选型如果你的需求是给某个类的某个方法加个拦截器ByteBuddy 更省事如果你的需求是扫描全量类、按规则批量改造方法体、还要控制内存和启动时间ASM 才是合适的工具。ASM 的核心优势有两个一是流式 API 的内存占用极低读一个类、改一个方法、写出结果整个过程不需要把整棵类结构树常驻内存二是它对 class 文件格式的覆盖最完整从常量池、字段、方法、注解、泛型签名、到栈帧表、模块信息、记录类组件一个不落。代价也很明显你得自己管局部变量槽、自己算栈深度、自己处理跳转标签。写错一个字节JVM 不会告诉你第 37 行有问题只会甩一个VerifyError或者更糟的ClassFormatError。所以下面几章的重点会放在怎么保证写出来的字节码是合法的这件事上。2. ClassReader、ClassVisitor、ClassWriter 构成的一条装配线ASM 的整体设计非常像一条流水线ClassReader是原料供给端它把字节数组解析成一串有序的访问事件ClassVisitor是流水线上的工位每个工位可以选择处理、修改或者原样放行ClassWriter是成品出口它把收到的事件重新序列化成字节数组。三个部件之间不存中间状态事件推过去就过去了。理解这一点后面所有的坑都能找到根源。2.1 访问者模式把 .class 拍成了一串事件一个 class 文件在 ASM 眼里不是一棵树而是一条按固定顺序播放的事件流。顺序大致是visit类头信息→visitSource源文件名→visitNestHost/visitOuterClass嵌套与外部类信息→ 若干visitAnnotation类注解→ 若干visitAttribute非标准属性→ 若干visitInnerClass内部类表→ 若干visitField字段→ 若干visitMethod方法→visitEnd结束。每个visitField内部又会播一遍自己的子事件注解、类型注解、属性、visitEnd。每个visitMethod内部事件更多visitAnnotationDefault、visitParameter、visitAnnotation、visitTypeAnnotation、visitAttribute、visitCode方法体开始、若干条指令事件、visitMaxs栈和局部变量上限、visitEnd。指令事件又细分成visitInsn无操作数指令如IRETURN、DUP、visitVarInsn带局部变量索引如ILOAD 3、visitMethodInsn方法调用、visitFieldInsn字段访问、visitLdcInsn常量加载、visitJumpInsn跳转、visitLabel标签、visitTryCatchBlock异常表、visitLineNumber行号表、visitFrame栈帧等等。这种事件流的设计带来一个关键约束你只能按顺序处理不能回头。你在处理第 3 个方法的时候第 1 个方法的事件早就被ClassWriter写进输出缓冲区了。所以任何需要看完全部方法再做决定的改造流式 API 都做不了必须换成 Tree API第 5 章会讲。这个限制听起来难受但它正是 ASM 内存占用低的来源——同一时刻内存里只有一个方法的指令列表。2.2 accept 的一次遍历到底走过了哪些节点启动流水线的动作只有一行ClassReader cr new ClassReader(classBytes); cr.accept(classVisitor, ClassReader.EXPAND_FRAMES);accept做的事情是从字节数组的魔数0xCAFEBABE开始解析常量池读出类版本、访问标志、类名、父类、接口列表依次调用classVisitor的对应方法然后遍历字段表和方法表对每个成员创建对应的FieldVisitor/MethodVisitor并播完事件最后调visitEnd。第二个参数是解析选项常用的有几个选项作用什么时候用0全量解析包括调试信息和栈帧需要完整信息时SKIP_DEBUG跳过源文件名、行号表、局部变量表减小输出体积、加快处理SKIP_FRAMES跳过栈帧事件准备用COMPUTE_FRAMES重算时EXPAND_FRAMES把压缩帧展开成完整帧需要逐帧分析时SKIP_CODE完全跳过方法体只改类结构、字段、注解时这几个选项不是随便组合的。我见过最常见的误用是既传了EXPAND_FRAMES又让ClassWriter用COMPUTE_FRAMES重算。这两件事是矛盾的——一边要求把原始帧给你看一边又要求全部重算覆盖。正确的搭配是要重算帧就用SKIP_FRAMES或0要自己分析帧就用EXPAND_FRAMES并且别开COMPUTE_FRAMES。还有一个容易忽略的点SKIP_DEBUG会丢掉行号表。丢了之后线上异常堆栈里的行号会全部变成Unknown Source排查问题时能让人抓狂。我一般只在明确知道输出类不会被用于异常定位的场景比如纯生成的代理类才开这个选项。2.3 漏转发就会丢属性一份必查的转发清单ClassVisitor的默认实现有个很贴心的设计所有方法都会自动转发给构造时传入的下游cv。所以你的自定义 visitor 只需要覆写你关心的那几个方法其余的原样放行。这个设计让代码写起来很短但也制造了一个隐蔽的坑如果你覆写了某个visitXxx方法却没有调用super.visitXxx这条事件就断了。事件断了会发生什么取决于断了哪条。断了visitAnnotation类注解全丢断了visitInnerClass内部类表空掉运行时可能报InnerClasses attribute相关的错误断了visitNestHost/visitNestMemberJava 11 之后的嵌套类私有成员访问会直接失败断了visitPermittedSubclass密封类的继承关系丢失断了visitRecordComponent记录类的组件信息没了反射拿不到。这些错误都有一个共同特征编译期完全正常运行时在某个特定路径上炸。我在项目里通常会写一个兜底基类把所有与类结构相关的转发方法统一列一遍自定义 visitor 一律继承它public class SafeClassVisitor extends ClassVisitor { public SafeClassVisitor(int api, ClassVisitor cv) { super(api, cv); } Override public void visitSource(String source, String debug) { super.visitSource(source, debug); } Override public ModuleVisitor visitModule(String name, int access, String version) { return super.visitModule(name, access, version); } Override public void visitNestHost(String nestHost) { super.visitNestHost(nestHost); } Override public void visitOuterClass(String owner, String name, String descriptor) { super.visitOuterClass(owner, name, descriptor); } Override public void visitNestMember(String nestMember) { super.visitNestMember(nestMember); } Override public void visitPermittedSubclass(String permittedSubclass) { super.visitPermittedSubclass(permittedSubclass); } Override public void visitInnerClass(String name, String outerName, String innerName, int access) { super.visitInnerClass(name, outerName, innerName, access); } Override public void visitRecordComponent(String name, String descriptor, String signature) { return super.visitRecordComponent(name, descriptor, signature); } }同样的逻辑适用于MethodVisitor和FieldVisitor。MethodVisitor里最容易漏的是visitAnnotationDefault注解接口的默认值和visitParameter方法参数名虽然它主要给反射用FieldVisitor里最容易漏的是visitAnnotation和visitTypeAnnotation。一个自检办法改造完一个类之后用javap -v对比改造前后的输出看常量池、字段表、方法表、属性表是不是只多出了你预期的那几条。多出来的少了都不对。3. 第一个可运行的插桩给方法前后各塞一段计时逻辑搞清楚了流水线的构成可以上手做点实际的事了。目标很明确在指定包的每个方法入口记录System.nanoTime()在方法正常返回前算出耗时并打日志。这个需求足够小但把 ASM 里几个最容易出错的地方全都碰到了局部变量槽的分配、返回指令的识别、栈的平衡、maxs 的计算。3.1 手工算局部变量槽比想象中简单方法体里的局部变量不是按名字存的而是按槽位编号。实例方法的 0 号槽固定是this然后参数依次占槽静态方法没有this参数从 0 号槽开始。long和double占两个连续槽其余类型占一个。方法最多能用多少个槽由visitMaxs(maxStack, maxLocals)里的第二个参数声明。我们要插的计时逻辑需要临时存一个long起始时间这个变量必须放在所有已用槽之后避免覆盖某个参数或方法体内部的局部变量。计算方法很直接private static int computeNewSlot(int access, String descriptor) { int slot ((access Opcodes.ACC_STATIC) ! 0) ? 0 : 1; for (Type argType : Type.getArgumentTypes(descriptor)) { slot argType.getSize(); } return slot; }Type.getArgumentTypes((IJLjava/lang/String;)V)会返回[int, long, String]三个TypegetSize()分别是 1、2、1累计 4 个槽。如果这是实例方法this占 1 个参数从 1 号开始总共占 5 个槽新的long变量就放在 5、6 号槽。注意这里说的是参数占的槽不是方法实际用到的最大槽。方法体内部可能声明了更多局部变量但我们把新变量放到了参数之后紧接着的位置而方法体内部的局部变量会在编译期被分配到更高的槽位……等等这个说法不严谨。实际上 javac 编译时分配的局部变量槽会避开参数占用的槽但具体分配顺序取决于作用域。为了绝对安全应该用visitMaxs里声明的maxLocals作为基准。但流式 API 有个顺序问题visitMaxs是在方法体指令全部播放完之后才调用的而我们在第一条指令处就要用到新槽位。解决办法是用 ASM 提供的LocalVariablesSorter它会在转发过程中自动重新分配所有槽位并暴露newLocal(Type)方法LocalVariablesSorter sorter new LocalVariablesSorter(api, access, methodVisitor); int startSlot sorter.newLocal(Type.LONG_TYPE);LocalVariablesSorter会接管所有visitVarInsn把旧的槽位映射成新的你只管拿newLocal返回的索引往栈上存取就行。这是我在所有需要在方法体中间插入局部变量的场景下的首选方案比自己算槽位可靠得多。唯一要注意的是如果方法体里还有IINC指令循环计数器的自增LocalVariablesSorter同样会处理不用特殊对待。3.2 TimeMethodVisitor 的完整实现与逐行说明下面这个类是整个插桩的核心。它包装原始MethodVisitor在visitCode里记录起始时间在遇到返回指令时插入统计逻辑。public class TimeMethodVisitor extends LocalVariablesSorter { private final String className; private final String methodName; private final String descriptor; private int startSlot; public TimeMethodVisitor(int api, MethodVisitor mv, int access, String name, String descriptor, String className) { super(api, access, descriptor, mv); this.className className; this.methodName name; this.descriptor descriptor; } Override public void visitCode() { super.visitCode(); startSlot newLocal(Type.LONG_TYPE); mv.visitMethodInsn(Opcodes.INVOKESTATIC, java/lang/System, nanoTime, ()J, false); mv.visitVarInsn(Opcodes.LSTORE, startSlot); } Override public void visitInsn(int opcode) { if (opcode Opcodes.IRETURN opcode Opcodes.RETURN) { emitTiming(); } super.visitInsn(opcode); } private void emitTiming() { mv.visitLdcInsn(className # methodName); mv.visitMethodInsn(Opcodes.INVOKESTATIC, java/lang/System, nanoTime, ()J, false); mv.visitVarInsn(Opcodes.LLOAD, startSlot); mv.visitInsn(Opcodes.LSUB); mv.visitMethodInsn(Opcodes.INVOKESTATIC, com/example/agent/TimingLog, log, (Ljava/lang/String;J)V, false); } }逐段拆一下。LocalVariablesSorter的构造器签名是(int api, int access, String descriptor, MethodVisitor mv)注意参数顺序和普通MethodVisitor不一样descriptor在mv前面第一次写容易写反。visitCode里先调super.visitCode()把事件传下去然后申请一个新槽位并写入起始时间。这三条指令的栈变化是INVOKESTATIC nanoTime把long压栈占两个槽LSTORE startSlot弹出存到局部变量。执行完栈恢复平衡。visitInsn里判断是不是返回指令。IRETURN(172) 到RETURN(177) 是连续六条指令分别对应int、long、float、double、引用、void六种返回。这里用的是区间判断比写六个if干净。注意ATHROW不在这个区间里——异常退出路径我们不统计因为异常路径下栈上是一个Throwable再往里塞参数会让栈状态变得复杂而且异常路径的耗时统计意义不大。emitTiming的指令序列需要仔细验证栈的平衡。执行前栈上可能有一个返回值如果是IRETURN就是int如果是RETURN就是空的。此时依次压入String方法名、long当前时间、long起始时间然后LSUB弹出两个long压入一个long差值此时栈上是[返回值?, String, long]。最后INVOKESTATIC log(String, long)V弹出两个参数栈回到[返回值?]和插入前一致。栈平衡合法。有一点要特别注意visitMethodInsn的第四个参数是isInterface在 ASM 5 之后才有。调System.nanoTime时传false调接口方法时必须传true。这个参数传错运行时不会在加载阶段报错而是在执行到那条指令时抛IncompatibleClassChangeError。我在早期版本里就因为统一传false踩过一次排查了半天。3.3 挂到 ClassFileTransformer 上跑起来有了 visitor还需要一个入口把它接进类加载流程。标准的做法是写一个 Java Agentpublic class TimeAgent { public static void premain(String args, Instrumentation inst) { inst.addTransformer(new ClassFileTransformer() { Override public byte[] transform(ClassLoader loader, String className, Class? classBeingRedefined, ProtectionDomain pd, byte[] classBytes) { if (className null || !className.startsWith(com/example/biz/)) { return null; } try { ClassReader cr new ClassReader(classBytes); ClassWriter cw new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES); ClassVisitor cv new TimeClassVisitor(cw); cr.accept(cv, ClassReader.SKIP_FRAMES); return cw.toByteArray(); } catch (Throwable t) { return null; // 增强失败就退回原始字节码 } } }); } }几处细节值得说。第一className用的是内部名格式com/example/biz/Foo不是点号分隔判断前缀时别写成com.example.biz。第二返回null表示不修改JVM 会继续用原始字节码这是一个非常重要的兜底——任何增强逻辑抛异常都应该吞掉并返回null宁可少统计一个方法也不能让整个应用起不来。第三ClassWriter(cr, COMPUTE_FRAMES)这个构造器会把ClassReader的常量池复用到ClassWriter里省去重复构建常量池的开销同时COMPUTE_FRAMES会自动计算栈帧和maxStack/maxLocals我们不用操心visitMaxs的参数。配套的pom.xml需要在打包时写入 manifestplugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId configuration archive manifestEntries Premain-Classcom.example.agent.TimeAgent/Premain-Class Can-Retransform-Classestrue/Can-Retransform-Classes /manifestEntries /archive /configuration /plugin启动时加-javaagent:/path/to/agent.jar业务代码里什么都不用改日志就会自己冒出来。4. VerifyError 专治各种想当然栈帧与 maxs 的坑字节码写错了JVM 给的反馈并不友好。它不会告诉你第 12 条指令的操作数类型不对只会甩一句java.lang.VerifyError: Bad type on operand stack后面跟着一串看起来毫无意义的偏移量。这一章把最常见的几类错误拆开讲。4.1 COMPUTE_MAXS 和 COMPUTE_FRAMES 到底差在哪ClassWriter有两个自动计算选项。COMPUTE_MAXS只计算方法的maxStack和maxLocals两个数值不碰栈帧。COMPUTE_FRAMES除了这两个值还会重新计算每个跳转目标处的栈帧表StackMapTable这个表是 Java 7 之后验证器强制要求的。COMPUTE_FRAMES的开销明显更大因为它要做一次完整的数据流分析而且还要处理类型合并。什么时候必须用它只要你的插桩改变了控制流——新增了跳转、新增了异常处理器、在跳转目标处改变了栈上的类型——就必须重算帧。我们上面的计时插桩没有引入新跳转理论上COMPUTE_MAXS就够用。但实践中我一般直接开COMPUTE_FRAMES原因是插桩逻辑一旦变复杂比如要在每个catch块里加日志很容易不知不觉引入帧不一致与其事后排查不如一开始就交给它算。两者不能同时用COMPUTE_FRAMES已经隐含了COMPUTE_MAXS。写COMPUTE_FRAMES | COMPUTE_MAXS不会报错但没必要。还有一个性能层面的经验COMPUTE_FRAMES的耗时跟类的大小和方法数量基本成正比但对启动时间敏感的应用比如命令行工具、短生命周期的批处理任务全量类扫描加全量帧重算可能带来几百毫秒的额外启动开销。这时候可以只对目标包开启COMPUTE_FRAMES其余类直接返回null不要白白解析一遍。4.2 getCommonSuperClass 的默认实现为什么在 agent 里会炸COMPUTE_FRAMES在做类型合并时需要回答一个问题两个不同的引用类型它们的公共父类是谁这个方法的默认实现是protected String getCommonSuperClass(String type1, String type2) { ClassLoader loader getClass().getClassLoader(); Class? c Class.forName(type1.replace(/, .), false, loader); Class? d Class.forName(type2.replace(/, .), false, loader); // ... 用反射判断继承关系 }问题出在那个loader上。它是ClassWriter自身所在的类加载器而不是被增强类的类加载器。在 Agent 场景下Agent 的类通常由系统类加载器加载被增强的业务类由应用类加载器加载。当COMPUTE_FRAMES需要判断两个业务类的公共父类时用系统类加载器去Class.forName直接抛ClassNotFoundException。这个异常会从getCommonSuperClass里抛出来被ClassWriter包一层之后从toByteArray()抛出。如果你在transform里没有 catch异常会一路冒到 JVM类加载失败应用起不来。这也是为什么前面那段代码里我把整个增强逻辑包在了try-catch(Throwable)里。正确的做法是重写这个方法用ClassReader读取字节码本身来判断继承关系完全不碰类加载ClassWriter cw new ClassWriter(ClassWriter.COMPUTE_FRAMES) { Override protected String getCommonSuperClass(String type1, String type2) { if (type1.equals(type2)) return type1; ClassReader r1 openReader(type1); ClassReader r2 openReader(type2); if (r1 null || r2 null) return java/lang/Object; if (isAssignable(r1.getClassName(), type2)) return type2; if (isAssignable(r2.getClassName(), type1)) return type1; if (isInterface(r1) || isInterface(r2)) return java/lang/Object; String parent r1.getSuperName(); while (parent ! null !isAssignable(parent, type2)) { ClassReader pr openReader(parent); if (pr null) return java/lang/Object; parent pr.getSuperName(); } return parent null ? java/lang/Object : parent; } };openReader需要你自己实现思路是通过当前线程上下文类加载器或者传入的目标类加载器用loader.getResourceAsStream(name .class)拿到字节流。这段代码不长但它是 Agent 类工具能不能在复杂类加载环境下跑通的关键。我在一个多模块项目里就因为没重写这个方法导致某个模块的类只要含有if-else就加载失败而另外几个模块完全正常——因为那几个模块的类恰好不触发类型合并。4.3 线上问题定位时我常用的三段排查动作真遇到VerifyError别急着改代码先把出问题的字节码挖出来看。第一步定位到具体方法。异常信息里通常带着类名但方法名要靠-XX:TraceClassLoading或者 JVM 的详细错误输出-XX:ShowMessageBoxOnError不一定适用。更直接的办法是在transform里对目标类把cw.toByteArray()的结果 dump 到临时目录用javap -v -p打出来逐条对比原始字节码。第二步看StackMapTable。javap输出里的StackMapTable段会标明每个跳转目标处的局部变量和操作数栈类型。如果插桩引入了跳转但没重算帧这里会出现旧帧和新指令对不上的情况——最典型的表现是某个偏移量处的局部变量类型声明为top但后续指令却去读它。第三步怀疑局部变量槽。LocalVariablesSorter虽然可靠但它对一个东西无能为力long/double占用两个连续槽如果你用newLocal(Type.LONG_TYPE)拿到槽位 5那么 5 和 6 都被占用7 才是下一个可用槽。如果某处手动写了ALOAD 6就会读到long的高位的一半触发类型错误。所以我在所有需要long/double局部变量的场景里都坚持用newLocal从不手写槽位号。5. Tree API当你要改结构而不只是插一段指令流式 API 只能往前走遇到需要全局判断的场景就卡住了。比如找出所有被Deprecated标注的 public 方法给它们的方法体开头加一行警告日志——你得先知道类上有没有这个注解才能决定方法怎么改但类注解事件出现在方法事件之前其实是可以的。真正需要 Tree API 的场景更苛刻删除某个方法、给类加一个新字段、把两个方法合并、根据全量方法签名做重载消歧。5.1 ClassNode 把整个类摊开成一棵对象树Tree API 的核心是ClassNode它实现了ClassVisitor接口但行为是把所有事件收集到自己身上而不是转发。用完之后类结构完整地躺在一个对象里你可以随便遍历、修改、重排。ClassReader cr new ClassReader(classBytes); ClassNode cn new ClassNode(Opcodes.ASM9); cr.accept(cn, 0); for (MethodNode mn : cn.methods) { if (mn.name.startsWith(get) mn.desc.startsWith(())) { mn.access | Opcodes.ACC_PUBLIC; } } ClassWriter cw new ClassWriter(ClassWriter.COMPUTE_FRAMES); cn.accept(cw); byte[] result cw.toByteArray();ClassNode的字段对应 class 文件的各个部分version、access、name、superName、interfaces、sourceFile、visibleAnnotations、fields、methods、innerClasses、nestHostClass、recordComponents。MethodNode的instructions是一个InsnList内部是双向链表支持insert、insertBefore、insertAfter、remove、cleartryCatchBlocks是异常表localVariables是局部变量表maxStack/maxLocals可以直接改。内存代价需要说清楚。一个中等规模的类比如 Spring 的某个核心类几百行ClassNode展开后占用的内存是原始字节数组的十倍以上。如果你要在启动阶段扫描几万个类全用 Tree API 会把堆撑爆。我的做法是先用流式 API 快速判断这个类需不需要改只看类上的注解、实现的接口命中之后再用 Tree API 做精细改造。两级过滤兼顾速度和灵活性。5.2 插入、删除、改签名几个高频改造模板在方法体最前面插入一段打印是最常见的需求InsnList list new InsnList(); list.add(new FieldInsnNode(Opcodes.GETSTATIC, java/lang/System, out, Ljava/io/PrintStream;)); list.add(new LdcInsnNode(enter mn.name)); list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, java/io/PrintStream, println, (Ljava/lang/String;)V, false)); mn.instructions.insert(list);instructions.insert(list)会把这段指令插到链表的第一个位置。但要注意如果方法有try-catch异常表的起始标签可能就在第一个位置直接insert会让这段指令跑到try块外面——通常这没问题但如果你希望它在try内部就得用insertBefore(firstRealInstruction)精确控制。删除一个方法更简单cn.methods.removeIf(m - m.name.equals(legacyMethod))。但删完之后要检查有没有别的方法通过INVOKEVIRTUAL或INVOKESPECIAL调用它删了方法名却留着调用指令运行时就是NoSuchMethodError。这类引用检查得自己写ASM 不提供。改方法签名要注意三处同步MethodNode.desc、方法内部所有引用该方法的调用点、以及可能存在的Override注解注解本身不校验签名但语义上会变得奇怪。修改描述符之后原来方法体里的ALOAD/ISTORE槽位分配可能整体错位因为参数占用的槽数变了。我的经验是改签名这种操作最好在源码层做字节码层只适合做保持签名不变、只改实现的增强。还有一个高频需求是给类加字段比如给某个类注入一个Logger静态字段FieldNode loggerField new FieldNode( Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_FINAL, LOGGER, Ljava/util/logging/Logger;, null, null); cn.fields.add(loggerField);加完字段还得加静态初始化代码去赋值否则字段是null。如果类里没有clinit方法得新建一个有的话要在它的开头插入赋值指令。这块的逻辑比看起来麻烦因为静态字段的初始化顺序会影响final字段的语义插入位置错了可能导致常量折叠。5.3 AdviceAdapter 解决了构造器插桩的老大难构造器是插桩的高危区域。原因在于 JVM 的一条硬约束在调用super(...)或this(...)之前this引用不可用任何访问this字段或方法的指令都会验证失败。如果你在构造器入口直接插桩而插桩代码里调用了实例方法就会踩雷。ASM 的commons包里有AdviceAdapter专门处理这个问题。它继承自GeneratorAdapter提供onMethodEnter和onMethodExit(opcode)两个钩子。对于普通方法onMethodEnter在方法体开始处调用对于构造器它会自动检测super(...)调用把onMethodEnter推迟到super(...)之后。这个细节自己用流式 API 实现要写一大堆状态判断。public class TimingAdvice extends AdviceAdapter { private int startSlot; protected TimingAdvice(int api, MethodVisitor mv, int access, String name, String descriptor) { super(api, mv, access, name, descriptor); } Override protected void onMethodEnter() { startSlot newLocal(Type.LONG_TYPE); invokeStatic(Type.getType(System.class), new Method(nanoTime, ()J)); storeLocal(startSlot); } Override protected void onMethodExit(int opcode) { if (opcode Opcodes.ATHROW) return; visitLdcInsn(getClassName() # getName()); invokeStatic(Type.getType(System.class), new Method(nanoTime, ()J)); loadLocal(startSlot); math(GeneratorAdapter.SUB, Type.LONG_TYPE); invokeStatic(Type.getType(TimingLog.class), new Method(log, (Ljava/lang/String;J)V)); } }onMethodExit会在每条返回指令前调用包括ATHROW。传入的opcode告诉你是哪种返回据此可以判断要不要保留返回值GeneratorAdapter内部已经处理了返回值暂存你不用管。我在上面直接跳过了ATHROW因为异常路径的统计前面说过了意义有限。还有一个坑AdviceAdapter的构造器是protected的而且第一个参数是api跟ClassVisitor的 API 版本要保持一致。如果你用Opcodes.ASM9创建ClassVisitor却用Opcodes.ASM7创建AdviceAdapter会抛IllegalArgumentException: Unsupported api。统一用一个常量别东拼西凑。6. 从能跑到敢上线几件容易被忽略的收尾工作插桩跑通只是第一步真正麻烦的是让它稳定地跑在各种各样奇怪的生产环境里。这一章讲三个我反复踩过的方向。6.1 TraceClassVisitor 与 CheckClassAdapter 的双重自检改完字节码第一件事是验证它合法。ASM 提供了两个工具用途不同。CheckClassAdapter是一个校验器它包装ClassWriter或任意ClassVisitor在事件流过的同时做结构检查访问标志是否合法、常量池引用是否存在、方法描述符是否符合规范、栈帧是否完整。用法是把它插在流水线的最后一环ClassWriter cw new ClassWriter(ClassWriter.COMPUTE_FRAMES); CheckClassAdapter checker new CheckClassAdapter(cw, true); cr.accept(new TimeClassVisitor(checker), ClassReader.SKIP_FRAMES);第二个参数true表示检查数据流更严格也更慢。单元测试里我建议开着生产环境的 Agent 里我不开因为它的开销确实不小而且我们已经用COMPUTE_FRAMES保证栈帧正确了。CheckClassAdapter还有个静态方法verify(ClassReader, boolean dump, PrintWriter)它会把类加载到一个独立的类加载器里做真实验证能捕获ClassWriter自身漏掉的语义问题。这个更重只在调试时用。TraceClassVisitor则是打印器它把收到的所有事件以文本形式写出去格式接近javap -v。调试插桩逻辑时我一般这样做ClassWriter cw new ClassWriter(ClassWriter.COMPUTE_FRAMES); TraceClassVisitor tracer new TraceClassVisitor(cw, new PrintWriter(new FileWriter(/tmp/trace.txt))); cr.accept(new TimeClassVisitor(tracer), ClassReader.SKIP_FRAMES);跑一遍打开/tmp/trace.txt能看到插进去的每一条指令还能看到maxStack被算成了多少。有时候栈平衡问题一眼就能从这份清单里看出来。6.2 版本号、常量池复用与改了没生效ClassReader在解析时会把类版本号读出来ClassVisitor.visit的第一个参数就是它。这个值必须原样转发给下游不能写死成Opcodes.V1_8。如果原类是 Java 17 编译的major version 61而你写死了 52Java 8输出类会退化成 Java 8 版本如果它引用了 Java 9 之后才有的 API比如List.of运行时会抛NoSuchMethodError。更隐蔽的情况是类里用了invokedynamic或者密封类特性降级版本号会导致这些属性被忽略行为悄悄改变。ASM 自身也有版本兼容问题。ClassReader读到比自己支持的更新的类版本时会抛IllegalArgumentException: Unsupported class file major version 65。解决办法只有一个升级 ASM 版本。ASM 9.6 支持到 Java 229.7 支持到 Java 23出问题先去查版本对应表。常量池复用这个优化也有边界。new ClassWriter(classReader, flags)复用ClassReader已经解析出来的常量池能省不少时间和内存但如果你的改造涉及删除大量字段和方法复用会让常量池里残留一堆不再被引用的常量——这不影响正确性只是让 class 文件略微变大。真正需要注意的是另一个场景如果你基于ClassReaderA 创建了ClassWriter然后把 visitor 的输出接到了另一个完全不同的类上常量池会错乱生成出来的类引用会指向错误的符号。这种跨类复用是绝对的禁忌。改了没生效是另一类高频问题。排查顺序我一般是这样的先确认 Agent 真的被加载了启动参数里-javaagent拼写、jar 路径、manifest 里的Premain-Class全对再确认transform方法被调到了打一行日志注意别打太多会刷屏再确认className前缀匹配规则没写错是斜杠不是点号最后确认返回的字节数组非空。这四步能覆盖九成以上的没生效。6.3 打包、类加载与 retransform 的边界Agent 的打包有几种方式简单粗暴的是把所有 ASM 依赖用 shade 插件打进同一个 jar注意重定位包名避免和业务应用自带的 ASM 版本冲突。我就遇到过一次应用里已经有一个旧版 ASMAgent 用了新版两个版本在同一个类加载器里打架结果Opcodes常量对不上生成的指令全乱。用maven-shade-plugin的relocation把 ASM 挪到自己的命名空间下问题消失。重转换retransform的边界也得清楚。通过Instrumentation.retransformClasses重新转换一个已经加载的类时不能改变类的结构——不能加字段、不能加方法、不能改父类、不能改接口列表。只能改方法体。原因是类的结构在第一次加载时就已经定下来了方法表、字段表、vtable 都已经建好事后改结构会让 JVM 内部数据结构不一致。如果强行改retransformClasses会抛UnsupportedOperationException。这个限制直接决定了很多方案的设计。比如给类动态加一个字段缓存计算结果这种需求在 premain 阶段做没问题类还没加载在 attach 之后做就不行。我在设计增强方案时会先问清楚这个增强是在启动阶段做还是在运行中动态挂载前者自由度大后者只能在方法体里做文章。最后提一句类加载顺序。Agent 的premain执行时业务类还没加载此时拿到的Instrumentation可以对后续所有类生效。但如果 Agent 自己在premain里就触发了某个业务类的加载比如通过反射做配置解析那个类就不会再被transform拦截了——因为它已经加载完了。规避办法是把所有配置解析延迟到transform里按需做或者用纯文本配置别在启动阶段碰业务类。我在实际项目里用 ASM 做了三年多的增强最大的体会是这套 API 本身不难难的是对 class 文件格式的熟悉程度。指令写得对不对往往不取决于你对 ASM 的记忆而取决于你对 JVM 验证规则的理解。所以我的建议是动手写插桩之前先花半小时用javap -v把几个自己写的类翻一遍看看简单的一个if-else被编译成什么样、一个try-finally的异常表长什么样、字符串拼接在 Java 17 下用了哪些invokedynamic。等这些结构在脑子里建立起直觉再回头看 ASM 的visitXxx方法会发现它只是把这些结构换了个名字而已。还有一个实用的小技巧所有插桩逻辑都写一个开关通过 Agent 参数控制默认关闭。上线之后先在一个小流量的实例上打开观察一两天再全量。字节码层面的问题往往有延迟性类加载的时机、方法的热点分布、JIT 的编译决策都可能
返回列表