如果有人让我给Java面试题排个热度榜,“JDK动态代理与CGLIB实现的区别”大概率能进前五。这题难就难在它不是靠死记硬背能过关的——你背熟了“JDK基于接口、CGLIB基于继承”,转头看业务代码照样懵:为什么事务注解没生效?为什么AOP加在某个类上就报错?为什么代理对象一强转就炸?这些问题的答案,其实全都藏在两个动态代理机制的细节里。
这篇文章不打算复述教科书,而是从“一份代理代码到底是怎么跑起来的”这个角度,把JDK动态代理和CGLIB的生成方式、调用链、性能差异、选型策略和常见坑位逐个拆开,最后给你一张可以直接抄作业的决策清单。如果你在学AOP、读过一些框架源码却对Proxy和Enhancer一知半解,或者只是想让面试这道题答出层次,这篇文章都值得看完。
1. 先说清楚:为什么一个代理会有两种实现路子
1.1 从静态代理说起:代码是真的会腐烂的
假设现在要给订单服务加方法耗时统计。最朴素的写法是给每个方法前后掐表:
public interface OrderService { void createOrder(String orderNo); void cancelOrder(String orderNo); } public class OrderServiceImpl implements OrderService { @Override public void createOrder(String orderNo) { // 业务逻辑 System.out.println("create order: " + orderNo); } @Override public void cancelOrder(String orderNo) { System.out.println("cancel order: " + orderNo); } } public class OrderServiceProxy implements OrderService { private final OrderService target; public OrderServiceProxy(OrderService target) { this.target = target; } @Override public void createOrder(String orderNo) { long start = System.currentTimeMillis(); target.createOrder(orderNo); System.out.println("createOrder cost: " + (System.currentTimeMillis() - start) + "ms"); } @Override public void cancelOrder(String orderNo) { long start = System.currentTimeMillis(); target.cancelOrder(orderNo); System.out.println("cancelOrder cost: " + (System.currentTimeMillis() - start) + "ms"); } }这段代码有个很明显的气味:代理类里的增强逻辑,完全是围绕“接口方法逐个手抄”来写的。如果OrderService再加一个方法,OrderServiceImpl和OrderServiceProxy都要跟着改;如果想让日志、权限、事务这类横切逻辑同时作用在十个业务类上,你就要写十个结构几乎一样的代理类,复制粘贴到手软。
这种模式就是静态代理。它在教学场景里非常优雅,在真实工程里却很快会变成维护噩梦。原因很简单:增强逻辑和业务代码被绑死在了同一个代理类里,复用性和扩展性都撑不住。需求一变,代码就要重构,这就是“代码腐烂”最常见的一种表现。
1.2 动态代理的思路:不再手写,而是运行时生成
动态代理做的事情,本质上是把上一节里的“人工抄写代理类”换成“机器自动生成代理类”。你可以把静态代理理解成每门课都手工誊写一份PPT,动态代理则是准备好一套模板和一份数据,机器按模板批量生成PPT。这样增强逻辑可以抽成公共模块,代理类不再需要人工维护。
Java生态里,实现动态代理的主流方案有两套:一套是JDK自带的动态代理,它只认接口;另一套是CGLIB,它靠生成目标类的子类来干活。这两套方案一个官配、一个第三方,机制不同、限制不同、适用的对象也不同。搞清楚它们各自是怎么生成代理类、怎么拦截方法、又各自有哪些边界条件,才能真正理解那些AOP框架为什么一会儿用这个、一会儿用那个。
2. JDK动态代理:它的世界只有接口
2.1 InvocationHandler和Proxy:最小可运行示例
JDK动态代理从代码层面看,只有两个核心角色。一个是InvocationHandler,它定义了“当代理对象的方法被调用时,应该做什么增强”;另一个是Proxy,它负责拿着接口和InvocationHandler去生成代理对象。
import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; public class LogHandler implements InvocationHandler { private final Object target; public LogHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("before method: " + method.getName()); Object result = method.invoke(target, args); System.out.println("after method: " + method.getName()); return result; } }然后我们用Proxy.newProxyInstance创建代理对象:
public class JdkProxyDemo { public static void main(String[] args) { OrderService target = new OrderServiceImpl(); OrderService proxy = (OrderService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new LogHandler(target) ); proxy.createOrder("NO-2024-001"); } }运行结果非常直白:
before method: createOrder create order: NO-2024-001 after method: createOrder这个示例虽然短,但把JDK动态代理的三个关键参数暴露出来了:ClassLoader、接口数组、InvocationHandler。很多人在这一步会忽略一个事实:第二个参数是“接口列表”,而不是“目标类”。也就是说,你传进去的是接口,拿到的是实现了这些接口的代理对象。目标类在这里的作用,仅仅是被handler通过反射调用而已。
2.2 代理类的真面目:它在字节码层面做了什么
要理解“为什么JDK只认接口”,得看一眼JDK动态代理生成的代理类在字节码层面长什么样。Proxy.newProxyInstance内部会通过ProxyClassFactory生成一个新的类,这个类大致是这样的:
public final class $Proxy0 extends Proxy implements OrderService { private static Method m1; // hashCode private static Method m2; // equals private static Method m3; // toString private static Method m4; // createOrder public $Proxy0(InvocationHandler h) { super(h); } @Override public final void createOrder(String orderNo) { super.h.invoke(this, m4, new Object[]{orderNo}); } }关键信息就在extends Proxy implements OrderService这一行。Java是单继承的,代理类已经占用了Proxy这个父类位置,剩下的扩展能力只能通过接口来提供。所以JDK动态代理必须要求你传入接口,否则它生成的类就没有可以“挂靠”的类型了。
调用链也很清晰:调用方持有的是接口类型引用,真正的方法调用落到$Proxy0上,$Proxy0里的方法只做一件事——把调用转发给构造器传入的InvocationHandler.invoke。invoke里通过Method.invoke(target, args)反射调用真实目标,再把返回值交还给代理对象。
这也解释了为什么JDK动态代理无法代理没有接口的类:没有接口,它就缺了一张“可实现的契约”,也就无法生成一个能被安全替换目标的代理类。这套设计放在JDK 1.3那个年代,其实是非常合理的取舍:接口本身就是一种抽象边界,代理对象和真实对象都实现同一组接口,调用方不需要知道背后到底是哪个实例。
2.3 JDK代理的边界条件
JDK动态代理的边界条件,总结起来是三句话。第一,目标类必须实现至少一个接口,否则直接IllegalArgumentException。第二,只能拦截接口里声明的方法;实现类自己额外加的public方法,从接口引用根本看不到,自然也就代理不到。第三,代理对象不能被强转成目标实现类,因为它和目标类之间没有任何继承关系,转型一定失败。
这些边界条件在写AOP时候经常以隐蔽的方式冒出来。最典型的就是:你给某个Service类配了切面,切面确实生效了,但你在业务代码里用OrderServiceImpl这个具体类型去接收从容器里拿出来的Bean,结果强转直接ClassCastException。原因就是容器返回的是$Proxy0,它跟OrderServiceImpl没有血缘关系。
3. CGLIB:不靠接口,靠“改字节码生儿子”
3.1 当目标类没有接口时怎么办
JDK动态代理的“接口依赖”在大量老项目里是个硬伤——早年间很多Service类压根没抽象接口,类上直接摞方法。这种情况要动态代理,就得靠CGLIB。
CGLIB的核心是三件套:Enhancer负责创建代理对象,MethodInterceptor负责定义增强逻辑,MethodProxy负责在拦截后调用真正的目标方法。看一段最简代码:
import net.sf.cglib.proxy.Enhancer; import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; import java.lang.reflect.Method; public class OrderInterceptor implements MethodInterceptor { @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { System.out.println("before method: " + method.getName()); Object result = proxy.invokeSuper(obj, args); System.out.println("after method: " + method.getName()); return result; } } public class CglibProxyDemo { public static void main(String[] args) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(OrderServiceImpl.class); enhancer.setCallback(new OrderInterceptor()); OrderServiceImpl proxy = (OrderServiceImpl) enhancer.create(); proxy.createOrder("NO-2024-002"); } }注意,这里的OrderServiceImpl不需要实现任何接口,我们照样把代理对象强转成了OrderServiceImpl。这就是CGLIB和JDK动态代理最显眼的差异:CGLIB的代理对象是目标类的子类,所以可以直接按目标类型接收。
3.2 继承路线下的调用链
CGLIB能这么做,是因为它在运行时生成了一个目标类的子类。这个子类会重写目标类里所有非final、非private、非static的方法,在每个重写方法里插入对MethodInterceptor.intercept的调用。调用链大致是:
调用方 -> CGLIB生成的子类重写方法 ->MethodInterceptor.intercept->MethodProxy.invokeSuper-> 目标类原始方法
这里有个值得注意的性能细节:MethodProxy.invokeSuper并没有走纯反射,而是利用CGLIB额外生成的FastClass索引机制,在代理类和目标类之间建立方法索引,调用时直接定位并调用,省掉了一大截反射开销。这也是过去很长一段时间里,CGLIB在方法调用性能上被评价优于JDK动态代理的主要原因。
实际上CGLIB在生成子类时还会一并生成两个FastClass辅助类,一个用于描述目标类的快速调用索引,一个用于描述代理类的快速调用索引。所以CGLIB创建代理对象时的开销比JDK大,因为它干的活更多:生成两个类的字节码、加载、链接,再创建代理实例。
3.3 继承路线的天然限制
“继承”这条路从来不是没有代价的。CGLIB的天花板必须列清楚:
- 目标类如果是final的,直接没法生成子类,CGLIB当场趴窝。
- 目标方法如果是final的,子类无法重写,拦截不到。
private和static方法天然不可被重写,也不会进入拦截器。- 默认情况下,
Enhancer.create()会调用目标类的无参构造器来创建代理对象实例,如果目标类没有无参构造器,会报错;有些框架会额外接上Objenesis这类库来绕过构造器。 - CGLIB对包私有方法的重写能力和类所在的包名相关,跨包继承时包私有方法无法重写,从而无法被拦截。
所以在给“能不能CGLIB代理”下结论之前,先检查类和方法的关键字是最稳妥的做法。很多线上报错,最终追到根上就是某个final方法或者final类堵住了代理生成的路。
4. 六个维度硬碰硬:二者到底差在哪
4.1 创建成本与调用成本
讲动态代理区别的文章,几乎没有不提性能的。但性能这块鱼龙混杂,老说法很多已经过时。先把结论放在前面:不要盲目听信“JDK代理慢、CGLIB快”这种一刀切的说法,它至少在JDK 7之后就越来越不准了。
从创建成本看:JDK动态代理每拿到一组接口,生成一个$ProxyN类;CGLIB代理每拿到一个目标类,除了要生成代理子类,还要生成两个FastClass辅助类,期间涉及大量字节码操作和类加载。CGLIB的创建开销明显更重。如果你的应用启动时要为上千个Bean创建代理,这个差距是有感知的。
从调用成本看:早年JDK动态代理靠反射调用目标方法,CGLIB靠FastClass索引调用,两者差距明显;但JDK 7u之后对动态代理的反射调用做了持续的优化,调用性能差距已经缩小到绝大多数业务场景可以忽略。真正的差距要放在“每秒几十万次代理调用”这种高压场景里,借助压测工具才能量化出来。
结论是:性能不该成为你选型的首要理由。首要理由永远是被代理对象“长什么样”。
4.2 代理对象的身份:能强转成什么
这是二者在工程上差异最显著的一点。JDK动态代理生成的代理对象,只实现了传入的接口,和具体实现类没有任何继承关系,所以你不能把代理对象强转成实现类;CGLIB生成的代理对象是目标类的子类,强转成目标类或者目标类实现的接口都没问题。
这种身份差异会直接影响依赖注入、缓存key、类型判断这类逻辑。举个例子:一个方法接收OrderServiceImpl类型的参数,如果你传入的是JDK代理对象,编译期没问题,运行期直接ClassCastException;换成CGLIB代理对象就安然无恙。这也是很多老项目从JDK代理切换到强制CGLIB代理后,某些平时要强转的地方突然不再报错的原因。
4.3 方法可见性对代理能力的影响
两种代理在“谁能被拦截”这件事上的覆盖范围也完全不同。我用一张表格把规则列清楚:
| 方法类型 | JDK动态代理 | CGLIB |
|---|---|---|
| 接口中的public方法 | 可以拦截 | 可以拦截 |
| 实现类独有的public方法 | 拦截不到(接口不可见) | 可以拦截 |
| protected方法 | 接口不识别 | 可以拦截 |
| 包私有方法 | 接口不识别 | 同包场景可以拦截,跨包不行 |
| final方法 | 无法拦截 | 无法拦截 |
| private方法 | 无法拦截 | 无法拦截 |
| static方法 | 无法拦截 | 无法拦截 |
从覆盖范围来看,CGLIB确实更“宽”,这也是它在AOP框架里经常被选作兜底方案的原因。但这并不代表CGLIB全面优于JDK——JDK代理胜在规范、轻量、无需引入第三方依赖,而且它对接口的强制要求反过来促使你面向抽象编程。
4.4 一张对比表收尾
把核心维度汇总成一张表,面试和做选型时可以直接翻:
| 对比维度 | JDK动态代理 | CGLIB |
|---|---|---|
| 实现机制 | 运行时生成代理类,实现传入接口 | 运行时生成目标类的子类 |
| 依赖 | JDK内置,无需第三方库 | 需要引入CGLIB/ASM相关依赖 |
| 被代理对象要求 | 必须有接口 | 普通类即可,但不能是final类 |
| 代理对象类型 | 只能按接口接收 | 可按目标类子类接收 |
| 方法拦截范围 | 仅接口中的public方法 | 非final、非private、非static方法 |
| 创建开销 | 相对较轻 | 相对较重(生成子类+FastClass) |
| 调用开销 | JDK 7u后已趋近CGLIB | FastClass索引,整体稳定 |
| 典型应用 | 面向接口编程的容器、AOP、切面 | 无接口的遗留类、部分框架默认方案 |
5. 框架里怎么选:默认策略背后的逻辑
5.1 AOP框架的选择顺序
跟着框架源码走一遍,你会发现主流的AOP实现选代理时基本遵循同一套规则:先看目标类有没有接口,有接口就优先用JDK动态代理;没有接口就自动切到CGLIB;如果用户显式要求强制使用CGLIB,那么哪怕目标类有接口,也一律生成子类代理。
为什么接口优先?一方面是历史原因,JDK动态代理出现得早,几乎所有经典框架都围绕接口设计过默认行为;另一方面是设计哲学,面向接口编程本来就更利于解耦、替换和单元测试。但现实工程里“没有接口的类”实在太多了,这些类要加事务、加权限、加审计,没有CGLIB兜底几乎寸步难行。
另外值得留意的是,新一代框架的默认倾向正在发生变化。很多框架在新版本里把默认代理改成了“优先CGLIB”,理由是CGLIB不需要强制接口,而且对具体类型更友好,能减少一部分“拿到接口代理却拿不到实现类”的强转问题。理解这种趋势背后的原因,比背下某个框架的默认值更有价值。
5.2 强制切换时需要注意什么
如果你在配置里显式开启了强制子类代理,有几件事必须提前确认。第一,目标类不能是final类,否则启动阶段就会报错。第二,被切入的方法不能是final方法,否则切面静默失效——这种情况最坑,不报错、没日志,功能就是起不来。第三,构造器行为会变化,CGLIB默认会调用父类无参构造器,如果你的目标类构造器里有初始化逻辑,要留心执行顺序变化带来的副作用。
我在实际项目里踩过一个印象深刻的坑:某个配置类里放了一个final方法,切面声明要拦截它,结果所有调用看起来都正常,可日志里根本没有切面的输出。排查了半天,最后反编译代理类才发现,final方法压根没被重写,拦截器根本没挂上。这个教训我后来写进了项目组的开发规范:被AOP管理的方法,一辈子不要加final。
5.3 一张决策清单
选型的时候照着下面这几条判断就行:
- 目标类是否实现接口?如果纯粹没有接口,别纠结,直接CGLIB。
- 接口是否完整暴露了你需要拦截的方法?如果切面只关心接口声明的方法,JDK动态代理够用且更清爽。
- 调用方是否需要用具体实现类接收代理对象?需要的话,老老实实用CGLIB。
- 目标类或方法是否出现了final、private、static?出现任何一个,都要重新审视切点设计,否则代理可能直接失效。
- 有没有极端的高并发代理调用热点?有的话启动后做一轮压测,用数据说话,别凭感觉选。
6. 绕不开的高频坑位(含排查链路)
6.1 自调用陷阱:为什么走完代理还是没增强
这是动态代理相关问题上榜率最高的一道题。场景长这样:同一个业务类里,userList()调用了userDetail(),切面只切了userDetail(),结果你从容器里拿到代理对象,调用userList(),发现userDetail()的切面完全没生效。
原因不复杂:内部调用用的是this,而this是原始对象,不是代理对象。代理对象只在“外部调用”那一层被使用,当代码进入真实对象内部后,this指向的就是原始实例,后续的方法调用自然不会再经过代理。打个比方,代理对象像门口的保安,你进大门时保安查了你的证件,但进入大楼后内部房间之间的走动,保安并不在场。
这个问题的排查链路通常是:确认拿到的确实是代理对象、确认调用入口走的是代理引用、确认目标方法没有final/private、确认不是类内部自调用。前三个都排掉之后,基本就是自调用问题。解决思路有三种:把内部调用改成通过代理引用调用、拆开两个方法到不同Bean,或者改用AopContext.currentProxy()这类工具获取当前代理,但后者需要额外开启暴露代理对象的开关。
JDK动态代理和CGLIB在这个问题上表现一致,它不属于某个实现方案的缺陷,而是“代理模式”这个设计本身的公共约束。
6.2 equals/hashCode/toString被代理后的怪异行为
动态代理还有一个容易被忽视的暗坑:Object类里的equals、hashCode、toString这三个方法,在两种代理机制下都可能被当作普通方法处理,从而产生怪异行为。
JDK动态代理的代理类会把这三个方法也转发给InvocationHandler.invoke。如果你的handler里统一处理所有方法,并且习惯性地打印代理对象,就可能触发toString递归调用,最终栈溢出。更隐蔽的是equals:两个不同的代理对象指向同一个目标对象时,equals结果取决于你的handler怎么处理,跟目标对象的equals实现可能完全不一致。
CGLIB在这方面有自己的封装,但同样不保证和你的直觉一致。我的规避经验是:handler里显式区分Object的三个方法,优先交给proxy自己处理或者直接返回固定逻辑,不要让它们走到通用的反射分支里去。如果业务上有按对象内容判断相等性的需求,主动调用目标对象的equals逻辑,而不是依赖代理对象的默认行为。
6.3 并发创建代理类时的ClassDefFoundError
这个坑年代稍微久远一点,但依然会出现在老JDK版本或者极端并发启动的场景里。现象是:多个线程同时开始为同一组接口创建动态代理时,某个线程加载代理类加载到一半,另一个线程也跑来加载同名的类,触发类定义竞态,抛出ClassDefFoundError。
CGLIB在生成代理类时同样存在类似的竞态窗口,因为每个代理类的生成和定义不是原子的。解决经验有几条:把代理对象的创建集中到应用启动阶段,用单例持有,避免运行时大量并发创建同类代理;条件允许的话升级JDK版本,新版对类加载竞态的处理要完善得多;一旦遇到这类问题,优先排查ClassLoader和类定义锁,而不是怀疑业务代码。
6.4 调试技巧:看一眼代理对象到底是谁
在业务代码里排查动态代理问题时,最快的定位方式是“看身份”。我会习惯性地在关键位置输出一行日志:
System.out.println(proxy.getClass().getName()); System.out.println(proxy instanceof OrderService); System.out.println(proxy instanceof OrderServiceImpl);如果输出的是com.sun.proxy.$Proxy0,说明走的是JDK动态代理;如果输出的是OrderServiceImpl$$EnhancerByCGLIB$$xxxx,说明走的是CGLIB。这两行类名信息,几乎能帮你在一分钟内判断出代理机制、代理类型以及强转是否安全。
这招排查速度极快。有一次同事拿了个“怎么切面突然不生效”的问题来,我让他先打印代理类名,结果发现他拿到的根本是原始对象而不是代理对象,后面的排查方向一下子就正了。多打印一行日志,往往比硬啃半天源码快得多。