Java 字节码工程实战——手写一个方法耗时统计的字节码增强工具
Java 字节码工程实战——手写一个方法耗时统计的字节码增强工具
适读人群:有 Java 基础、对字节码工程或 AOP 底层原理感兴趣的工程师 | 阅读时长:约20分钟 | 核心价值:理解字节码增强的基本原理,用 ASM 和 ByteBuddy 手写一个方法耗时统计工具,掌握 Java Agent 的使用方式
一、为什么要学字节码工程
刚开始工作时,我以为字节码是底层黑科技,只有框架作者才需要懂。
后来有一次我需要对线上系统做性能分析,所有方法的耗时都需要统计,但方法有几千个,如果用 AOP 注解一个个加,不但工作量大,还要改代码、重新部署。
有没有办法不改业务代码,直接给所有方法加上耗时统计?有,这正是字节码增强的用武之地。
字节码增强让你在不修改源代码的情况下,在方法执行前后插入任意逻辑。这就是 Spring AOP、Arthas、SkyWalking、Jacoco 代码覆盖率统计等工具的底层原理。
这篇文章我用两个方案分别实现一个方法耗时统计工具:一是用 ByteBuddy(高级 API,推荐),二是用 ASM(低级 API,帮助理解原理)。
二、字节码基础
Java 编译后的 .class 文件是字节码,不是机器码,是 JVM 能理解的指令集。用 javap -c 可以查看字节码:
javap -c com.example.OrderService输出类似:
public long calculateTotal(int quantity, long price);
Code:
0: iload_1 // 把参数 quantity 压栈
1: i2l // int 转 long
2: lload_2 // 把参数 price 压栈(long 占两个槽)
3: lmul // 两数相乘
4: lreturn // 返回 long 结果字节码增强就是在这个层面,往指令序列里插入新的指令。比如在方法的第一条指令前插入"记录开始时间"的指令,在 return 指令前插入"计算并打印耗时"的指令。
三、方案一:ByteBuddy 实现(推荐)
ByteBuddy 是一个高级字节码操作库,提供了友好的 Java API,不需要了解字节码指令细节。
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.14.10</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<version>1.14.10</version>
</dependency>定义方法拦截器:
package com.example.agent;
import net.bytebuddy.implementation.bind.annotation.Origin;
import net.bytebuddy.implementation.bind.annotation.RuntimeType;
import net.bytebuddy.implementation.bind.annotation.SuperCall;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Method;
import java.util.concurrent.Callable;
/**
* 方法耗时统计拦截器。
* ByteBuddy 会把这个类的 intercept 方法织入到目标方法的调用点。
* @RuntimeType 表示允许运行时类型转换,支持各种返回类型的方法。
*/
public class TimingInterceptor {
private static final Logger log = LoggerFactory.getLogger(TimingInterceptor.class);
/**
* 方法拦截逻辑。
*
* @param method 被拦截的原始方法(通过反射获取方法信息)
* @param callable 调用原始方法的 Callable(通过 @SuperCall 注入)
*/
@RuntimeType
public static Object intercept(@Origin Method method,
@SuperCall Callable<?> callable) throws Exception {
long startNano = System.nanoTime();
String methodName = method.getDeclaringClass().getSimpleName() + "." + method.getName();
try {
// 调用原始方法
Object result = callable.call();
long elapsedMs = (System.nanoTime() - startNano) / 1_000_000;
log.info("[Timing] {} 耗时: {}ms", methodName, elapsedMs);
return result;
} catch (Exception e) {
long elapsedMs = (System.nanoTime() - startNano) / 1_000_000;
log.warn("[Timing] {} 异常,耗时: {}ms, 异常: {}", methodName, elapsedMs,
e.getMessage());
throw e;
}
}
}使用 ByteBuddy 做运行时增强:
package com.example.agent;
import com.example.service.OrderService;
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.implementation.MethodDelegation;
import net.bytebuddy.matcher.ElementMatchers;
/**
* ByteBuddy 运行时字节码增强示例。
* 动态生成 OrderService 的子类,覆盖所有 public 方法,
* 把方法调用委托给 TimingInterceptor。
*/
public class ByteBuddyDemo {
public static void main(String[] args) throws Exception {
// 创建 OrderService 的增强子类
Class<? extends OrderService> enhancedClass = new ByteBuddy()
.subclass(OrderService.class)
// 拦截所有 public 方法(除 Object 的方法)
.method(ElementMatchers.isPublic()
.and(ElementMatchers.not(ElementMatchers.isDeclaredBy(Object.class))))
// 委托给 TimingInterceptor
.intercept(MethodDelegation.to(TimingInterceptor.class))
.make()
.load(ByteBuddyDemo.class.getClassLoader())
.getLoaded();
// 使用增强后的类,和普通 OrderService 接口完全兼容
OrderService service = enhancedClass.getDeclaredConstructor().newInstance();
service.createOrder(/* ... */);
// 控制台输出:[Timing] OrderService.createOrder 耗时: 45ms
}
}四、方案二:Java Agent + ASM(深度理解原理)
ByteBuddy 底层是 ASM,直接用 ASM 能帮助你理解字节码增强的本质。更重要的是,Java Agent 允许在 JVM 启动时对任意类做字节码增强,不需要修改任何业务代码。
Java Agent 的 premain 入口:
package com.example.agent;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
/**
* Java Agent 入口类。
* JVM 启动时通过 -javaagent:timing-agent.jar 参数加载,
* premain 方法在 main 方法之前执行,注册字节码转换器。
*/
public class TimingAgent {
/**
* JVM 启动时调用(-javaagent 方式)。
*/
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("[TimingAgent] 启动,将对指定包的所有类做耗时统计...");
// 注册字节码转换器,对所有类加载时进行处理
inst.addTransformer(new TimingTransformer(), true);
}
/**
* 运行时动态 attach(适合 Arthas 这类工具的实现方式)。
*/
public static void agentmain(String agentArgs, Instrumentation inst) {
inst.addTransformer(new TimingTransformer(), true);
// 对已加载的类重新转换
try {
inst.retransformClasses(Class.forName("com.example.service.OrderService"));
} catch (Exception e) {
e.printStackTrace();
}
}
}ASM 字节码转换器:
package com.example.agent;
import org.objectweb.asm.*;
import org.objectweb.asm.commons.AdviceAdapter;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
/**
* 字节码转换器,使用 ASM 的 AdviceAdapter 在方法前后插入耗时统计代码。
* AdviceAdapter 封装了在方法入口和出口插入代码的常用逻辑,
* 比直接操作 MethodVisitor 更简单。
*/
public class TimingTransformer implements ClassFileTransformer {
/** 只对这个包下的类做增强 */
private static final String TARGET_PACKAGE = "com/example/service";
@Override
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
// 只处理目标包下的类
if (className == null || !className.startsWith(TARGET_PACKAGE)) {
return null; // 返回 null 表示不做修改
}
try {
ClassReader cr = new ClassReader(classfileBuffer);
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
// 使用自定义 ClassVisitor 处理每个方法
ClassVisitor cv = new TimingClassVisitor(cw, className);
cr.accept(cv, ClassReader.EXPAND_FRAMES);
return cw.toByteArray();
} catch (Exception e) {
System.err.println("[TimingAgent] 转换类失败: " + className + ", " + e.getMessage());
return null;
}
}
}
/**
* ASM ClassVisitor:访问类的每个方法,把 public 方法委托给 TimingMethodVisitor。
*/
class TimingClassVisitor extends ClassVisitor {
private final String className;
TimingClassVisitor(ClassVisitor cv, String className) {
super(Opcodes.ASM9, cv);
this.className = className;
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor,
String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
// 只对 public 非构造方法插入耗时统计
boolean isPublic = (access & Opcodes.ACC_PUBLIC) != 0;
boolean isConstructor = "<init>".equals(name);
if (isPublic && !isConstructor && mv != null) {
return new TimingMethodVisitor(mv, access, name, descriptor, className);
}
return mv;
}
}
/**
* ASM MethodVisitor:使用 AdviceAdapter 在方法入口和出口插入字节码指令。
*/
class TimingMethodVisitor extends AdviceAdapter {
private final String className;
private final String methodName;
/** 存放开始时间的本地变量槽索引 */
private int startTimeVar;
protected TimingMethodVisitor(MethodVisitor mv, int access,
String name, String descriptor, String className) {
super(Opcodes.ASM9, mv, access, name, descriptor);
this.className = className;
this.methodName = name;
}
/**
* 方法入口:插入 "long startNano = System.nanoTime();" 的等价字节码。
*/
@Override
protected void onMethodEnter() {
// 分配一个新的本地变量槽存放开始时间
startTimeVar = newLocal(Type.LONG_TYPE);
// 调用 System.nanoTime(),返回值留在操作数栈上
mv.visitMethodInsn(Opcodes.INVOKESTATIC,
"java/lang/System", "nanoTime", "()J", false);
// 把栈顶的 long 存到本地变量
mv.visitVarInsn(Opcodes.LSTORE, startTimeVar);
}
/**
* 方法出口(正常返回和异常两种情况都会调用):
* 插入耗时计算和日志输出的等价字节码。
*/
@Override
protected void onMethodExit(int opcode) {
// 调用 System.nanoTime() 获取结束时间
mv.visitMethodInsn(Opcodes.INVOKESTATIC,
"java/lang/System", "nanoTime", "()J", false);
// 减去开始时间:endNano - startNano
mv.visitVarInsn(Opcodes.LLOAD, startTimeVar);
mv.visitInsn(Opcodes.LSUB);
// 除以 1_000_000 换算成毫秒
mv.visitLdcInsn(1_000_000L);
mv.visitInsn(Opcodes.LDIV);
// 调用 System.out.println 打印(简化版,实际用 SLF4J 更好)
// 这里用一个静态辅助方法更简洁
mv.visitLdcInsn(className.replace('/', '.') + "." + methodName);
mv.visitMethodInsn(Opcodes.INVOKESTATIC,
"com/example/agent/TimingHelper",
"logTiming",
"(JLjava/lang/String;)V",
false);
}
}辅助方法类(被字节码调用):
package com.example.agent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 被注入的字节码调用的静态辅助方法。
* 这个类必须在 classpath 里,字节码增强时注入的 INVOKESTATIC 指令才能找到它。
*/
public class TimingHelper {
private static final Logger log = LoggerFactory.getLogger(TimingHelper.class);
/**
* 记录方法耗时日志。
*
* @param elapsedMs 耗时(毫秒)
* @param methodName 方法名
*/
public static void logTiming(long elapsedMs, String methodName) {
log.info("[ASM Timing] {} 耗时: {}ms", methodName, elapsedMs);
}
}五、打包 Java Agent JAR
Java Agent 需要在 MANIFEST.MF 里声明 Premain-Class:
<!-- pom.xml 中添加 Maven Shade 插件配置 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Premain-Class>com.example.agent.TimingAgent</Premain-Class>
<Agent-Class>com.example.agent.TimingAgent</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>使用方式:
java -javaagent:/path/to/timing-agent.jar -jar your-app.jar六、踩坑实录
坑1:ASM ClassWriter 的 COMPUTE_FRAMES 导致 StackOverflowError
现象:用 ASM 增强的类在运行时报 StackOverflowError,但原始类没有问题。
原因:ASM 的 ClassWriter.COMPUTE_FRAMES 需要在计算 stack frames 时加载类,如果类加载器有特殊逻辑(比如 OSGi),可能触发循环加载或递归调用。
解法:继承 ClassWriter 并重写 getCommonSuperClass 方法,避免在其中调用 Class.forName(),改用 ClassLoader 的字节码读取方式。这个坑相当隐蔽,我在接入 OSGi 框架时踩过。
坑2:ByteBuddy 增强后找不到类定义
现象:用 ByteBuddy 增强后的类,某些 instanceof 判断失败,原来应该返回 true 的变成了 false。
原因:ByteBuddy 默认生成子类,增强后的对象是 OrderService 的子类 OrderService$ByteBuddy$xxx,而原来的代码判断 obj instanceof OrderService 仍然是 true,但如果代码里有严格的 obj.getClass() == OrderService.class,就会失败。
解法:改用接口 + 子类注入的方式,或者使用 redefine/rebase 模式(ByteBuddy 直接修改原有类的字节码,而不是创建子类)。
坑3:Java Agent 在 Lambda 方法上无效
现象:字节码增强对 (a, b) -> a + b 这样的 Lambda 表达式不生效,Lambda 内部的耗时没有被统计到。
原因:Lambda 在 JVM 里被编译成 invokedynamic 指令,实际的方法体是在运行时动态生成的 $lambda$xxx 私有方法,不是普通的 public 方法,类加载时我们的过滤条件(isPublic)排除了它。
解法:如果需要统计 Lambda 内部耗时,需要去掉 public 过滤,同时在方法名过滤里加上 $lambda 的处理逻辑,或者改为对方法执行时间的采样统计,而不是插桩每个方法。
