Skip to content

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 风格 APIMockito、Hibernate、各类 Agent活跃,通常最快支持新 JDK

实测三个旧版本在 JDK 21 上生成一个类:

text
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 时,这类依赖要一并升级——它们不像业务库那样可以「先放着」。

三、性能:先看数量级 ​

直接调用JIT 内联后几乎没有开销(0.72ns)1,396,900,000 次/秒JDK 动态代理透传到目标对象(2.42ns)412,600,000 次/秒反射 Method.invoke参数数组、访问检查(7.57ns)132,100,000 次/秒CGLIB 3.2.5 与 Byte Buddy 1.6.14 在 JDK 21 上都生成不了类:反射调用 defineClass 被模块强封装挡住字节码库必须跟随 JDK 升级,这是选型时最容易被忽略的成本
图 1 · JDK 21 上用 JMH 实测:直接调用与 Byte Buddy 生成的子类被 JIT 内联后几乎没有开销;JDK 动态代理约 2.4ns,反射约 7.6ns;这些差距在微秒级的业务里通常可以忽略(横轴为对数刻度)

JDK 21 上用 JMH 实测同一个 greet(String) 方法(1 个 fork,预热 3 秒,测量 5 秒),代理都只做透传:

调用方式单次耗时说明
直接调用0.72nsJIT 内联后几乎没有开销
Byte Buddy 子类代理0.70nsMethodDelegation 生成普通方法调用,被内联
JDK 动态代理2.42ns经过 InvocationHandler,再用反射调用目标
Spring 重打包的 CGLIB 子类代理2.61nsMethodInterceptor + invokeSuper
反射 Method.invoke7.57ns参数数组、访问检查

对比一次网络往返:同一台机器上 redis-benchmark 测得的 GET 中位数约 0.12ms,也就是 120,000ns,见 容量评估与秒杀设计 的配套实验。代理开销占比在万分之一以下。

所以选型时不要拿微基准的倍数吓自己。真正需要关心性能的是「每个请求要做几万次这样的调用」的场景,比如序列化框架的字段访问、ORM 的属性映射。

四、什么时候用哪个 ​

4.1 优先:JDK 自带 ​

java
// 接口代理: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 ​

java
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 上实测把一个类里的字符串常量换掉:

java
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 APIUnsupported class file version: 70
JDK 26 的 Class-File API成功
ASM 9.10.1成功
ASM 9.7.1Unsupported 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 那天直接不能启动。

代理在框架里的用法,见 设计模式速查 的代理一节。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权