Java 字节码操作库:选型的关键不是性能,是能不能跟上 JDK
实测把 2017 年的 CGLIB 3.2.5 和 Byte Buddy 1.6.14 放到 JDK 21 上:前者抛
InaccessibleObjectException,后者抛UnsupportedOperationException: Cannot define class using reflection,都生成不了类。字节码库既要跟上 class 文件格式的变化,也要跟上 JDK 对内部 API 的封装——版本跟进能力才是选型的第一指标。
本文对比四个常见库的定位,并用 JMH 实测直接调用、JDK 动态代理、反射、Byte Buddy 与 Spring CGLIB 代理的开销(JDK 21),结果见文末配套实验。选型顺序是 JDK 原生能力 → Byte Buddy → ASM:接口代理用 java.lang.reflect.Proxy,够用就不引入第三方库;需要代理类或做字节码增强时用 Byte Buddy;需要逐条控制指令时才用 ASM。判断依据不是微基准跑分,而是库跟不跟得上 JDK 版本,以及团队能不能承受调试成本。
一、四个库的定位
| 库 | 抽象层次 | 典型用户 | 现状 |
|---|---|---|---|
| ASM | 最低,直接操作字节码指令,基于访问者模式 | 几乎所有上层库(含 CGLIB、Byte Buddy)、编译器工具 | 活跃,跟随 JDK 同步更新 |
| Javassist | 中等,可以用 Java 源码字符串生成方法 | 老项目、教学 | 维护缓慢 |
| CGLIB | 中等,专注生成子类代理 | 历史项目、Spring 内部重打包版本 | 原版已停止维护 |
| Byte Buddy | 高,DSL 风格 API | Mockito、Hibernate、各类 Agent | 活跃,通常最快支持新 JDK |
实测三个旧版本在 JDK 21 上生成一个类:
CGLIB 3.2.5 → InaccessibleObjectException: Unable to make protected final
java.lang.Class java.lang.ClassLoader.defineClass(...) accessible:
module java.base does not "opens java.lang" to unnamed module
Byte Buddy 1.6.14 → UnsupportedOperationException: Cannot define class using reflection
Javassist 3.29.2 → 正常生成并调用类前两个失败在同一个地方:它们都靠反射调用 ClassLoader.defineClass 来定义新类,而 JDK 16 起默认强封装 java.base 的内部实现,这条路被堵死了。Byte Buddy 的新版本和 Spring 重打包的 CGLIB 都改用了 MethodHandles.Lookup 这类正式 API。另一类常见失败是库不认识新的 class 文件版本,所以字节码库和 JDK 是强耦合的。升级 JDK 时,这类依赖要一并升级——它们不像业务库那样可以「先放着」。
二、性能:先看数量级
JDK 21 上用 JMH 实测同一个 greet(String) 方法(1 个 fork,预热 3 秒,测量 5 秒),代理都只做透传:
| 调用方式 | 单次耗时 | 说明 |
|---|---|---|
| 直接调用 | 0.72ns | JIT 内联后几乎没有开销 |
| Byte Buddy 子类代理 | 0.70ns | MethodDelegation 生成普通方法调用,被内联 |
| JDK 动态代理 | 2.42ns | 经过 InvocationHandler,再用反射调用目标 |
| Spring 重打包的 CGLIB 子类代理 | 2.61ns | MethodInterceptor + invokeSuper |
反射 Method.invoke | 7.57ns | 参数数组、访问检查 |
对比一次网络往返:同一台机器上 redis-benchmark 测得的 GET 中位数约 0.12ms,也就是 120,000ns,见 容量评估与秒杀设计 的配套实验。代理开销占比在万分之一以下。
所以选型时不要拿微基准的倍数吓自己。真正需要关心性能的是「每个请求要做几万次这样的调用」的场景,比如序列化框架的字段访问、ORM 的属性映射。
三、什么时候用哪个
3.1 优先:JDK 自带
// 接口代理:JDK 原生支持,无需任何依赖
Greeter proxy = (Greeter) Proxy.newProxyInstance(
Greeter.class.getClassLoader(),
new Class<?>[]{Greeter.class},
(p, method, args) -> {
long start = System.nanoTime();
try {
return method.invoke(target, args);
} finally {
log.info("{} 耗时 {}ns", method.getName(), System.nanoTime() - start);
}
});限制很明确:只能代理接口。目标类没有接口时才需要往下看。
3.2 需要代理类:Byte Buddy
Class<?> dynamic = new ByteBuddy()
.subclass(RealService.class)
.method(ElementMatchers.named("handle"))
.intercept(MethodDelegation.to(Interceptor.class))
.make()
.load(getClass().getClassLoader())
.getLoaded();注意:基于子类的代理无法代理 final 类和 final 方法,这也是 Spring AOP 的已知限制。
3.3 需要极致控制:ASM
只有在写框架、编译期插桩、Java Agent 时才需要直接用 ASM。它要求理解字节码指令和栈映射帧,代码量和维护成本都高得多。
3.4 新选择:Class-File API
JDK 24 起,java.lang.classfile 作为正式 API 提供(JDK 22、23 为预览,见 JEP 484),JEP 的目标之一是让 JDK 自己的组件改用它,最终移除 JDK 内部的 ASM 副本。在 JDK 25 上实测把一个类里的字符串常量换掉:
ClassFile cf = ClassFile.of();
ClassModel model = cf.parse(bytes); // javac --release 21 编译的类
CodeTransform replace = (b, e) -> {
if (e instanceof ConstantInstruction ci && "old".equals(ci.constantValue())) {
b.ldc("new"); // 换掉这一条指令
} else {
b.with(e); // 其余指令原样保留
}
};
byte[] rewritten = cf.transformClass(model, ClassTransform.transformingMethodBodies(replace));改写后的类仍是原来的版本号(主版本号 65),加载、通过校验、调用都正常;栈映射帧由 API 自动重算,代码里不用管。
它和 ASM 的差别主要在版本边界上。同一个类用 JDK 26 编译(主版本号 70)后,实测:
| 解析方 | 结果 |
|---|---|
| JDK 25 的 Class-File API | Unsupported class file version: 70 |
| JDK 26 的 Class-File API | 成功 |
| ASM 9.10.1 | 成功 |
| ASM 9.7.1 | Unsupported class file major version 70 |
Class-File API 跟着运行它的 JDK 走:Java Agent 这类在同一个 JVM 里处理类的场景,永远不会遇到比自己新的类文件,不用再为每次 JDK 升级等字节码库发新版。但构建期工具如果运行在旧 JDK 上、处理新 JDK 编译的类,就会读不了,这时反而是及时升级的 ASM 更灵活。
四、代价:调试与启动
性能之外,字节码生成的真实成本在别处:
- 栈轨迹变长:异常栈里会出现
$Proxy0、XxxSubclass$$EnhancerBySpringCGLIB$$...,定位问题要先看懂这些帧。 - 调试器里看不到源码:断点打不进生成的类。
- 类加载增多:每个代理类都是一个新类,大量生成会增加 Metaspace 占用与启动时间。
- 反射与注解:代理类上的注解不会自动继承,
getAnnotation可能返回 null——Spring 的AnnotationUtils就是为了处理这类问题。 - 与 AOT、原生镜像冲突:GraalVM 原生镜像下运行时生成类受限,框架通常改用编译期生成。
代理在框架里的用法,见 设计模式速查 的代理一节。
配套实验
- codesphere-labs/java/call-and-mapping-cost:五种调用方式的 JMH 结果,CGLIB 3.2.5、Byte Buddy 1.6.14、Javassist 3.29.2 在 JDK 21 上的表现(验证记录)
- codesphere-labs/java/classfile-api:在 JDK 25 上用 Class-File API 生成与改写类文件,JDK 25、26 的 API 与 ASM 9.10.1、9.7.1 读取 JDK 26 类文件的结果(验证记录)
参考资料