JVM字节码工程:ASM、Javassist、ByteBuddy的场景选型
JVM字节码工程:ASM、Javassist、ByteBuddy的场景选型
适读人群:Java高级开发工程师、架构师、中间件开发者 | 阅读时长:约18分钟 | 适用JDK版本:JDK 8 / 11 / 17 / 21
开篇故事
2018年,我们团队做了一个性能监控SDK,需要在不修改业务代码的情况下,自动为每个Service方法埋点计时。
最初用Spring AOP实现——写切面,拦截所有@Service的方法,记录开始和结束时间。这个方案工作得很好,但有个局限:只能切Spring管理的Bean,无法覆盖工具类、Mapper层、非Spring的第三方代码。
后来有个需求,要监控Kafka Consumer的消费耗时,Kafka的消息处理是框架回调,不走Spring Bean,AOP切不到。
为了解决这个问题,我开始研究字节码操作库。最终用ByteBuddy写了一个Java Agent,在类加载时动态为目标方法插入计时代码。从此,不管是Spring Bean还是普通类,只要是JVM里跑的Java代码,都能被监控到。
这个SDK后来演化成了公司内部的APM(Application Performance Monitoring)系统的核心组件,被十几个团队使用。
今天,我把三个主流字节码操作库(ASM、Javassist、ByteBuddy)的特点和选型思路系统整理出来。
一、字节码操作的应用场景
字节码操作的核心价值是:在不修改源码的情况下,动态修改类的行为。
典型应用场景:
- APM(应用性能监控):在方法前后插入计时代码(如SkyWalking、Pinpoint)
- AOP框架:实现切面逻辑(如Spring AOP的CGlib,AspectJ)
- Mock框架:在测试中替换方法实现(如Mockito)
- 热修复:在线修复Bug,不重启服务
- 代码生成:根据元数据生成实现类(如MyBatis Mapper)
- 安全检测:在运行时检查方法参数合法性
二、原理深度解析
2.1 Java字节码基础
Java字节码是JVM的指令集,存储在.class文件中。理解字节码结构是使用字节码库的基础:
// 源代码
public int add(int a, int b) {
return a + b;
}
// 对应的字节码(javap -c 查看)
public int add(int, int);
Code:
0: iload_1 // 将第1个参数(a)压入操作数栈
1: iload_2 // 将第2个参数(b)压入操作数栈
2: iadd // 弹出两个int,相加,结果压栈
3: ireturn // 返回栈顶int值字节码操作的本质是:读取.class文件的字节数组,按照JVM规范解析其结构,修改其中的指令序列,生成新的字节数组,通过ClassLoader加载新版本的类。
2.2 三个库的定位对比
| 维度 | ASM | Javassist | ByteBuddy |
|---|---|---|---|
| API层次 | 字节码指令级别 | Java源码片段 | 高级DSL |
| 学习曲线 | 陡峭(需要理解字节码) | 中等 | 平缓 |
| 性能 | 最快(直接操作字节码) | 较慢(编译源码片段) | 快(编译期优化) |
| 类型安全 | 否 | 部分(字符串拼接) | 是(编译期检查) |
| JDK新版本适配 | 需要跟进 | 较慢 | 活跃维护 |
| 典型使用者 | Spring, Hibernate底层 | Arthas, 老项目 | SkyWalking, Mockito |
2.3 ASM的工作方式(访问者模式)
ASM使用访问者模式处理字节码:
// ASM示例:在方法开始前插入打印语句
public class TimingMethodAdapter extends MethodVisitor {
private final String methodName;
public TimingMethodAdapter(MethodVisitor mv, String methodName) {
super(ASM9, mv);
this.methodName = methodName;
}
@Override
public void visitCode() {
// 在方法体开始前插入:System.out.println("entering " + methodName);
// 操作:将常量压栈,调用println
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("entering " + methodName);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println",
"(Ljava/lang/String;)V", false);
super.visitCode();
}
}
// 使用ClassVisitor处理整个类
ClassReader reader = new ClassReader(originalBytes);
ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES);
ClassVisitor visitor = new ClassVisitor(ASM9, writer) {
@Override
public MethodVisitor visitMethod(int access, String name, ...) {
MethodVisitor mv = super.visitMethod(access, name, ...);
return new TimingMethodAdapter(mv, name);
}
};
reader.accept(visitor, 0);
byte[] modifiedBytes = writer.toByteArray();2.4 Javassist的工作方式(源码片段)
Javassist允许用Java源码字符串片段来修改方法:
// Javassist示例:在方法开始前插入计时代码
ClassPool pool = ClassPool.getDefault();
CtClass ctClass = pool.get("com.example.OrderService");
CtMethod ctMethod = ctClass.getDeclaredMethod("processOrder");
// 在方法开始前插入代码片段
ctMethod.insertBefore("{ long start = System.currentTimeMillis(); }");
// 在方法返回前插入代码片段(使用$_访问返回值)
ctMethod.insertAfter("{ System.out.println(\"cost: \" + "
+ "(System.currentTimeMillis() - start)); }");
// 捕获异常时的代码($e代表异常对象)
ctMethod.addCatch("{ System.out.println(\"Error: \" + $e.getMessage()); "
+ "throw $e; }", ClassPool.getDefault().get("java.lang.Exception"));
byte[] modifiedBytes = ctClass.toBytecode();2.5 ByteBuddy的工作方式(高级DSL)
ByteBuddy提供了类型安全的链式API:
// ByteBuddy示例:拦截方法,插入计时逻辑
public class TimingInterceptor {
@RuntimeType
public static Object intercept(@Origin Method method,
@SuperCall Callable<?> callable) throws Exception {
long start = System.currentTimeMillis();
try {
return callable.call();
} finally {
long cost = System.currentTimeMillis() - start;
System.out.println(method.getName() + " cost: " + cost + "ms");
}
}
}
// 创建子类(代理模式)
Class<?> proxyClass = new ByteBuddy()
.subclass(OrderService.class)
.method(ElementMatchers.any())
.intercept(MethodDelegation.to(TimingInterceptor.class))
.make()
.load(OrderService.class.getClassLoader())
.getLoaded();
// 或者使用Instrumentation API修改现有类(Java Agent中使用)
new AgentBuilder.Default()
.type(ElementMatchers.nameStartsWith("com.example"))
.transform((builder, type, classLoader, module, protectionDomain) ->
builder.method(ElementMatchers.any())
.intercept(MethodDelegation.to(TimingInterceptor.class)))
.installOn(instrumentation);三、诊断工具与命令
3.1 查看字节码
# 查看class文件的字节码(基础)
javap -c MyClass.class
# 查看详细字节码(包含常量池)
javap -verbose MyClass.class
# 查看字节码(更友好的格式,需要ASM工具)
java -cp asm-util.jar org.objectweb.asm.util.ASMifier MyClass.class
# 查看运行时JVM中的字节码(Arthas)
jad com.example.OrderService # 反编译3.2 验证字节码增强是否生效
# 使用Arthas验证(在运行时)
java -jar arthas-boot.jar
> jad com.example.OrderService processOrder
# 查看反编译结果,确认是否包含了增强代码
# 查看ClassLoader中加载的类版本
> classloader -c <hashcode> --load com.example.OrderService四、完整调优方案
4.1 选型决策树
需要字节码操作?
↓
是框架/中间件开发,需要最高性能?
↓ 是 ↓ 否
ASM 需要用Java源码片段描述逻辑?
↓ 是 ↓ 否
Javassist ByteBuddy(推荐)我的推荐:
- 业务开发、工具开发:首选ByteBuddy,API友好,不容易出错
- 中间件、性能敏感:考虑ASM,但需要深入理解字节码
- 快速原型、兼容旧项目:Javassist,上手快
4.2 完整的Java Agent实现(ByteBuddy)
// 使用ByteBuddy实现一个完整的方法计时Agent
public class TimingAgent {
public static void premain(String args, Instrumentation instrumentation) {
new AgentBuilder.Default()
// 匹配目标类
.type(ElementMatchers.nameStartsWith("com.example.service"))
// 忽略JDK内部类(避免循环依赖)
.ignore(ElementMatchers.nameStartsWith("java.")
.or(ElementMatchers.nameStartsWith("sun."))
.or(ElementMatchers.nameStartsWith("jdk.")))
// 字节码增强
.transform((builder, typeDescription, classLoader, module, protectionDomain) ->
builder
// 拦截所有public方法
.method(ElementMatchers.isPublic()
.and(ElementMatchers.not(ElementMatchers.isConstructor())))
.intercept(MethodDelegation
.withDefaultConfiguration()
.withBinders(Morph.Binder.install(Callable.class))
.to(new TimingInterceptor())))
.installOn(instrumentation);
}
}@Singleton
public class TimingInterceptor {
@RuntimeType
public Object intercept(@Origin Method method,
@Morph Callable<Object> callable) throws Exception {
String key = method.getDeclaringClass().getSimpleName()
+ "." + method.getName();
long start = System.nanoTime();
try {
return callable.call();
} finally {
long costMs = (System.nanoTime() - start) / 1_000_000;
MetricsRegistry.record(key, costMs);
}
}
}# MANIFEST.MF配置
Premain-Class: com.example.agent.TimingAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
# 构建包含MANIFEST的jar(Maven配置)
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Premain-Class>com.example.agent.TimingAgent</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
# 使用Agent
java -javaagent:timing-agent.jar -jar app.jar4.3 ASM实现字节码插桩(适合框架开发者)
// 用ASM在try-finally中插入计时代码
public class TryCatchTimingAdapter extends AdviceAdapter {
private final String methodName;
private int startTimeVarIndex;
public TryCatchTimingAdapter(MethodVisitor mv, int access,
String name, String descriptor) {
super(ASM9, mv, access, name, descriptor);
this.methodName = name;
}
@Override
protected void onMethodEnter() {
// long startTime = System.currentTimeMillis();
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System",
"currentTimeMillis", "()J", false);
startTimeVarIndex = newLocal(Type.LONG_TYPE);
mv.visitVarInsn(LSTORE, startTimeVarIndex);
}
@Override
protected void onMethodExit(int opcode) {
// long cost = System.currentTimeMillis() - startTime;
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System",
"currentTimeMillis", "()J", false);
mv.visitVarInsn(LLOAD, startTimeVarIndex);
mv.visitInsn(LSUB);
// 打印(实际项目中替换为metrics记录)
// System.out.println(methodName + ": " + cost + "ms");
// 省略打印字节码...
}
}五、踩坑实录
坑一:ByteBuddy在JDK 17的模块系统限制
用ByteBuddy做字节码增强,在JDK 17上报:java.lang.reflect.InaccessibleObjectException,无法访问java.lang包内的类。
JDK 17的强封装模块系统(Strong Encapsulation)默认不允许访问模块内部API。ByteBuddy内部需要访问一些JDK内部类来完成字节码操作。
解决方案:启动时加--add-opens参数:
--add-opens java.base/java.lang=ALL-UNNAMED
--add-opens java.base/java.util=ALL-UNNAMED或者使用ByteBuddy 1.14+(已经为JDK 17做了大量兼容性适配)。
坑二:ASM的ClassWriter.COMPUTE_FRAMES在某些情况下计算错误
使用ClassWriter.COMPUTE_FRAMES让ASM自动计算栈帧(stack frames),但在某些复杂的控制流(异常分支、循环)中,自动计算的栈帧可能不正确,导致运行时VerifyError。
解决方案:对于复杂情况,改用手动指定栈帧,或者在ClassWriter中重写getCommonSuperClass()方法,提供正确的类层次信息。
坑三:Javassist的字节码和原始字节码格式不一致
用Javassist修改了一个方法,然后用Arthas的jad反编译查看,发现生成的字节码有很多奇怪的中间变量和不必要的类型转换,比手写的字节码多了约30%的指令数。
Javassist生成的字节码质量不如手写的,对性能敏感的场景需要用ASM精确控制生成的指令。
坑四:字节码增强导致ClassLoader泄漏
用Javassist修改类时,如果修改后的类被定义到了一个新的ClassLoader中(Javassist有时会这样做),但旧的ClassLoader没有被正确回收,导致ClassLoader泄漏。
排查时发现,每次触发字节码增强,Metaspace就会增长一点点,最终触发Metaspace OOM。
解决方案:确保字节码增强的ClassLoader正确管理生命周期,使用defrost()释放Javassist的内部缓存,或者改用ByteBuddy(对ClassLoader的管理更规范)。
六、总结
三个字节码操作库各有侧重:
ASM是底层基础设施,直接操作字节码指令,性能最好,但学习曲线陡峭,需要深入理解JVM字节码规范。适合开发底层框架、追求极致性能的场景。Spring、Hibernate、MyBatis的底层都用了ASM。
Javassist是中间层,允许用Java源码字符串做字节码操作,降低了上手难度,但性能不如ASM,生成的字节码质量也较低。适合快速原型和维护遗留项目。
ByteBuddy是高层DSL,提供类型安全的链式API,API设计现代,活跃维护,对新版JDK的适配及时。是目前新项目的首选。SkyWalking的Java Agent、Mockito的Mock机制都基于ByteBuddy实现。
选型建议:新项目从ByteBuddy开始,只有在确实需要极致性能或者ByteBuddy无法满足的场景,再考虑下沉到ASM层。
