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 自带的能力:接口代理用
java.lang.reflect.Proxy,方法句柄用MethodHandles,JDK 24 起还有正式的 Class-File API。够用就不要引入第三方库。 - 需要代理类(非接口)或做字节码增强时,用 Byte Buddy,它是目前维护最积极、API 最友好的选择,也是 Mockito、Hibernate 等项目的底层。
- CGLIB 已经停止维护,实测 3.2.5 在 JDK 21 上无法使用。Spring 至今使用的是重打包并持续修补的内部版本,不是原版依赖。
- 性能差距在业务场景里通常可以忽略:实测直接调用 0.72ns、JDK 动态代理 2.42ns、反射 7.57ns。一次数据库或 Redis 的网络往返至少是十万纳秒量级,代理开销是几纳秒。
- 真正的成本是调试与启动时间:生成的类没有源码、栈帧变长、类加载变多。
二、四个库的定位
| 库 | 抽象层次 | 典型用户 | 现状 |
|---|---|---|---|
| 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 的属性映射。
四、什么时候用哪个
4.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);
}
});限制很明确:只能代理接口。目标类没有接口时才需要往下看。
4.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 的已知限制。
4.3 需要极致控制:ASM
只有在写框架、编译期插桩、Java Agent 时才需要直接用 ASM。它要求理解字节码指令和栈映射帧,代码量和维护成本都高得多。
4.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 原生镜像下运行时生成类受限,框架通常改用编译期生成。
六、常见误区
- 「CGLIB 比 JDK 代理快」:这个结论来自很多年前的测试。实测 Spring 重打包的 CGLIB 代理 2.61ns、JDK 代理 2.42ns,差距可以忽略,而且 CGLIB 原版已经不能在新 JDK 上用了。
- 「Spring 用 CGLIB,所以 CGLIB 还在维护」:Spring 用的是重打包并自行修补的内部版本。
- 「反射慢,所以要用字节码生成」:先算清楚调用频次,多数业务代码的瓶颈在 I/O。
- 「生成的类和手写的一样」:调试体验、注解可见性、原生镜像支持都不一样。
小结
字节码库的选型顺序是:JDK 原生能力 → Byte Buddy → ASM。判断依据不是微基准跑分,而是三件事:目标能不能用接口代理解决、这个库跟不跟得上 JDK 版本、团队能不能承受调试成本。实测已经说明了最后一点的重要性——停止维护的库不是「用旧版本也行」,而是升级 JDK 那天直接不能启动。
代理在框架里的用法,见 设计模式速查 的代理一节。
配套实验
- 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 类文件的结果(验证记录)
参考资料