
如果让我选一个 Java 里最值得弄明白、又最容易被忽略的底层能力动态代理一定排在前三名。JDK 原生动态代理和 CGLIB 动态代理几乎出现在每一个成熟项目的角落里——Spring 的事务、拦截器、日志切面本质上就是这两个东西在工作。这篇内容我会从静态代理的痛点讲起把 JDK 原生动态代理和 CGLIB 动态代理的原理、代码、字节码细节和工程选型一次讲清楚。零基础的同学可以照着示例一步步跑通有经验的老手可以直接跳到第 4 节看容易踩的坑和 Spring AOP 的选择逻辑。1. 从静态代理的痛点说起动态代理究竟解决的是哪个问题1.1 代理的两个角色与一段最小可运行的静态实现代理这个设计模式藏在“间接”两个字里。你在代码里调用的不是真正的目标对象而是一个中间层中间层再帮你去调用真正的目标。为什么非要绕这一下最常见的理由是想在调目标方法前后插入通用逻辑又不想改原来的实现。先看一个最小例子。假设有个接口定义了一个用户查询操作public interface IUserService { String getUserName(Long uid); } public class UserServiceImpl implements IUserService { Override public String getUserName(Long uid) { return user: uid; } }现在要给这个方法加耗时统计。如果直接改UserServiceImpl侵入性太强业务逻辑和统计逻辑混在一起以后想去掉还要动核心代码。于是写一个代理类让它实现同一个接口内部持有目标对象public class UserServiceProxy implements IUserService { private final IUserService target; public UserServiceProxy(IUserService target) { this.target target; } Override public String getUserName(Long uid) { long start System.currentTimeMillis(); try { return target.getUserName(uid); } finally { System.out.println(调用 getUserName 耗时: (System.currentTimeMillis() - start)); } } }客户端变成这样调用IUserService proxy new UserServiceProxy(new UserServiceImpl()); proxy.getUserName(1001L);日志逻辑成功和业务解耦了。这种手写代理只要方法数量不多看起来完全够用。问题出在工程规模变大之后。1.2 静态代理三大硬伤样板代码、数量膨胀、无法复用静态代理的第一个硬伤是样板代码。上面只代理了一个方法真实系统里接口往往有十几个方法。每个方法都要写启动计时、调用 target、finally 里统计耗时代码结构完全一致属于典型的复制粘贴式开发。多加一个方法就要多改代理类一遍。第二个硬伤是代理类和目标类一一绑定。换一个接口、换一个业务类就要重新写一个代理类。系统里有十个 Service就要写十个代理类想加一种新能力比如权限校验又要再改动这十个代理类的代码雪崩式膨胀。第三个硬伤是逻辑无法复用。耗时统计、日志打印、异常转换这些横切逻辑理想情况下应该是一段可复用的代码。静态代理里它们被硬编码在每个代理类的方法里怎么抽都抽不干净。动态代理解决的就是这三个痛点不手写代理类而是运行时生成代理类同一段横切逻辑可以套到任意接口或类上目标方法数量再多也只要写一个回调逻辑。所以动态代理从来不是为了炫技它是把“代理”这个动作从代码行数里解放出来的一种基础设施能力。2. JDK 原生动态代理为什么必须面向接口运行时如何把代理类造出来2.1 三个核心参数与一个可跑的 InvocationHandler 实例JDK 动态代理从java.lang.reflect.Proxy这个类开始。它不要求你写代理类只要提供三样东西类加载器、目标对象实现的接口数组、一个InvocationHandler回调对象。import java.lang.reflect.Proxy; IUserService target new UserServiceImpl(); IUserService proxy (IUserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), // 参数1类加载器 target.getClass().getInterfaces(), // 参数2目标对象实现的接口 (proxyInstance, method, args) - { // 参数3InvocationHandler long start System.currentTimeMillis(); try { Object result method.invoke(target, args); return result; } finally { System.out.println(调用 method.getName() 耗时: (System.currentTimeMillis() - start)); } } ); proxy.getUserName(1001L);看到没目标方法的耗时逻辑只写了一遍后续IUserService接口里加再多方法这段代码都不用改。InvocationHandler的invoke方法接收三个参数代理对象本身、当前被调用的Method、方法实参数组。业务逻辑通常围绕method.invoke(target, args)展开也可以完全截胡比如做参数校验后不调用目标方法直接返回。参数 1 的类加载器要选对。用目标类的类加载器是最稳妥的因为代理类和目标接口本来就在同一个类加载体系里。常见的错误是随手传一个不相关的类加载器比如Test.class.getClassLoader()一旦接口和代理类在不同加载域就会抛ClassCastException或者加载失败。调用链路的实时流转顺序是客户端调代理对象的方法代理对象内部把这次调用转给InvocationHandler.invoke()invoke里通过反射Method.invoke()真正执行目标对象的方法最后把返回值原样传回客户端。2.2 反编译 $Proxy0看清 JDK 代理类生成的全貌只看 API 永远理解不透动态代理。JDK 在运行时生成的代理类并不是什么黑魔法它就是一个普通的 Java 类。想到内部去看一眼可以在启动参数里加上-Dsun.misc.ProxyGenerator.saveGeneratedFilestrueJDK 8 及之前版本用这个参数生成的类会写到项目根目录的com/sun/proxy/$Proxy0.class。新版 JDK 对应的参数名可能变化更通用的做法是运行时手动把字节码 dump 出来我用一个简单工具方法处理过效果一样。这个$Proxy0类反编译之后的结构很有代表性public final class $Proxy0 extends Proxy implements IUserService { private static Method m1; private static Method m2; private static Method m3; public $Proxy0(InvocationHandler h) { super(h); } public final String getUserName(Long uid) throws Throwable { try { return (String) super.h.invoke(this, m3, new Object[]{uid}); } catch (RuntimeException | Error e) { throw e; } catch (Throwable e) { throw new UndeclaredThrowableException(e); } } public final String toString() { // 类似逻辑调用 handler.invoke(this, m2, null) } public final boolean equals(Object obj) { // 类似逻辑调用 handler.invoke(this, m1, new Object[]{obj}) } static { try { m1 Class.forName(java.lang.Object).getMethod(equals, Class.forName(java.lang.Object)); m2 Class.forName(java.lang.Object).getMethod(toString); m3 Class.forName(com.demo.IUserService).getMethod(getUserName, Class.forName(java.lang.Long)); } catch (NoSuchMethodException e) { throw new NoSuchMethodError(e.getMessage()); } } }注意三点。第一代理类继承了java.lang.reflect.Proxy。Java 是单继承语言代理类已经把唯一的父类位置用掉了所以 JDK 动态代理只能通过实现接口来扩展行为。这就是“JDK 动态代理必须面向接口”的根本原因不是设计者不给支持而是 JVM 的类继承模型决定了它做不到。第二getUserName方法体就是一行super.h.invoke(...)。Proxy.h是构造前保存的InvocationHandler所以每个代理对象真正干活的是同一个回调而不同代理方法之间靠静态的Method对象区分。每个被调用方法都会提前反射并缓存到静态变量里。第三编译器会自动给Object的equals、hashCode、toString也生成代理方法。也就是说你对代理对象调用这三个方法同样会穿透到InvocationHandler里。很多人写动态代理时没注意这一点在invoke里对每个方法都无脑打日志结果一打印对象就疯狂输出原因就在这。2.3 常见误区和 Object 方法的一个细节初学者最容易误解的是“JDK 动态代理到底增强了谁”。很多人以为增强的是IUserService接口本身或者以为$Proxy0会包含UserServiceImpl的全部方法。实际上$Proxy0只关心接口列表和你传入的目标实现类没有直接关系。换句话说哪怕有个接口INoImpl没有任何实现类只要你给它一个InvocationHandler也能生成一个可用的代理对象。代理对象连目标方法都没有完全靠 handler 里写逻辑兜底。这听起来像投机取巧但某些测试脚手架、Mock 框架就是靠这个能力工作的。另一个细节是hashCode和equals的处理。因为代理类继承了Proxy而Proxy里的equals和hashCode又是通过 handler 代理的所以要小心循环调用。假如你的 handler 里调用了传入的 proxy 对象的toString()或者hashCode()就可能触发一次新的invoke再遇到 handler 里再次调用自身方法轻则无限递归重则栈溢出。我的习惯是 handler 回调里尽量避免主动调用代理对象自身的equals、toString实在需要调试信息就拼接普通字符串。到这里JDK 动态代理的优势和限制都很清楚了零依赖、使用简单、生成类只有一个但必须有接口调用依赖反射。接下来看 CGLIB 怎么解决“没有接口也能代理”的问题。3. CGLIB 动态代理继承一切真的可以“没有接口也代理”吗3.1 Enhancer MethodInterceptor首次跑通 CGLIB 代理CGLIB 不讲接口这件事它直接在字节码层面生成目标类的子类。子类天然继承了目标类的所有非私有、非 final 方法所以只要目标类不是 finalCGLIB 就能工作。先看一个最简单的 CGLIB 示例。这里目标类不再需要接口public class UserService { public String getUserName(Long uid) { return user: uid; } }用Enhancer创建一个代理对象import net.sf.cglib.proxy.Enhancer; import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; UserService target new UserService(); Enhancer enhancer new Enhancer(); enhancer.setSuperclass(UserService.class); enhancer.setCallback((MethodInterceptor) (obj, method, args, methodProxy) - { long start System.currentTimeMillis(); try { Object result methodProxy.invokeSuper(obj, args); return result; } finally { System.out.println(调用 method.getName() 耗时: (System.currentTimeMillis() - start)); } }); UserService proxy (UserService) enhancer.create(); proxy.getUserName(1001L);和 JDK 动态代理有明显区别这里设置的是setSuperclass不是接口数组回调是MethodInterceptor它有四个参数多出来的MethodProxy是 CGLIB 自己的调用句柄。最关键的是调用目标时用了methodProxy.invokeSuper(obj, args)这个写法会直接调用 CGLIB 生成的父类方法绕过反射性能更好。enhancer.create()默认走无参构造器。如果目标类没有无参构造器就要用带参数版本enhancer.create(new Class[]{String.class}, new Object[]{某个构造参数});这块是 CGLIB 和 JDK 动态代理的一个本质差异JDK 代理在内存里构造的是$Proxy0实例它和UserServiceImpl的实现类并无继承关系不需要调用目标类的构造器CGLIB 生成的子类在实例化时必然要调用父类构造器所以构造器有没有、参数是什么会直接影响代理能否创建成功。3.2 CGLIB 的底层策略子类重写、字节码增强与 FastClassCGLIB 生成的新类大体上做了三件事继承目标类、重写非 final 方法、在方法体里插入回调逻辑。重写的方法体不像手写那样复杂本质逻辑是判断当前是否有拦截器有就走拦截器没有就调用父类原始实现。为了理解这里为什么快得提到 CGLIB 额外生成的 FastClass 机制。它不是只生成一个代理子类还会生成两个辅助类专门为方法调用建立索引。代理子类和辅助类之间通过索引直接定位方法不需要反射去解析方法名和参数类型。打个不算夸张的比方JDK 代理的反射调用像按名字在通讯录里搜号码再拨号CGLIB 的调用像是提前把常用联系人存在手机快捷拨号键上。老版本 JDK 上这个差距很明显所以很多性能敏感的项目首选 CGLIB。现代 JDK 对反射做了不少优化两个方案在绝大多数业务场景下差距已经没那么悬殊但 CGLIB 在高频调用的边缘场景里依然是更稳的选择。要留意的是CGLIB 生成的不只是“一个子类”。enhancer.create()会一次性生成代理类、FastClass 辅助类等一整套结构类数量比 JDK 代理多。对启动阶段的影响就是加载类和校验字节码的开销更大。这也是为什么很多框架在运行时才创建代理对象时会体会到一个明显的顿挫感。3.3 final 类与 final 方法CGLIB 的边界清清楚楚CGLIB 不是万能钥匙。它靠继承工作而 Java 的继承圈定了两个禁区final 类不能有子类final 方法不能被子类重写。final 类直接无解。Enhancer.setSuperclass(FinalClass.class)一定会报错因为 CGLIB 压根生成不出子类。final 方法则是“静默失效”代理子类可以生成但重写不了 final 方法所以对这个方法的拦截不会生效实际执行的是父类原始逻辑。这个静默失效比报错更危险。Spring 事务里如果某个Transactional方法碰巧是 private 的或者类被final修饰事务拦截永远不生效但编译、启动都毫无异常只有运行期发现操作没有回滚时才会暴露问题。private 方法也类似。private 不可继承CGLIB 子类不存在重写 private 方法的机会所以这些方法天然不属于被拦截范围。要判断某个方法能不能被 CGLIB 拦截先看修饰符final、private、static这三个直接排除。4. 两者正面硬刚选型对比、性能实测与真实工程里的取舍4.1 八维度对比表与性能理解把两个方案放一起比较别只看“谁快谁慢”要从目标要求、依赖、实例化方式、调用机制等多个维度看。对比维度JDK 原生动态代理CGLIB 动态代理代理方式运行时生成接口实现类运行时生成目标类的子类目标类要求必须有接口目标类不能是 final方法要求只代理接口中声明的方法非 final、非 private、非 static 方法额外依赖无JDK 自带需要 cglib 相关 jar实例化代价不调用目标类构造器会调用父类构造器调用机制通过反射调用目标方法可通过 FastClass 索引直接调用父类方法生成类数量通常一个代理类代理类 多个辅助类隐蔽性类名形如$Proxy0类名形如UserService$$EnhancerByCGLIB关于性能我明确说一下实测体验。在一次业务封装 Demo 里我分别用两个方案代理同一个接口循环调用十万次统计耗时。JDK 8 环境下CGLIB 的总体耗时会比 JDK 代理低差距大约在几毫秒到几十毫秒量级取决于机器。放到业务系统里单次请求一次代理调用也就几微秒的差距几乎可以忽略。真正值得关心的是另一个方面JDK 代理每次调用都反射一次而 CGLIB 走 FastClass 索引在某些极端调优场景里这个差距会被放大。选型时我的判断顺序是有接口且没有特殊需求优先 JDK 动态代理目标是类、没有接口或者对性能有极致要求选 CGLIB框架内部已经默认一种方案时跟随框架配置不要私自混用。4.2 Spring AOP默认用谁、proxy-target-class 开关改了什么事Spring AOP 的代理选择集合了上面所有讨论。很多人只知道“Spring Boot 默认用 CGLIB”但实际细节值得梳理。在纯 Spring Framework 里如果一个 Bean 实现了接口AOP 默认用 JDK 动态代理如果 Bean 是普通类没接口就用 CGLIB。这个行为可以通过proxy-target-class开关改变。把proxy-target-class设为true后即使目标类实现了接口也强制用 CGLIB 生成子类代理。Spring Boot 2.x 起默认值被调整为true也就是说在 Boot 项目里大部分事务和切面代理默认走 CGLIB。这个开关变更带来的影响要理解透。用 JDK 动态代理时注入的类型必须是接口类型因为代理对象和实现类之间没有继承关系不能直接把代理对象转成实现类。强制开启 CGLIB 后代理对象本质上是目标类的子类可以安全转换成目标类类型。很多老项目在某个版本升级后发现注入报类型转换错误排查到最后基本都是这个开关从 false 变 true又恰好有人把字段类型写成了具体实现类。Spring AOP 的判断逻辑不复杂但它连接了前面所有知识点如果目标类是接口类型或者实现了接口默认倾向 JDK 代理一旦不允许接口代理就退到 CGLIB。了解了这两个动态代理的内部差异再去看 Spring 源码里的DefaultAopProxyFactory会非常顺畅。4.3 换着用容易踩的四个坑自调用、类型转换、序列化、构造器第一个坑自调用拦截失效。假设当前实例是代理对象内部某个方法调用了同一个实例的另一个方法public class UserService { public void outer() { inner(); // 直接 this.inner() } public void inner() { // 本意想被拦截 } }如果客户端通过代理对象调用outer()代理对象里执行的是重写后的outer()但outer()内部执行new this.inner()时this指向代理对象内部的目标对象还是原始对象。这个调用链根本不会经过代理方法所以inner()上的拦截逻辑全部失效。JDK 代理和 CGLIB 代理都会遇到这个问题。解决办法是让原始对象持有代理对象的引用或者在切面设计时避免内部调用依赖增强。第二个坑JDK 代理的类型转换。JDK 动态代理生成的$Proxy0只实现了接口没有继承任何实现类所以下面这行代码必炸UserServiceImpl impl (UserServiceImpl) Proxy.newProxyInstance(...); // ClassCastException只能转回接口类型。如果你代码里大量使用了具体类声明却用 JDK 代理做增强一旦被注入的其实是代理对象运行期就会现形。这就是为什么 Spring Boot 把默认切到 CGLIB 后能解决一部分项目长期隐痛的原因。第三个坑序列化和反序列化。CGLIB 代理类继承了目标类序列化时会同时保存父类字段和代理引入的字段反序列化时对辅助类的依赖经常造成ClassNotFoundException。如果你的对象需要跨进程传输谨慎给代理对象加序列化更安全的做法是传输原始的 DTO 或者 VO 对象而不是把被代理的 Service 对象直接序列化。第四个坑构造器副作用被重复触发。CGLIB 生成的是目标类的子类每次创建代理对象都会调用一次目标类的构造函数。如果构造函数里有初始化缓存、发送埋点这类副作用操作代理对象的创建过程会平白多出一次副作用。JDK 动态代理因为不碰目标类构造器就不存在这个问题。碰到难以理解的“为什么对象创建了两次”的问题时先想想是不是 CGLIB 代理。此外还有一个隐蔽点JDK 代理和 CGLIB 代理对Object方法行为不一致我在 2.3 提过 JDK 会拦截toString/equals/hashCode。CGLIB 的重写逻辑也会覆盖这些方法但它对Object方法的处理有自己的一套规则通常会把toString/equals/hashCode也交给拦截器。所以不管用哪个方案都别在拦截器里对getDeclaringClass()为Object.class的方法做一刀切式的日志处理否则日志系统里会不断出现对象的 toString 输出。回看整个动态代理体系JDK 原生动态代理胜在简单、标准、零依赖适合接口场景CGLIB 胜在能代理任意类方法调用路径更短适合类场景与高性能场景。二者不是替代关系而是互补关系。我自己在实际项目里的体会是先搞清楚目标类型是接口还是类再决定用哪套方案不要一上来就追求复杂工具。理解它们生成的字节码形态和调用链路之后再遇到 Spring AOP 失效、代理类型转换异常、事务不生效这类问题排查速度会快很多因为你能直接判断出当前走的是哪条代理路径然后顺着 handler 或者 methodProxy 的调用链去找问题根源。