1. 代理模式不是“包装一下”那么简单:先看它解决什么问题
1.1 一个没有代理的代码,职责全搅在一起
如果你去看Spring AOP的源码,会发现一个有意思的现象:Spring容器里很多Bean并不是你写的那个类,而是它的代理。有人觉得这是框架“搞鬼”,其实背后的理论基础正是Java设计模式里的代理模式。代理模式也是我平时项目里用得最多、看源码时绕不开的一个模式,很多经典问题,比如“为什么@Transactional有时候会失效”“MyBatis的Mapper接口没有实现类却能被调用”“RestController里注入的Service到底是个什么东西”,归根到底都和代理模式有关。
我先举一个最常见的反面例子。假设你有一个支付接口,现在需要给支付方法加上权限校验、日志记录、事务控制。
public class PayService { public void pay(String orderId) { // 权限校验 if (!checkPermission()) { throw new SecurityException("无权限"); } try { // 开启事务 System.out.println("开始支付:" + orderId); // 执行业务 // 提交事务 } catch (Exception e) { // 回滚事务 } // 记录日志 System.out.println("支付完成:" + orderId); } }这段代码的问题很明显:业务逻辑和横切逻辑全缠在一起。今天要在支付方法里加日志,明天要在退款方法里加日志,后天可能还要给优惠券方法加权限校验。如果每个方法都复制粘贴一遍,代码很快就失控了,改一处增强逻辑要动所有方法,完全违背了单一职责原则。
代理模式的想法很朴素:把“业务行为”和“增强行为”拆开。业务方法只负责业务,权限校验、日志、事务这些事交给代理对象做。调用方只需要面向同一个接口编程,代理在方法前后插入增强逻辑,再调用真实对象。
1.2 代理模式的三要素和调用链
代理模式有三个角色:
| 角色 | 职责 |
|---|---|
| 抽象主题(Subject) | 定义真实对象和代理对象共同的行为接口 |
| 真实主题(RealSubject) | 真正执行业务逻辑的对象 |
| 代理(Proxy) | 持有真实主题引用,对外暴露相同接口,拦截并增强方法调用 |
调用过程是:客户端调用proxy.method(),代理先执行增强逻辑,然后调用realSubject.method()。如果不需要增强,代理可以直接把请求转发给真实对象;如果确实要拦截,代理可以在调用前后做各种事情。
用一个生活化的类比:明星不会直接接粉丝的电话和商业合作,经纪人站在中间,先过滤骚扰、谈合同、排档期,最后才让明星上台演出。经纪人就是代理,明星是真实对象,商业合作是接口定义的行为。粉丝不需要直接找明星,只需要对接经纪人,体验上没差别,但中间多了很多控制空间。
1.3 代理模式和装饰器模式到底有什么区别
很多初学者会把代理模式和装饰器模式搞混,因为从类图上看,两者几乎一模一样:都是持有一个目标对象,都实现相同的接口,都在方法调用前后做一层包装。
我的理解是这样:装饰器模式的重点是“动态增加职责”,比如给一杯咖啡加奶、加糖,外层装饰器一层层套上去,每一层都增强了原有行为;代理模式的重点是“控制访问”,比如权限校验、延迟加载、远程调用,代理往往是想隐藏真实对象的存在,不让客户端直接触达。
一句话总结:装饰器让对象“变得更丰富”,代理让访问“变得更可控”。代码结构可以长得一样,但设计意图不同。面试时如果被问到,能说出意图差异,比背类图有用得多。
2. 静态代理:最笨的方式,反而最能讲清代理的本质
2.1 一个最简代码:接口、真实主题、代理类
动态代理虽然强大,但我始终建议先手写一个静态代理。原因很简单:只有亲手写过代理类,你才会真正理解“代理对象持有真实对象引用,并把调用转发过去”这件事到底是怎么回事。
先定义一个接口:
public interface UserService { void addUser(String username); }真实业务类:
public class UserServiceImpl implements UserService { @Override public void addUser(String username) { System.out.println("保存用户:" + username); } }代理类:
public class UserServiceProxy implements UserService { private final UserService target; public UserServiceProxy(UserService target) { this.target = target; } @Override public void addUser(String username) { System.out.println("代理:新增用户前,校验权限"); target.addUser(username); System.out.println("代理:新增用户后,记录日志"); } }使用方式:
UserService target = new UserServiceImpl(); UserService proxy = new UserServiceProxy(target); proxy.addUser("张三");运行结果会多出两行代理增强日志。这个例子虽然简单,但把代理模式的核心结构全部体现出来了:代理类和真实类实现同一个接口,代理类通过构造方法拿到真实对象,调用方不直接操作UserServiceImpl,而是操作UserServiceProxy。
2.2 为什么用组合而不是继承
看到这里你可能想问:代理类为什么不直接继承UserServiceImpl,然后重写addUser方法?继承确实也能达到类似效果,但在设计上是有问题的。
- 继承会绑定父类的具体实现。如果父类方法变了,子类的覆写逻辑可能受到牵连。
- 代理对象不应被限制成只能代理某一个具体实现类。使用接口加组合的方式,
UserServiceProxy可以代理任何一个实现了UserService的类,今天可以代理UserServiceImpl,明天换一个VipUserServiceImpl,代理类不用改。 - 组合的方式让代理类只依赖抽象接口,不依赖具体细节,符合依赖倒置原则。
这也是我在实际开发里更推荐“组合优于继承”的原因。很多框架底层的代理机制,本质上也是在运行时动态组合一个代理类,而不是简单继承业务类去做覆写。
2.3 静态代理的致命伤
静态代理最大的问题就是“代码爆炸”。
如果UserService接口里有20个方法,代理类就要写20个转发方法;如果系统里有10个业务接口都需要日志增强,就要写10个代理类;如果突然想在所有代理方法上再加一个耗时统计,你就要把这10个代理类全部改一遍。这比直接把逻辑写在业务类里也好不了多少。
静态代理适合理解原理,但在真实业务中,我更推荐使用动态代理。动态代理的精髓在于:把“增强逻辑”抽成一个独立对象,让它在运行时统一处理所有方法,这样不管接口有多少方法,代码只需要维护一份。
3. JDK动态代理:InvocationHandler把“增强”变成了可复用对象
3.1 Proxy.newProxyInstance的三个参数
JDK从1.3开始就内置了动态代理,核心类是java.lang.reflect.Proxy和java.lang.reflect.InvocationHandler。
创建代理对象使用Proxy.newProxyInstance,它需要三个参数:
UserService proxy = (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new LogInvocationHandler(target) );- 第一个参数
ClassLoader:用来加载运行时生成的代理类,通常直接使用目标类的类加载器。 - 第二个参数
interfaces:目标对象实现的接口数组,运行时生成的代理类会实现这些接口,这样调用方才能把它强转成接口类型。 - 第三个参数
InvocationHandler:方法调用的统一处理者。代理对象上任何一个接口方法的调用,都会转到这里来。
最核心的就是这个InvocationHandler。你把增强逻辑写在它的invoke方法里,就相当于把“代理行为”和“具体业务类”彻底解耦了。
3.2 在invoke里做日志和权限控制
先写一个最常用的日志代理:
import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.util.Arrays; public class LogInvocationHandler implements InvocationHandler { private final Object target; public LogInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("方法 [" + method.getName() + "] 开始执行,参数:" + Arrays.toString(args)); long start = System.currentTimeMillis(); Object result = method.invoke(target, args); long cost = System.currentTimeMillis() - start; System.out.println("方法 [" + method.getName() + "] 执行结束,耗时:" + cost + " ms,返回:" + result); return result; } }调用方式:
UserService target = new UserServiceImpl(); UserService proxy = (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new LogInvocationHandler(target) ); proxy.addUser("张三");这里有个容易被忽略的细节:invoke方法里的method是接口方法的Method对象,method.invoke(target, args)这行才是真正把调用转发给原始业务对象。如果你想在转发前加权限校验,只需要在method.invoke之前写判断逻辑;如果你想做重试,只需要在捕获异常时再调用一次。换句话说,所有接口方法都会被这个统一的invoke处理,这就是动态代理能解决静态代理“代码爆炸”问题的根本原因。
3.3 JDK动态代理为什么只能代理接口
这是面试里被问烂了的问题,也是理解JDK代理机制的关键点。
JDK动态代理生成的代理类结构大致是这样:
// 反编译后的结构示意,不是真实运行代码 public final class $Proxy0 extends Proxy implements UserService { private static Method m1; public final void addUser(String username) { super.h.invoke(this, m1, new Object[]{username}); } }可以看到,$Proxy0已经继承了java.lang.reflect.Proxy类。Java是单继承语言,一个类不可能同时继承Proxy又继承你的UserServiceImpl,所以只能靠“实现接口”来给代理类定义方法。
如果目标类没有实现任何接口,JDK动态代理就无能为力了,因为没有接口可以让$Proxy0去实现。这时候就需要CGLIB出场。
3.4 一个真实场景:用JDK动态代理做方法级幂等控制
我去年在项目里做过一个通用的幂等组件,就是基于JDK动态代理实现的。思路非常简单:在接口方法上打一个@Idempotent注解,代理拦截到方法后,先去Redis查请求号,如果已经处理过就直接返回旧结果,否则执行业务方法并把结果缓存起来。
public class IdempotentInvocationHandler implements InvocationHandler { private final Object target; public IdempotentInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (!method.isAnnotationPresent(Idempotent.class)) { return method.invoke(target, args); } String requestId = extractRequestId(args); Object cached = getFromCache(requestId); if (cached != null) { return cached; } Object result = method.invoke(target, args); saveToCache(requestId, result); return result; } }这个组件的好处是:业务类完全不知道幂等逻辑的存在,只要加一个注解就能获得幂等能力。这正是代理模式最有价值的地方,增强逻辑被抽出来复用,业务代码保持干净。
4. CGLIB代理:没有接口时,用继承绕过限制
4.1 Enhancer和MethodInterceptor的基本用法
CGLIB(Code Generation Library)不是JDK自带的,但Spring框架内部集成了它。它的核心思路是:直接在运行时生成目标类的子类,通过覆写父类方法来实现代理,所以不需要接口。
单独使用CGLIB时,需要引入依赖:
<dependency> <groupId>cglib</groupId> <artifactId>cglib</artifactId> <version>3.3.0</version> </dependency>看一个最简单的例子。假设有一个OrderService,没有实现任何接口:
public class OrderService { public void createOrder(String orderNo) { System.out.println("创建订单:" + orderNo); } }使用Enhancer创建代理:
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 CglibProxyFactory { public static <T> T createProxy(T target) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(target.getClass()); enhancer.setCallback(new MethodInterceptor() { @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { System.out.println("CGLIB拦截前"); Object result = proxy.invokeSuper(obj, args); System.out.println("CGLIB拦截后"); return result; } }); return (T) enhancer.create(); } }使用:
OrderService target = new OrderService(); OrderService proxy = CglibProxyFactory.createProxy(target); proxy.createOrder("PO20250101");intercept方法里的四个参数要理解清楚:
obj:CGLIB生成的子类实例。method:父类的Method对象。args:方法入参。proxy:CGLIB的MethodProxy,用于调用父类方法。
4.2 为什么CGLIB不能代理final方法
这个坑我在刚接触CGLIB时踩过。CGLIB是生成一个目标类的子类,然后重写父类的方法。如果目标类被final修饰,或者某个方法被final修饰,编译器根本不允许重写,代理自然就失效了。
具体现象是:类如果是final,CGLIB会直接抛异常;方法如果是final,CGLIB不会拦截这个方法,调用时走的是父类原始逻辑,没有任何增强。私有方法同样无法被代理,因为子类看不到父类私有方法。
所以在使用Spring AOP时,如果要确保切面生效,类和方法都不能是final,方法还得是public或protected。这也是为什么很多人写@Transactional加在private方法上不生效的原因之一。
还有一点要注意:在CGLIB的intercept中,调用父类方法一定要用proxy.invokeSuper(obj, args),不要手动写method.invoke(obj, args)。因为obj是CGLIB生成的子类实例,如果你用method.invoke(obj, args),这个obj上的方法可能是被覆写过的,会再次进入intercept,造成死循环。
4.3 JDK代理和CGLIB该选谁
| 对比项 | JDK动态代理 | CGLIB代理 |
|---|---|---|
| 实现方式 | 基于接口和反射 | 基于继承和字节码生成 |
| 目标要求 | 目标类必须实现接口 | 目标类不能被final修饰,方法不能被final修饰 |
| 生成机制 | Proxy.newProxyInstance | Enhancer.create |
| 创建成本 | 较低 | 相对较高 |
| Spring旧版默认策略 | 有接口时使用 | 无接口时使用 |
| Spring Boot 2.x默认策略 | 已改为优先使用CGLIB | 已改为优先使用CGLIB |
Spring Boot 2.x开始,官方把spring.aop.proxy-target-class默认值改成了true,意思是即使目标类有接口,默认也使用CGLIB代理。所以现在你直接注入一个Service,很多时候拿到的其实是CGLIB创建的代理对象,而不是JDK动态代理。
日常开发中,其实不需要你手动二选一,Spring已经自动处理了。但面试时你得能说清楚:JDK代理基于接口,灵活且没有额外依赖;CGLIB基于子类,不需要接口,但final方法和类无法代理。
5. 框架源码里的代理模式:Spring、MyBatis、Retrofit都在用它
5.1 Spring AOP与声明式事务
Spring AOP是整个Spring框架里代理模式最集中的体现。@Transactional注解本身不做事,真正起作用的是Spring在Bean初始化阶段判断这个类是否需要事务增强,如果需要,就生成一个代理对象放进容器。
调用流程是:Controller注入Service时,实际上拿到的是代理对象;调用service.method()时调用链会经过TransactionInterceptor,它在方法执行前开启事务,方法正常结束后提交事务,方法抛出异常时回滚事务。
这也引出了网上常见的“事务失效”问题。如果同一个类内部,一个普通方法调用了另一个带@Transactional的方法,且调用发生在原始对象内部,没有经过代理对象,那么事务注解是无效的。
给一段最能说明问题的代码:
@Service public class OrderService { public void createOrder(String orderId) { // this调用,没有经过代理 generateOrderNo(orderId); } @Transactional public void generateOrderNo(String orderId) { System.out.println("该方法上的事务注解不会生效"); } }外部调用createOrder时,进入的是代理对象,代理对象调用target.createOrder();但在createOrder内部,this指向的是原始目标对象,而不是代理对象,所以generateOrderNo上面的@Transactional完全被跳过。
解决办法有三个:
- 把
generateOrderNo拆到另一个Bean中,通过注入这个Bean来调用。 - 在
OrderService里注入自己的代理对象,比如通过@Lazy注入OrderService,用它来调用generateOrderNo。 - 设置
exposeProxy=true,然后通过AopContext.currentProxy()拿到当前代理对象。
我更推荐第一种方案,拆类会让职责更清晰,也避免依赖Spring内部机制。
5.2 MyBatis为Mapper接口生成代理实现
MyBatis里有一个非常经典的动态代理应用:UserMapper只是一个接口,没有任何实现类,但你却能直接调用它的方法。
public interface UserMapper { @Select("SELECT * FROM user WHERE id = #{id}") User findById(Long id); }调用的时候:
UserMapper mapper = sqlSession.getMapper(UserMapper.class); User user = mapper.findById(1L);底层就是MyBatis通过JDK动态代理为UserMapper生成一个代理对象。调用findById时,MapperProxy这个InvocationHandler会解析接口方法上的注解或XML映射,找到对应的MappedStatement,最终执行SQL并返回结果。
这就是代理模式的神奇之处:你只定义了一个接口,运行时动态代理替你实现了接口。所以说,看到接口没有实现类,不要觉得奇怪,背后往往藏着一个代理。
5.3 Retrofit和RPC框架的“无实现类”接口
Java生态里这种套路非常多。Retrofit也是用动态代理把接口方法变成HTTP请求:
public interface ApiService { @GET("user/list") Call<UserList> getUserList(); }Retrofit会为ApiService生成代理对象,调用getUserList()时,代理对象解析方法上的@GET注解,拼接URL,构造OkHttp请求,再返回结果。业务代码完全感知不到网络请求的细节。
RPC框架更明显。Dubbo里@DubboReference注入的接口代理,本地调用一个空方法,代理对象会负责把方法名、参数序列化,通过网络发送给远程服务提供方,再接收结果返回。调用远程服务就像调用本地方法一样,这种体验正是代理模式带来的隔离能力。
5.4 业务代码里什么时候真正需要代理
框架已经替我们做了很多代理的事,那业务代码里还需要自己写代理吗?
我的经验是:当你有“同一套增强逻辑要应用到多个不相关的方法或类”时,才值得考虑动态代理。比如统一日志、权限校验、方法级幂等、接口限流、失败重试。
但更推荐的做法是:在Spring生态里直接用AOP注解或切面,而不是手写Proxy.newProxyInstance。因为Spring AOP已经封装好了创建代理、管理Bean生命周期、处理异常等复杂逻辑,你只需要写清楚切面规则和增强逻辑。如果脱离Spring框架,写一个纯Java的工具类,自己用JDK动态代理或CGLIB做通用拦截,那也很合适。
我在之前的项目里用动态代理做过一个方法级幂等组件,效果很好,但前提是团队对代理原理有共识。如果只是为了让某一段代码少写几行,强行引入动态代理,反而会增加认知成本。代理模式是工具,不是目的。
6. 我踩过的代理失效的坑,以及面试被追问的细节
6.1 同类内部方法调用,@Transactional失效
这个坑我在前文已经提到了,但值得单独拿出来再讲一遍,因为它是线上环境最容易出问题的一类Bug。
举个更贴近业务的例子:订单创建时,需要扣库存、生成订单号、发通知。你可能会这样写:
@Service public class OrderService { @Transactional public void createOrder(OrderDTO dto) { this.deductStock(dto.getSkuId()); this.generateOrderNo(dto.getOrderId()); this.sendMessage(dto.getUserId()); } }表面看起来,所有方法都在同一个事务里,但只要你把deductStock、generateOrderNo、sendMessage这些方法挨个加上@Transactional,其实并不会让事务边界变得更正确。真正决定事务的,是最外层createOrder这个方法是否经过代理对象。
如果createOrder是入口方法,并且它本身就有@Transactional,那问题不大;但如果入口方法没有事务注解,里面调用的方法虽然有注解,也因为this调用全部失效。
排查这类问题时,我会先在调用链上打印对象的类名:
System.out.println(service.getClass().getName());如果输出的是com.example.OrderService,说明没有走代理;如果输出的是com.example.OrderService$$EnhancerBySpringCGLIB$$...,说明走的是CGLIB代理。看到真实类名,基本就能定位到内部调用问题。
6.2 代理对象的类型判断容易翻车
写框架或者公共组件时,经常需要判断传入对象是不是某个类型。在存在代理的情况下,这种判断会变得很微妙。
JDK动态代理生成的$Proxy0并不是目标类的子类,它只是实现了目标接口。所以如果你写:
if (target instanceof UserServiceImpl) { // do something }当target是JDK动态代理对象时,这个判断会返回false,因为你那个代理对象和UserServiceImpl没有继承关系。而如果你用的是CGLIB,代理对象是目标类的子类,instanceof UserServiceImpl又会返回true。
这种不确定性容易导致框架代码表现不一致。所以我的建议是:在公共逻辑里尽量基于接口或注解做判断,不要依赖具体实现类。
6.3 动态代理与Spring循环依赖的暗坑
Spring解决循环依赖依赖三级缓存,允许单例Bean在属性还没完全填充时就提前暴露。如果这个Bean还需要代理增强,情况就会更复杂:早期暴露出去的可能是一个还没完成增强的原始对象,等后续创建完代理之后,另一个Bean拿到的还是之前暴露的原始对象,导致增强不生效。
Spring Boot 2.6以后,官方默认禁止了循环依赖,遇到这种情况启动时会直接报错。如果你在升级Spring Boot时碰到“The dependencies of some of the beans in the application context form a cycle”,不要想着怎么强行打开开关,而是应该把循环依赖的代码结构拆掉,这是更健康的做法。
6.4 代理对象创建的成本和缓存
JDK动态代理和CGLIB创建代理对象都有成本,尤其是CGLIB,运行时需要生成字节码,成本更高。Spring里的Bean默认是单例,所以创建一次代理对象的成本被分摊到了整个应用生命周期。如果你在业务代码里频繁创建代理对象,比如在for循环里创建,性能会明显变差。
我见过有人把代理创建做成每次调用都重新生成,完全没有必要。如果是自己手写动态代理,应该把代理对象缓存起来,或者在容器启动阶段创建好。一个代理对象通常绑定一个目标对象和一个InvocationHandler,只要目标对象不变,它就可以复用。
6.5 面试常问的几个对比问题
我把代理模式相关的面试问题整理成了一份清单,方便你自查:
| 问题 | 关键回答思路 |
|---|---|
| JDK动态代理和CGLIB有什么区别? | JDK代理必须是接口,基于反射;CGLIB不要求接口,基于继承,不能代理final类和方法。 |
| Spring AOP默认使用哪种代理? | 旧版有接口用JDK,无接口用CGLIB;Spring Boot 2.x默认CGLIB。 |
| 代理模式和装饰器模式的区别? | 装饰器强调增强功能,代理强调控制访问。 |
| @Transactional为什么会失效? | 同类内部调用、方法非public、类或方法为final、异常被吞掉等。 |
| 代理模式和适配器模式的区别? | 适配器是为了让不兼容的接口协同工作,会修改接口定义;代理不改变接口,保持接口一致,只做控制与转发。 |
还有一个比较偏的追问:Proxy.newProxyInstance生成的代理类在什么情况下会提前存在?HotSpot会对相同ClassLoader、相同接口集合、相同InvocationHandler类型的代理类做缓存,所以重复创建同一个接口的代理,不一定每次都生成新的类。这个点知道即可,工作中很少用到。
最后说一点我个人的体会。学代理模式,一定不要只盯着类图看,最好去读一段框架源码。Spring的JdkDynamicAopProxy、MyBatis的MapperProxy,代码都不长,读完之后你对“代理对象、InvocationHandler、MethodProxy”这几个概念的理解会完全不一样。如果哪天你在业务里遇到“调用没生效”“事务时好时坏”这类问题,先把对象类名打印出来看一眼,大概率会发现是代理对象和真实对象在互相纠缠。代理模式就是这样,理解它不需要死记定义,跑通一个动态代理Demo,再跟着框架源码走一遍,就够了。