前阵子面了一个三年经验的候选人,聊到Spring的@Transactional为什么能自动帮我们做事务提交和回滚,他说是AOP。我再问AOP底层靠什么实现,对方犹豫了一下,说“应该是动态代理吧”,但再往下问JDK动态代理和CGLIB有什么区别、代理对象是怎么生成的、为什么MyBatis只写一个Mapper接口就能执行SQL,他就答不上来了。
这个场景其实很典型。动态代理是Java面试中的高频考点,也是Spring AOP、MyBatis Mapper、RPC框架、日志埋点、权限拦截这些基础设施的公共底座。你平时在项目里用的每一个@Transactional、每一个@Async、每一个FeignClient,背后几乎都有动态代理在默默干活。但多数人对它的理解停留在“会用Proxy.newProxyInstance”这个层面,对底层原理、框架选型逻辑、以及那些真正会踩到坑,其实没怎么系统梳理过。
这篇文章我就把动态代理从头到尾拆一遍,包含JDK动态代理的手写实操、底层字节码生成逻辑、CGLIB的用法与对比、Spring和MyBatis里的真实应用场景,以及我在实际项目中踩过的坑。内容不算短,但对准备面试或者想真正理解框架底层的人来说,值得耐心看完。
1. 代理模式回顾:为什么静态代理撑不住真实需求
1.1 代理模式到底在解决什么问题
先回到最朴素的问题。假设你现在写了一个UserService,里面有一个saveUser方法,上线之后发现每个方法都要加耗时统计和日志打印。最粗暴的做法是直接在每个方法里塞一段System.currentTimeMillis()和logger.info,但这样业务代码会被大量非业务逻辑污染,而且统计逻辑一旦变化,所有方法都得跟着改。
代理模式解决的就是这个问题。它不直接修改目标类,而是创建一个代理对象,让调用方和真实对象之间隔一层。代理对象负责在调用真实方法前后做增强处理,比如打日志、做鉴权、控制事务。用代码说话,代理模式的核心角色有三个:抽象接口、真实对象、代理对象。抽象接口定义你能干什么,真实对象是真正干活的,代理对象是站在真实对象前面的“前台”,帮你挡掉杂事。
这种“不修改原代码,却在原方法前后附加行为”的能力,听起来很像AOP对吧?其实AOP就是代理模式在横切逻辑上的经典应用。所以理解动态代理之前,先把代理模式想明白,后面就顺了。
1.2 手写一个静态代理,看看痛点在哪
我写个小例子。假设有个发消息的功能:
public interface MessageSender { void send(String message); } public class SmsSender implements MessageSender { @Override public void send(String message) { System.out.println("发送短信: " + message); } }静态代理类长这样:
public class SmsSenderProxy implements MessageSender { private final MessageSender target; public SmsSenderProxy(MessageSender target) { this.target = target; } @Override public void send(String message) { long start = System.currentTimeMillis(); System.out.println("开始发送短信..."); target.send(message); System.out.println("耗时: " + (System.currentTimeMillis() - start) + "ms"); } }用起来也没问题,但项目一旦复杂起来,痛点就很明显:
- 如果MessageSender接口有二十个方法,代理类里就得写二十个转发方法,每个方法都要重复“记录时间-调用-计算耗时”这段逻辑。
- 如果接口新增一个方法,真实类和代理类必须同步修改,否则编译都过不了。
- 如果想让一个代理类同时代理多个不同类型的接口,基本做不到,因为代理类是在编译期写死的。
静态代理把“增强逻辑”和“转发逻辑”耦合在了一起,每增加一个接口就多一批模板代码。这时我们真正想要的是:能不能在运行期动态生成一个代理类,让它自动实现指定接口,并且把每个方法调用都转发给同一个处理器?这就是动态代理要干的事。
2. JDK动态代理从0到手写:核心API与第一个完整案例
2.1 JDK动态代理的两个主角:Proxy和InvocationHandler
JDK动态代理的核心只有两个东西,但很多人过了很久都没完全理解它们的分工:
java.lang.reflect.Proxy:负责在运行期生成代理类,它是整个机制的入口。InvocationHandler:你写的增强逻辑都放在它的invoke方法里,它是代理行为的“大脑”。
代理类生成之后,它实现了你指定的接口,但方法体里并不直接写业务逻辑,而是把每一次方法调用都转交给InvocationHandler.invoke()。换句话说,Proxy负责“造出代理对象”,InvocationHandler负责“代理对象接到方法调用后该干什么”。
这个设计非常巧妙,它把“生成代理类”和“定义增强逻辑”拆成了两个独立维度。一个InvocationHandler可以代理任何接口,只要Proxy能生成对应代理类;一个Proxy生成的代理对象也可以搭配不同的InvocationHandler来改变行为。真正做到了一份增强逻辑到处复用。
2.2 10分钟手写一个可运行的JDK动态代理
我直接上一个完整案例,场景就用当年最经典的“接口方法耗时监控”。先定义一个业务接口和实现类:
public interface UserService { void saveUser(String name); String getUserById(Long id); } public class UserServiceImpl implements UserService { @Override public void saveUser(String name) { System.out.println("保存用户: " + name); } @Override public String getUserById(Long id) { return "user_" + id; } }接下来写InvocationHandler,这是整个代理逻辑的核心:
import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; public class TimeCostInvocationHandler implements InvocationHandler { private final Object target; public TimeCostInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { long start = System.currentTimeMillis(); System.out.println("调用方法: " + method.getName()); // 通过反射调用真实对象的方法 Object result = method.invoke(target, args); long cost = System.currentTimeMillis() - start; System.out.println("方法 " + method.getName() + " 耗时: " + cost + "ms"); return result; } }然后写一个工具方法,用于创建代理对象:
import java.lang.reflect.Proxy; public class ProxyFactory { public static Object createProxy(Object target) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new TimeCostInvocationHandler(target) ); } }测试一下:
public class Main { public static void main(String[] args) { UserService userService = new UserServiceImpl(); UserService proxy = (UserService) ProxyFactory.createProxy(userService); proxy.saveUser("张三"); String user = proxy.getUserById(1L); System.out.println("查询结果: " + user); } }输出结果:
调用方法: saveUser 保存用户: 张三 方法 saveUser 耗时: 8ms 调用方法: getUserById 查询结果: user_1 方法 getUserById 耗时: 2ms跑通这个例子之后,你会发现动态代理的本质就一句话:调用方持有的是代理对象,代理对象把每次方法调用都转交给了InvocationHandler,Handler里做增强逻辑,再通过反射把真正的方法调用落到目标对象上。
2.3 invoke方法里的三个参数分别是什么
invoke方法有三个参数,每次写的时候都要理解它的含义:
proxy:当前生成的代理对象本身。它和你在外部拿到的那个对象是同一个,但基本不应该直接使用它。如果误用了,很容易踩到无限递归的坑,后面第7节详细说。method:正在被调用的方法反射对象。通过它你可以拿到方法名、参数类型、注解,也能通过method.invoke(target, args)调用真实对象的方法。args:调用方法时传入的实际参数列表。没有参数时它是一个null或空数组,所以写代码时最好做一下判空。
我见过很多刚接触动态代理的人,容易把method.invoke(target, args)写错成method.invoke(proxy, args)。这两种写法后果完全不同,能把代码跑出StackOverflowError,这个坑值得单独说。
提示:invoke方法内部无论多复杂,最后一定要return真实方法的返回结果,否则调用方拿到的永远是null,程序行为会莫名其妙。
2.4 为什么JDK动态代理要求目标类必须实现接口
这是JDK动态代理最常被问到的一个限制。原因在于虚拟机对Proxy生成的代理类有硬性要求:代理类本身已经继承了java.lang.reflect.Proxy类。Java是单继承的,所以代理类没法再继承你的业务类,只能通过实现接口来扩展能力。
这就注定了JDK动态代理只能对“接口”做代理。如果你的目标是UserServiceImpl这样的具体类,并且这个类没有实现任何接口,那么target.getClass().getInterfaces()拿到的是空数组,Proxy.newProxyInstance根本无从下手,直接报IllegalArgumentException。
那如果业务类就是没实现接口,怎么办?这就引出了后面要说的CGLIB动态代理。它不走接口路线,而是通过生成目标类的子类来代理,这正好绕开了JDK代理的单继承限制。
3. 扒开JDK动态代理的底裤:运行时字节码与类加载真相
3.1 Proxy.newProxyInstance内部到底做了什么
很多资料讲动态代理只讲到API使用层面,对底层原理语焉不详。我把关键链路梳理一下,看起来复杂,其实核心只有四步:
- 第一步:检查传入的接口列表。接口必须是接口类型、不能重复、不能是public接口但来自不同包,否则直接抛异常。
- 第二步:查询代理类缓存。代理类生成一次之后会被缓存,相同类加载器、相同接口列表的代理类,下次直接从缓存取,不用重复生成。
- 第三步:如果缓存没有,调用
ProxyClassFactory生成代理类的字节码。这一步是真正的技术核心,它用ProxyGenerator.generateProxyClass()方法在运行时拼接出一个$Proxy0的字节码数组。 - 第四步:通过
defineClass0这个native方法,把字节码数组交给JVM定义成真正的Class对象,然后反射实例化,绑定InvocationHandler。
这里注意一个细节:动态代理虽然叫“动态”,但并不是每次调用都去生成类。代理类只在第一次需要时生成,后续通过缓存复用。这个缓存机制后面会专门展开。
3.2 生成的代理类长什么样:$Proxy0解剖
JDK生成的代理类有一个通用命名模式:com.sun.proxy.$Proxy0、$Proxy1。为了验证它的真实结构,可以在运行时把生成的字节码保存下来,然后反编译看内部实现。
保存字节码有两种方式,不同JDK版本参数不一样:
// JDK 9+:放在main方法里启动参数或直接System.setProperty System.setProperty("jdk.proxy.ProxyGenerator.saveGeneratedFiles", "true"); // JDK 8及更早: System.setProperty("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");保存之后,反编译出来的$Proxy0结构大概是这样的(简化版):
public final class $Proxy0 extends Proxy implements UserService { private static Method m1; // equals方法 private static Method m2; // toString方法 private static Method m3; // saveUser方法 private static Method m4; // getUserById方法 public $Proxy0(InvocationHandler h) { super(h); } public final void saveUser(String name) { try { super.h.invoke(this, m3, new Object[]{name}); } catch (RuntimeException | Error e) { throw e; } catch (Throwable t) { throw new UndeclaredThrowableException(t); } } }从这个结构你可以看到几个关键信息:
- 代理类和目标类完全没有继承关系,它只是和目标类实现了同一个接口。
- 代理类构造器接收InvocationHandler,并将它传给父类Proxy保存。
- 每个接口方法内部都调用了
super.h.invoke(this, method, args),把调用转交给InvocationHandler。 - 原始方法Method对象被静态缓存了,所以代理内部并没有走
Class.getMethod()那种搜索过程,效率比普通反射场景高一些。 - 未受检异常直接抛出,受检异常被包在
UndeclaredThrowableException里。这就是为什么代理方法抛出受检异常时,你用catch (Exception e)往往接不到原始异常类,需要再unwrap一层。
3.3 代理类缓存的实现细节
JDK在Proxy类内部维护了一个WeakCache<ClassLoader, Class<?>[], Class<?>>类型的代理类缓存。这个设计有几个细节值得了解:
- 缓存key是类加载器和接口列表的组合。所以接口相同但类加载器不同,会各自生成不同的代理类,不会复用。
- 缓存值存的是WeakReference,GC时可能被回收。如果代理类被回收了,下次获取会重新走生成逻辑。
- 每个类加载器最多只能生成65535个代理类,因为生成的类名
$ProxyN里的N是一个short类型。
这个缓存直接解释了一个经典问题:为什么不是每次调用Proxy.newProxyInstance都会重新生成类。你真正常见到的代理对象其实只有那么几十个,大量调用都在走缓存查询。这也是动态代理性能没有很多人想象中那么差的原因之一。
3.4 从字节码层面看动态代理的性能问题
很多人一听到动态代理就担心性能,总觉得反射很慢。真实情况是:JDK动态代理在8之后做了很多优化,尤其是JVM对Method.invoke这一块的优化,在JIT热点路径上已经非常快。而且代理类里Method对象是静态保存的,省掉了方法查找开销。
真正影响性能的不是反射本身,而是你的InvocationHandler里做了什么。如果每次方法调用都在Handler里做大量字符串拼接、JSON序列化、DB查询,那才是瓶颈。代理只是一个转发层,不应该承载太多业务逻辑,这一点在工程实践中非常重要。
4. CGLIB动态代理实战与全面对比
4.1 为什么非接口类也需要代理
实际开发里大量对象是没有接口的。比如你引入了一个第三方jar包,里面的核心类就一个普通类,你想在调用它的方法时加监控,JDK动态代理直接没法用。再比如Spring容器里很多Bean只声明了具体类而不是接口,如果Spring AOP只能用JDK代理,这些Bean就永远没法增强。
CGLIB就是为了解决这个场景诞生的。它的思路是:既然不能通过接口代理,那就直接生成目标类的一个子类。子类继承目标类,并重写目标类的非final方法。调用方拿着这个子类实例当作目标类使用,方法调用被子类重写逻辑拦截,然后在重写方法前后做增强。
这里插一个知识点:CGLIB底层是靠ASM字节码操作库来生成子类字节码的。它和JDK代理的区别,本质上不是“有没有接口”,而是一个走接口、一个走继承。
4.2 用CGLIB手写一个动态代理案例
先引入依赖。用Maven的话:
<dependency> <groupId>cglib</groupId> <artifactId>cglib</artifactId> <version>3.3.0</version> </dependency>注意,Spring Boot的spring-core里已经内置了CGLIB的重打包版本,所以很多场景你不需要额外引这个依赖。但手动学习时建议单独引,不然容易和Spring内部版本搞混。
写一个不需要接口的目标类:
public class OrderService { public void createOrder(String orderNo) { System.out.println("创建订单: " + orderNo); } public String getOrderStatus(Long orderId) { return "PAID"; } }CGLIB的核心API是Enhancer和MethodInterceptor:
import net.sf.cglib.proxy.Enhancer; import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; public class CglibProxyFactory { public static Object createProxy(Class<?> targetClass) { Enhancer enhancer = new Enhancer(); // 设置父类 enhancer.setSuperclass(targetClass); // 设置回调 enhancer.setCallback(new MethodInterceptor() { @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { long start = System.currentTimeMillis(); System.out.println("CGLIB拦截方法: " + method.getName()); // 注意这里用的是 proxy.invokeSuper,不是 method.invoke Object result = proxy.invokeSuper(obj, args); System.out.println("方法耗时: " + (System.currentTimeMillis() - start) + "ms"); return result; } }); return enhancer.create(); } }测试代码:
public class CglibMain { public static void main(String[] args) { OrderService proxy = (OrderService) CglibProxyFactory.createProxy(OrderService.class); proxy.createOrder("NO123456"); String status = proxy.getOrderStatus(1001L); System.out.println("订单状态: " + status); } }输出结果:
CGLIB拦截方法: createOrder 创建订单: NO123456 方法耗时: 12ms CGLIB拦截方法: getOrderStatus 订单状态: PAID 方法耗时: 3ms这里要重点提示一个和JDK代理不同的地方:CGLIB的MethodInterceptor.intercept方法里有四个参数,其中第四个参数MethodProxy是CGLIB为每个方法额外生成的索引调用代理。它调用真实方法的方式是proxy.invokeSuper(obj, args),而不是JDK代理里那种method.invoke(target, args)。这个区别非常关键,因为invokeSuper走的是CGLIB优化过的FastClass机制,性能上通常比纯JDK反射要好。
顺带说一句,method.invoke(obj, args)在CGLIB里也可以调用真实方法,但因为obj本身是子类实例,实际调用会再次进入intercept逻辑,导致无限递归。所以不要混用,否则你会收获一个StackOverflowError。
4.3 JDK动态代理和CGLIB对比,一张表说清楚
很多人在面试时被问“JDK代理和CGLIB选哪个”,其实只要把下面这张表想明白,就基本能回答到位。
| 对比维度 | JDK动态代理 | CGLIB动态代理 |
|---|---|---|
| 代理原理 | 运行期生成实现接口的代理类 | 运行期生成目标类的子类 |
| 目标要求 | 目标必须实现接口 | 目标类不能被final修饰 |
| 方法要求 | 代理所有接口方法 | final方法无法被代理 |
| 生成方式 | 字节码拼接,JVM内部完成 | 基于ASM字节码操作生成子类 |
| 调用方式 | 反射Method.invoke | FastClass索引,直接方法调用 |
| 性能表现 | 初始化开销较小,长期调用经过JIT优化后不差 | 生成类开销略大,运行期FastClass调用通常更快 |
| 依赖关系 | JDK自带,无额外依赖 | 需要引入cglib依赖 |
| 使用场景 | 有接口的Spring Bean、MyBatis Mapper等 | 无接口的具体类、第三方库类 |
有一个容易记反的点:CGLIB运行期性能通常比JDK代理更好,但它的初始化开销更高,因为生成子类要做更多字节码操作。Spring的官方文档里对此有过讨论,但到了现代JDK版本,两者的差距已经没那么大。工程上优先考虑的是“目标类有没有接口”,而不是单纯为了性能选型。
4.4 Spring Boot为什么默认使用CGLIB
Spring的AOP历史上支持两种代理模式。默认行为在不同版本有变化:
- Spring Boot 1.x:如果目标类实现了接口,默认走JDK动态代理;没有接口才走CGLIB。
- Spring Boot 2.x及之后:默认开启
spring.aop.proxy-target-class=true,也就是只要Spring容器里有AOP需求,统一使用CGLIB。
这个变化背后有一个很现实的原因:JDK代理要求接口,如果某个Bean没有接口,之前Spring会悄悄降级到CGLIB,但等大家发现时往往已经产生了“某些Bean被代理、某些Bean没被代理”的不一致行为。统一强制CGLIB之后,行为一致性大大提升,代价是引入了CGLIB的那些限制(无法代理final类、final方法),但绝大多数业务Bean根本不会被final修饰,所以这个代价在工程上完全可接受。
5. 动态代理在主流框架里的真实打开方式
5.1 Spring AOP:一次调用穿越多个代理的拦截链路
Spring AOP在运行时做两件事:判断Bean是否满足切点表达式,如果满足就为它生成代理对象,然后把代理对象放进容器。后面所有依赖注入拿到的都是这个代理对象,而不是原始Bean。
这里有一个细节值得展开。当多个切面同时作用于一个Bean时,Spring会叠加代理。比如先加事务切面,再加日志切面,调用顺序可能是这样:
调用方 -> 日志代理 -> 事务代理 -> 真实Bean方法
这个链条能成立,靠的就是动态代理的多层包装。每个切面各自生成一个代理对象,前一个代理的InvocationHandler里持有下一个代理对象的引用,一层层forward下去。
但多代理也有一个副作用:如果某层代理把异常给吞了,后置切面逻辑不会执行;如果切面里抛出的异常类型和处理顺序设计得不好,事务可能一直回滚到你怀疑人生。Spring AOP虽然封装了复杂度,但理解代理链背后的转发逻辑,排查问题时非常有帮助。
5.2 MyBatis Mapper:只有接口没有实现类,为什么能执行SQL
MyBatis是动态代理最惊艳的应用之一。你在项目里写了一个UserMapper接口,里面声明了User selectById(Long id)方法,但你没有写任何实现类,为什么Spring注入Mapper的时候能拿到一个可用对象?
原因就是MyBatis在启动时扫描Mapper接口,针对每个接口调用Proxy.newProxyInstance生成代理对象,然后把Mapper方法的配置(对应的SQL语句、参数映射、返回类型)解析好之后绑定到一个MapperProxy的InvocationHandler里。当你调用selectById时,调用会被转发到MapperProxy.invoke,它根据方法名去查找对应的SQL,通过SqlSession执行查询,最后把ResultSet映射成User对象。
如果试着简化一下,其实核心逻辑长得像这样:
public class MapperProxy<T> implements InvocationHandler { private final Class<T> mapperInterface; private final SqlSession sqlSession; @Override public Object invoke(Object proxy, Method method, Object[] args) { // 根据方法签名找到对应的MappedStatement MappedStatement ms = sqlSession.getConfiguration() .getMappedStatement(mapperInterface.getName() + "." + method.getName()); // 执行SQL并返回映射结果 return sqlSession.selectOne(ms, args == null ? null : args[0]); } }这个场景里,动态代理的价值体现得淋漓尽致:框架只需要知道接口定义,就能在运行时替我们生成“实现类”。业务侧连一个实现都不用写,整个数据访问层就自动跑起来了。
5.3 其他常被忽略的动态代理应用场景
除了Spring和MyBatis,还有几个常见场景值得知道:
- Feign/RPC框架:声明一个远程接口,不需要实现,框架动态生成代理,把方法调用转换为网络请求。
- 日志切面:统一打印接口入参出参,不用每个方法手动打日志。
- 权限校验:方法级别的权限拦截,在代理层校验当前用户是否有权限执行。
- 缓存切面:方法级缓存,命中缓存直接返回,不命中再调真实方法并回填缓存。
- 幂等控制、分布式锁、接口限流:这类横切逻辑都适合在代理层统一处理,业务代码保持干净。
你会发现所有这些场景有个共同点:它们都是横切逻辑,不适合写在业务代码里,而动态代理提供了一种“不动业务代码、把公共逻辑插进去”的机制,这正是AOP思想的技术基础。
6. 动态代理的七个经典坑,几乎人人都踩过
6.1 在invoke里错误引用proxy导致无限递归
这是动态代理新手最容易踩的坑。代码可能长这样:
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 错误写法:proxy.getClass()或method.invoke(proxy, args) Object result = method.invoke(proxy, args); return result; }问题在于,proxy本身就是代理对象,对它再调用同一个方法时,又会进入invoke,然后再调用proxy,无限循环下去,直到栈溢出。
正确的做法一定是把真实对象目标对象存下来,通过method.invoke(target, args)调用。唯一的例外是你故意要在代理对象之间做转发,但那是设计过的链路,不是随手写出来的。
6.2 Spring事务中this调用导致增强失效
这个坑在Spring项目里非常高频。你写了一个Service:
@Service public class UserService { public void process() { this.save(); // 事务会失效 } @Transactional public void save() { // 数据库操作 } }调用process()时,外部进来的是代理对象,但this.save()里的this是原始Bean对象,不是代理对象,所以save方法上的@Transactional根本没有机会生效,事务不会开启。
解决办法有几个。最简单的是把save方法拆到另一个Bean里,通过注入调用;也可以在Spring配置spring.aop.proxy-target-class=true的前提下,使用AopContext.currentProxy():
((UserService) AopContext.currentProxy()).save();前提是启动类上开启@EnableAspectJAutoProxy(exposeProxy = true)。理解这个问题的关键,还是想清楚“外部持有的究竟是不是代理对象”这个点。
6.3 JDK代理对象只能强转为接口类型
有人这样写,直接崩溃:
UserServiceImpl impl = (UserServiceImpl) proxy; // ClassCastExceptionJDK生成的代理对象是$Proxy0,它和UserServiceImpl没有任何继承关系,只实现了UserService接口。它只能强转为接口类型,不能强转为实现类类型。如果代码里大量直接引用了实现类,建议先重构为面向接口编程,否则动态代理根本玩不转。
6.4 CGLIB无法代理final方法和final类
CGLIB靠生成子类实现代理,final类不能被继承,final方法不能被重写,这是硬限制。如果你的目标类或者方法被final修饰,CGLIB会直接报错或者静默跳过增强。我曾经在一个老项目里排查了很久的诡异Bug,最后发现是一个基础Service类里几个关键方法被final修饰了,CGLIB代理生成了子类但无法重写这些方法,导致切面逻辑时而生效时而不生效。
潜在教训:如果你确定这个类需要被AOP增强,就别给方法加final,或者至少评估一下代理方式的兼容性。
6.5 JDK代理对Object类方法的处理容易被忽略
equals、hashCode、toString这三个Object方法在代理调用链中同样会被转发到invoke。如果你的InvocationHandler里没处理这几个方法,代理对象的行为会变得很怪。
举个实际例子:两个代理对象包装同一个目标对象,如果equals方法没有比对内部target,比较结果可能是false;如果不小心在invoke里调用了proxy.toString(),还会导致递归栈溢出。
一个稳妥的处理方式是:在invoke里对方法名做一个判断,对Object的这几个方法直接调用method.invoke(target, args),或者直接返回由InvocationHandler自定义的实现,不要无脑转发。
6.6 代理对象序列化与类加载器隔离的坑
分布式项目里如果要对代理对象做序列化,会发现JDK生成的代理类虽然实现了Serializable(经由Proxy),但InvocationHandler多持有目标对象引用,序列化时会连带序列化一大坨内部状态,甚至可能因为目标对象不可序列化而直接失败。更隐蔽的问题是类加载器隔离:代理类通过指定ClassLoader加载和缓存,如果同一个接口由两个不同的ClassLoader加载,会生成两个不同的代理类。在OSGi、应用热部署这类场景,容易出现类型转换异常。
6.7 依赖注入的“代理陷阱”
Spring容器里如果某个Bean被代理了,通过@Autowired注入时,注入的是代理对象而不是原始对象。如果你在代码里做过类型判断,比如bean instanceof UserServiceImpl,这个判断在CGLIB代理下是true(因为子类是目标类的子类),但在JDK代理下是false(因为代理类只实现了接口,不继承实现类)。这会导致同一种逻辑在不同代理模式下行为不一致。
另外还要注意,如果项目里自己手动用Proxy.newProxyInstance生成代理对象,再交给Spring管理,原始目标类和代理对象可能同时存在于容器中。如果你没有区分名字,注入时可能拿到的是没有经过任何增强的原始对象,这也是很多人排半天查不出来的隐藏问题。
7. 工程视角下的选型建议与学习路径
7.1 什么时候用JDK动态代理,什么时候用CGLIB
一句话结论:
- 能面向接口就面向接口,用JDK动态代理。原生支持、无额外依赖、调试方便、和Spring的AOP接口对接顺畅。
- 目标类没有接口、第三方库类无法改代码时,用CGLIB。比如你在扩展某个开源工具类的行为时。
- 在一个团队项目里,尽量保持代理模式统一。不要一会儿JDK一会儿CGLIB,否则各种类型判断、序列化、调试体验都会混乱。
- 如果是在Spring Boot 2.x环境里做AOP,直接用Spring默认的CGLIB,不用纠结。
7.2 第三股势力:AspectJ和Byte Buddy
动态代理之外还有更底层的字节码增强技术,比如AspectJ在编译期直接修改类字节码,Byte Buddy在运行期操作字节码。它们可以做动态代理做不到的事,比如修改静态方法、修改类的字段定义、增强构造方法,甚至拦截一个类的new操作。
从工程角度看,动态代理适合方法级别的横切逻辑,字节码增强适合更底层的框架能力。Spring AOP本身基于动态代理,但它也支持通过@EnableLoadTimeWeaving接入AspectJ的编译期机制,这两种方式各有取舍。一般业务开发用Spring AOP就够了,真要玩字节码增强,建议先把ASM或者Byte Buddy的基础手写几遍,再深入AspectJ。
7.3 想彻底掌握动态代理,我建议按这个顺序练习
- 第一步:把JDK动态代理的完整案例手写三遍,不看书的情况下能独立写完。
- 第二步:写一个带事务模拟的代理层。不真的连DB,用ThreadLocal模拟事务状态,体会代理层如何统一控制提交回滚。
- 第三步:手写一个极简AOP框架。定义@Log注解和@Transactional注解,用动态代理扫描被注解注释的Bean并生成代理对象,这个练习做完,Spring AOP的很多疑问会迎刃而解。
- 第四步:用CGLIB代理改造同一个练习,体验无接口场景下的代理差异。
- 第五步:打开JDK的Proxy源码和CGLIB的Enhancer源码,对着刚才写的代码,把类生成、缓存、调用链逐行过一遍。
我一直觉得,框架源码看得再多,不亲手写一轮代理,很多东西都是浮在表面的。尤其“代理对象和真实对象到底是什么关系”这个感觉,光靠读源码建立不起来,必须自己把代理对象接过来调一调,在断点里看一遍调用链,理解才算真正落地。
8. 写在最后:从面试答案到实战能力的跃迁
动态代理这个知识点,面试可以只考到“JDK和CGLIB的区别”,但真正能拉开差距的,是你能不能从代理对象、InvocationHandler、类生成、缓存、AOP链路这条线,把一个方法调用从进入到返回的完整路径讲清楚。我在实际项目里帮同事排查过不少诡异问题,最后都绕回到“在座各位拿到的到底是不是代理对象”这件事上。比如某个Service的方法事务不生效,第一反应就该查它是不是被this调用了;某个接口注入后强转报错,先想想它是不是被JDK代理了;某个CGLIB代理对象方法没有拦截,去看看目标方法是不是final了。
如果你正在准备面试,我的建议是把整篇文章里的代码自己敲一遍,然后尝试不看资料解释清楚这三个问题:JDK动态代理的代理类是如何生成的、为什么必须有接口、Spring里JDK代理和CGLIB代理是如何相互配合的。能把这几个问题讲明白,动态代理这个知识点基本就过关了。而对已经在写业务代码的人来说,找到自己项目里现有的代理使用场景,打开Spring的AOP配置看一看当前走的是哪种代理模式,再结合今天拆的底层逻辑,应该会有一种“原来之前那些黑盒都是这么回事”的感觉。技术这东西,看一遍不如写一遍,自己动手生成一个代理出来,比背十遍八股文都要扎实。