接口响应时间不理想时,排查过程通常需要知道每个方法究竟消耗了多少时间。手写日志虽然直接,但侵入性强,改动分散,上线后还容易忘记清理。面向切面编程虽然可以减少源码改动,却受限于 Spring 容器和动态代理机制,静态方法、私有方法、构造器以及未被容器托管的类都很难覆盖。ASM 框架的作用是在字节码层面插入统计逻辑,不修改源码、不依赖 AOP 容器,就能统一采集方法耗时,很多 APM 工具的链路追踪也采用类似方案。

一、为什么字节码插桩比传统方案更彻底
传统 AOP 的典型实现是 Spring AOP。它通过动态代理为 Bean 创建代理对象,在调用目标方法前后执行增强逻辑。这种方式要求目标类必须被 Spring 容器托管,并且只能作用于 public 方法调用;私有方法、静态方法、final 类、构造器以及 JDK 代理只能基于接口的限制,都让 AOP 在统计方法耗时场景下显得力不从心。如果系统里包含大量工具类或静态方法,AOP 覆盖不到,统计结果就会出现明显缺口。
字节码插桩则不同。它不关心类是否被容器管理,也不关心方法修饰符。ASM 读取 class 文件的字节流,在方法体入口和出口直接写入计时指令,然后再生成新的 class 字节流。对于运行时加载的类,可以通过 Java Agent 在类加载阶段做 transform;对于已经打包好的应用,也可以在构建阶段完成插桩。这种方案对业务代码零侵入,统计逻辑统一维护,后续升级或下线只需要调整插桩组件。
ASM 本身是一个轻量级字节码操作框架,核心模型是 ClassReader、ClassVisitor、MethodVisitor 和 ClassWriter。ClassReader 负责解析原始字节码,ClassVisitor 在访问类结构时进行拦截,MethodVisitor 负责处理方法内部指令,ClassWriter 最终输出修改后的字节码。虽然直接使用 MethodVisitor 编写字节码门槛不低,但 asm-commons 提供的 AdviceAdapter 可以大幅简化在方法入口和出口插入代码的工作。
二、基于 AdviceAdapter 实现耗时统计
实现方法耗时统计的关键是在方法刚进入时记录一个开始时间,在方法正常返回前再次获取当前时间,两者相减得到耗时。开始时间通常使用 System.nanoTime(),因为它的精度更高,适合纳秒级测量。为了避免中间结果被其他逻辑覆盖,需要把开始时间保存到局部变量表,AdviceAdapter 的 newLocal 方法可以帮我们分配一个可用的局部变量槽位。
下面这段代码定义了一个 ClassVisitor,在访问每个方法时判断是否跳过构造方法,其他方法则包装成自定义的 MethodVisitor。CostMethodVisitor 继承 AdviceAdapter,在 onMethodEnter 中调用 System.nanoTime() 并保存返回值,在 onMethodExit 中再次调用 System.nanoTime(),减去开始时间后调用 CostReporter.report 完成上报。
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
import org.objectweb.asm.commons.AdviceAdapter;
public class CostClassVisitor extends ClassVisitor {
public CostClassVisitor(ClassVisitor classVisitor) {
super(Opcodes.ASM9, classVisitor);
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor,
String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
if (mv == null || name.equals("<init>")) {
return mv;
}
return new CostMethodVisitor(mv, access, name, descriptor);
}
static class CostMethodVisitor extends AdviceAdapter {
private final String methodName;
private int startTimeVar;
protected CostMethodVisitor(MethodVisitor mv, int access, String name, String descriptor) {
super(Opcodes.ASM9, mv, access, name, descriptor);
this.methodName = name;
}
@Override
protected void onMethodEnter() {
mv.visitMethodInsn(Opcodes.INVOKESTATIC,
"java/lang/System", "nanoTime", "()J", false);
startTimeVar = newLocal(Type.LONG_TYPE);
mv.visitVarInsn(Opcodes.LSTORE, startTimeVar);
}
@Override
protected void onMethodExit(int opcode) {
mv.visitMethodInsn(Opcodes.INVOKESTATIC,
"java/lang/System", "nanoTime", "()J", false);
mv.visitVarInsn(Opcodes.LLOAD, startTimeVar);
mv.visitInsn(Opcodes.LSUB);
mv.visitLdcInsn(methodName);
mv.visitMethodInsn(Opcodes.INVOKESTATIC,
"com/example/CostReporter", "report", "(JLjava/lang/String;)V", false);
}
}
}
代码中的 onMethodEnter 会在方法体第一条指令前被调用,onMethodExit 则会在每个正常返回指令前触发。这里使用 Type.LONG_TYPE 告诉 AdviceAdapter 需要一个 long 类型局部变量。方法描述符 ()J 表示 System.nanoTime() 没有参数并返回 long,上报方法的描述符 (JLjava/lang/String;)V 表示接收一个 long 和一个 String,没有返回值。
CostReporter 是业务侧自定义的接收类,简单实现如下。实际生产环境通常不会直接 System.out.println,而是把耗时放入异步队列或写入监控系统,避免统计逻辑影响主流程。
public class CostReporter {
public static void report(long costNanos, String methodName) {
long costMillis = costNanos / 1_000_000;
System.out.println(methodName + " cost " + costMillis + " ms");
}
}
AdviceAdapter 的优势在于自动处理不同返回指令、局部变量索引以及操作数栈的平衡。正常返回包括 IRETURN、LRETURN、ARETURN、RETURN 等,AdviceAdapter 会分别在这些指令前调用 onMethodExit。如果直接继承 MethodVisitor 手写,这些细节非常容易出错。
三、插桩后的类如何加载与验证
完成 MethodVisitor 的改写逻辑后,还需要用 ClassReader 读取原始字节码,用 ClassWriter 输出修改后的字节码。下面是一个静态工具类的示例,transform 方法接收原始 class 字节数组,返回插桩后的 class 字节数组。
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
public class CostTransformer {
public static byte[] transform(byte[] originalClass) {
ClassReader reader = new ClassReader(originalClass);
ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES);
CostClassVisitor visitor = new CostClassVisitor(writer);
reader.accept(visitor, ClassReader.EXPAND_FRAMES);
return writer.toByteArray();
}
}
ClassWriter 构造器中传入 COMPUTE_FRAMES 可以让 ASM 重新计算栈帧,但配合 AdviceAdapter 和新增局部变量时,建议传入原始 ClassReader 以保留原有类型的帧信息。ClassReader 的 accept 方法会驱动整个访问流程,传入 ClassReader.EXPAND_FRAMES 可以在需要时展开栈帧,减少计算失败的概率。最终 toByteArray 返回新的字节码。
如果只是本地验证,可以在类加载前读取 class 文件,调用 transform 后写入临时目录,再用自定义 ClassLoader 加载。更贴近生产的做法是编写 Java Agent,在 premain 中注册 ClassFileTransformer,通过 instrumentation.addTransformer 拦截类加载过程。构建期插桩则需要把 transform 逻辑接入 Maven 或 Gradle 的编译流程,对编译产物进行二次处理。
四、异常路径、构造器与性能开销的坑
上面的实现有一个明显局限:onMethodExit 只覆盖正常返回路径。如果方法在运行中抛出异常,JVM 会直接跳转到异常处理器或方法外,onMethodExit 根本不会执行,导致这次调用不会被统计。对于大多数耗时分析场景,正常返回已经能反映主要问题,但如果需要精确覆盖异常路径,就必须在方法体插入 try-finally 结构,或者在 athrow 指令前也执行统计逻辑。手动构造完整的 try-catch-finally 字节码比较复杂,可以借助 GeneratorAdapter 的 catchException 能力,先把整个方法体包进 try 块,在 catch 中上报耗时并重新抛出异常。
构造方法也是一个需要特别注意的地方。构造器的内部名称是 <init>,如果要对构造方法做统计,必须在调用 super 构造器之后才能插入统计代码,否则会违反 JVM 校验规则。示例中选择先跳过构造器,可以避免这类问题;如果需要统计,需要区分实例初始化方法,并在 onMethodEnter 中判断插入点。静态初始化块 <clinit> 同理,它由 JVM 调用,访问时机比较靠前,通常不建议插桩。
性能开销同样不能忽视。每个方法都插桩会引入两次 System.nanoTime() 调用、局部变量操作和一次上报。对于超高频调用方法,这部分开销可能比方法本身的执行时间还高。实际落地时可以设置包名或类名过滤,只对核心业务层插桩;也可以增加采样比例,例如每 100 次调用只统计一次,避免统计成本反噬系统性能。上报环节建议使用异步队列而不是同步 IO,因为 report 方法运行在业务线程内,同步写日志会放大耗时统计的副作用。
最后还需要注意 ASM 版本与 JDK 的兼容性。ASM 9 能支持较新的 class 文件版本,但若目标环境和 ASM 版本不匹配,可能无法解析最新字节码。插桩后最好用 javap 或 ASMifier 查看生成结果,确认局部变量表、栈帧和异常表都符合预期,避免类加载时出现 VerifyError。