1. 代理模式到底在解决什么问题
代理模式这个词,第一次听会觉得特别抽象。但如果把它翻译成大白话,其实就一句话:你想访问一个对象,但不想直接访问它,于是中间加了一层"中间人",所有请求先经过这个中间人,由它决定是拦下来、加点料,还是原样转发。这就是代理模式的核心。它属于结构型设计模式,跟装饰器、适配器、外观模式是同一个家族,但解决的问题完全不一样。
在 Java 里,我们平时用的 Spring AOP、MyBatis 的 Mapper 接口、Dubbo 的远程调用、甚至 Lombok 生成的一些代码,底层都有代理模式的影子。可以说,你只要在写 Java 后端,就躲不开它。这也是为什么"设计模式期末""设计模式大作业""java设计模式"这些词总能上热搜——代理模式几乎是每门设计模式课必考的章节,也是面试里问得最深的几个点之一。
这篇文章我打算把代理模式从头到尾拆一遍。静态代理怎么写、JDK 动态代理为什么必须基于接口、CGLIB 为什么能代理没有接口的类、Spring 到底选哪个、以及实际项目里最容易踩的那几个坑,都会讲到。适合刚学完设计模式想搞清楚原理的同学,也适合工作几年但一直没弄明白 AOP 底层的人。看完之后,你应该能自己手写一个简易版的 AOP 框架。
1.1 用一个生活类比先建立直觉
先别急着看代码。假设你要租房子,你不需要直接跟房东打交道,而是找中介。中介就是"代理"。房东是真实对象(RealSubject),你是调用方(Client),中介是代理对象(Proxy)。你和房东都实现了同一个"房屋出租"的抽象接口(Subject)。
中介存在的价值在于:它可以在你见房东之前,先帮你筛房源、收中介费、签合同、做背景核实。这些事房东本来也能做,但房东没精力。放到代码里,就是在不修改原对象的前提下,给它增加额外的功能——日志、缓存、权限校验、事务控制,全都是在这个"中间层"里加进去的。
关键在于,调用方(你)根本感觉不到代理的存在。你调的还是一个rent()方法,只是这个方法实际上被中介包了一层。这就是代理模式最迷人的地方:对调用方透明。你不需要改任何调用代码,功能就凭空多出来了。
1.2 为什么不能直接用继承或者装饰器
很多人第一次学完代理会问:我想要增强功能,直接继承原类重写方法不就行了?或者用装饰器包一层不行吗?这两个问题问得特别好,正好能说清楚代理的定位。
继承的问题是侵入性太强,而且耦合死。你继承了UserService,重写save()加日志,那这个带日志的类就固定死了。如果我又想加缓存、又想加权限,难道再继承两层?类的数量会爆炸式增长,而且一旦原类改动,所有子类都要跟着改。代理模式不碰原类,原类该干嘛干嘛,增强逻辑放在代理里,两边互不干扰。
装饰器和代理结构几乎一模一样,都是持有一个被包装对象、实现同一个接口。但意图不同:装饰器关注的是"给对象动态叠加功能",是层层套娃,功能可叠加、可组合;代理关注的是"控制对对象的访问",通常一个代理对应一个目标,目的是拦截和管控。举个例子:给一杯咖啡加奶、加糖,这是装饰器;你访问数据库时中间横插一个连接池代理,这是代理。分清楚这个,设计模式大作业里让"说出它们的区别"就稳了。
1.3 静态代理的代码骨架长什么样
先把最简单的形态写出来,建立手感。假设有一个用户服务接口:
public interface UserService { void save(String name); String findById(Long id); }真实实现:
public class UserServiceImpl implements UserService { @Override public void save(String name) { System.out.println("保存用户: " + name); } @Override public String findById(Long id) { return "用户-" + id; } }静态代理就是手写一个类,实现同一个接口,内部持有真实对象:
public class UserServiceProxy implements UserService { private final UserService target; public UserServiceProxy(UserService target) { this.target = target; } @Override public void save(String name) { long start = System.currentTimeMillis(); System.out.println("[日志] 开始 save"); target.save(name); System.out.println("[日志] save 耗时 " + (System.currentTimeMillis() - start) + "ms"); } @Override public String findById(Long id) { System.out.println("[日志] 查询 id=" + id); return target.findById(id); } }调用方这样写:
UserService target = new UserServiceImpl(); UserService proxy = new UserServiceProxy(target); proxy.save("张三"); System.out.println(proxy.findById(1L));静态代理的原理一目了然,也最好理解。但它有几个硬伤:每一个接口都要手写一个代理类,接口方法一多,代理类就臃肿不堪;每个方法都要重复写日志、计时代码,完全没有复用;一旦要加的增强逻辑变了,所有代理类都要改。所以实际项目里几乎没人手写静态代理,它的存在主要是为了帮你理解动态代理省掉了什么。
注意:静态代理不是没用。当你只需要给某一个特定类做非常定制的拦截、且逻辑复杂到动态代理不好表达时,手写一个静态代理反而更清晰。别因为它是"初级形态"就完全否定它。
2. JDK 动态代理是怎么凭空造出一个类的
静态代理最大的痛点是"手写"。那有没有办法让我不写代理类,程序运行的时候自动生成一个?有,这就是动态代理。Java 从 1.3 开始就在java.lang.reflect包里内置了动态代理支持,用的核心 API 就是Proxy和InvocationHandler。它的思路是:你给我一个接口,我在运行时动态生成一个实现了这个接口的类,然后把所有方法调用都转发给一个统一的处理器。
2.1 InvocationHandler 是干什么的
理解动态代理,只要理解两件事:生成的代理类实现了哪些接口,以及方法调用被转发到了哪里。前者由Proxy.newProxyInstance的参数决定,后者由InvocationHandler决定。所有的增强逻辑,都写在invoke方法里。invoke有三个参数:proxy是生成的代理对象本身,method是当前被调用的方法,args是方法参数。你在这个方法里想加什么就加什么,加完再决定要不要method.invoke(target, args)调真实方法。
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 { long start = System.currentTimeMillis(); System.out.println("[前置] " + method.getName()); Object result = method.invoke(target, args); System.out.println("[后置] " + method.getName() + " 耗时 " + (System.currentTimeMillis() - start) + "ms"); return result; } }使用的时候:
UserService target = new UserServiceImpl(); UserService proxy = (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new LogHandler(target) ); proxy.save("李四");注意newProxyInstance的三个参数:类加载器、目标实现的接口数组、处理器。生成出来的代理类名字通常长这样:com.sun.proxy.$Proxy0。你在调试的时候看到$Proxy开头的类名,就知道这里走了 JDK 动态代理。
2.2 为什么 JDK 代理必须基于接口
这是面试最爱问的一个点。Proxy.newProxyInstance要求必须传入接口数组,不传就报错。原因在于,Java 是单继承的。JDK 生成的代理类,已经通过extends Proxy继承了java.lang.reflect.Proxy这个类(因为要复用它内部的一些状态),既然父类位置被占了,代理类就没法再继承你的业务类了。所以它只能靠"实现接口"这条路,通过接口来描述"我有哪些方法"。
这就解释了一个经典报错:
java.lang.ClassCastException: com.sun.proxy.$Proxy0 cannot be cast to com.xxx.UserServiceImpl你如果写UserServiceImpl proxy = (UserServiceImpl) Proxy.newProxyInstance(...),一定挂。因为生成的对象是$Proxy0,它跟UserServiceImpl没有继承关系,只是实现了同一个接口。正确写法必须用接口接收:UserService proxy = (...)。
提示:如果目标类没有实现任何接口,JDK 动态代理直接没法用。这时候要么给它补一个接口,要么换 CGLIB。这不是 bug,是设计上的取舍。
2.3 看一下反编译后的代理类
想真正理解动态代理,最有效的办法是把它生成的字节码 dump 出来看一眼。加一个启动参数:
-Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true(JDK 8 用的是-Dsun.misc.ProxyGenerator.saveGeneratedFiles=true,老版本和新版本参数不一样,这是很容易踩的坑。)运行后会在项目根目录生成com/sun/proxy/$Proxy0.class,用反编译工具打开,你会看到类似这样的结构:
public final class $Proxy0 extends Proxy implements UserService { private static Method m3; private static Method m4; public $Proxy0(InvocationHandler h) { super(h); } @Override public final void save(String name) { try { super.h.invoke(this, m3, new Object[]{name}); } catch (Throwable e) { throw new UndeclaredThrowableException(e); } } @Override public final String findById(Long id) { try { return (String) super.h.invoke(this, m4, new Object[]{id}); } catch (Throwable e) { throw new UndeclaredThrowableException(e); } } static { m3 = Class.forName("com.xxx.UserService").getMethod("save", String.class); m4 = Class.forName("com.xxx.UserService").getMethod("findById", Long.class); } }看到这个你就彻底明白了:所谓动态代理,就是把静态代理里你手写的那些方法,交给 JVM 在运行时用字节码生成技术自动拼出来。每个方法体都是一句super.h.invoke(this, mX, args),统一转发到你的处理器。静态代理和动态代理的差别,本质上只是"方法体谁来写"。
3. CGLIB 如何代理没有接口的类
JDK 动态代理的死穴是"必须有接口"。那如果我的业务类就是一个普普通通的类,没实现任何接口呢?这就需要 CGLIB 出场了。CGLIB(Code Generation Library)走的是完全不同的路:它不靠接口,靠继承。它在运行时生成目标类的一个子类,重写父类的所有非 final 方法,把调用先引到拦截器里。
3.1 MethodInterceptor 的写法
CGLIB 的核心接口是MethodInterceptor,跟 JDK 的InvocationHandler位置对应:
public class CglibProxy implements MethodInterceptor { @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { System.out.println("[前置] " + method.getName()); Object result = proxy.invokeSuper(obj, args); System.out.println("[后置] " + method.getName()); return result; } }注意这里调用真实方法用的是proxy.invokeSuper(obj, args),而不是反射的method.invoke。这是 CGLIB 性能能打得过反射的一个关键:MethodProxy内部靠 FastClass 机制直接定位方法,绕开了反射的一部分开销。使用方式:
Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(UserServiceImpl.class); enhancer.setCallback(new CglibProxy()); UserServiceImpl proxy = (UserServiceImpl) enhancer.create(); proxy.save("王五");因为生成的是子类,所以enhancer.create()返回的对象可以直接强转成UserServiceImpl,这也是它跟 JDK 动态代理最大的体感差别。
3.2 静态代理、JDK、CGLIB 三者对比
刚开始学的时候很容易把三者搞混,我用一张表把它们摆清楚:
| 对比维度 | 静态代理 | JDK 动态代理 | CGLIB 代理 |
|---|---|---|---|
| 代理类生成时机 | 编译期手写 | 运行时生成 | 运行时生成 |
| 是否必须先有接口 | 是 | 是 | 否 |
| 代理方式 | 实现同一接口 | 实现同一接口 | 继承目标类 |
| 能否代理 final 类 | 可以(针对接口) | 可以 | 不可以 |
| 能否代理 final 方法 | 可以 | 可以 | 不可以 |
| 调用方接收类型 | 接口 | 接口 | 接口或父类 |
| 主要依赖 | 无 | JDK 自带 | 需引入 cglib |
| 典型使用者 | 手写 | Spring AOP 默认 | Spring 无接口场景 |
看完这张表,很多之前的疑问应该都串起来了。选型逻辑其实很简单:有接口优先 JDK,没接口只能 CGLIB。Spring 早期默认策略就是这样,但从 Spring Boot 2.x 开始,spring.aop.proxy-target-class默认改成了true,也就是默认强制用 CGLIB,哪怕目标有接口。原因很多,其中一个是为了避免"同一个类既被 JDK 代理又被 CGLIB 代理导致类型不统一"的问题。
3.3 关于性能的那点争议
网上关于"JDK 动态代理和 CGLIB 谁快"的争论从来没停过。这里给一个相对靠谱的结论,避免被带偏:
- JDK 8 之前:CGLIB 创建代理慢,但调用快;JDK 代理创建快,但调用慢。所以长期运行、反复调用的场景 CGLIB 占优,一次性创建的场景 JDK 占优。
- JDK 8 之后:JDK 对反射做了大量优化,
method.invoke的性能提升非常明显,两者差距被大幅缩小,很多基准测试里已经互有胜负。 - 实际情况:代理本身的开销,相比数据库查询、网络 IO、序列化这些操作,几乎可以忽略。不要为了选代理方式去做无意义的微优化,按业务需要选(有没有接口)才是正解。
注意:如果你在反复调用的核心链路上,且对性能极其敏感,可以先做个 JMH 基准测试再决定,而不是凭网上的老结论拍板。很多人直接引用几年前的博客结论,其实早就不准了。
4. 代理模式在真实项目里的落地场景
讲完原理,该说它到底用在哪了。代理模式之所以重要,不是因为它"高级",而是因为它几乎是所有框架的底层骨架。你每天用的那些注解,背后十有八九是一个代理在默默干活。
4.1 Spring AOP 就是代理模式的集大成者
先说你最熟悉的 Spring AOP。你写一个切面:
@Aspect @Component public class LogAspect { @Around("@annotation(com.xxx.Loggable)") public Object around(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); Object result = pjp.proceed(); System.out.println(pjp.getSignature() + " 耗时 " + (System.currentTimeMillis() - start) + "ms"); return result; } }然后在业务方法上打个@Loggable,日志就自动加上了。你以为 Spring 改写了你的代码?其实没有。它在启动时扫描到@Loggable,就为这个 Bean 创建了一个代理对象放进容器。你从容器里getBean拿到的,已经是那个代理了。Controller 里注入的、Service 里调用的,全都是代理。整个过程对你写的业务代码零侵入。
这也是为什么很多人遇到"加了@Transactional但事务没生效"时会懵——大概率是代理根本没走到,后面会专门讲。
4.2 MyBatis 的 Mapper 接口只写了接口没有实现
再看一个例子。你写 MyBatis 的 Mapper 时,只需要声明一个接口:
public interface UserMapper { User selectById(Long id); }你从来没写过这个接口的实现类,但它居然能跑。为什么?因为 MyBatis 用 JDK 动态代理给每个 Mapper 接口生成了一个代理对象。当你调selectById时,代理拦下这个方法,读取方法名和参数,去匹配 XML 或者注解里配置的 SQL,执行后把结果映射回对象。接口本身只是个"契约",真正的逻辑由代理根据契约动态拼出来。这就是代理模式在 ORM 框架里最经典的用法。
4.3 代理模式的几种典型变体
代理模式根据目的不同,还有几个细分变体,了解它们能帮你在架构设计时想得更周全:
| 类型 | 作用 | 典型场景 |
|---|---|---|
| 远程代理 | 隐藏远程调用的细节,像调本地方法一样调远程 | Dubbo、Feign 声明式调用 |
| 虚拟代理 | 延迟创建开销大的对象,用到时才真正初始化 | MyBatis 懒加载、图片懒加载 |
| 保护代理 | 做权限检查,控制谁能访问 | 安全框架的鉴权拦截 |
| 缓冲代理 | 缓存结果,减少重复计算或查询 | 热点数据缓存、方法级缓存 |
| 智能引用代理 | 统计引用、记录日志、管理生命周期 | Spring AOP、连接池代理 |
这五个变体其实共用同一套代理模式的骨架,只是"增强的内容"不一样。理解了骨架,换什么目的都只是在invoke里改逻辑而已。
提示:RPC 里的"远程代理"特别能体现代理的价值。你调一个本地方法,代理内部帮你做序列化、发网络请求、反序列化、再返回,你完全感觉不到网络的存在。Dubbo 的
@Reference注入进来的就是一个代理对象。
5. 踩坑实录与排查技巧速查
代理模式原理不复杂,但实际用起来坑特别多,尤其是 Spring AOP 相关的。这部分是我觉得最值钱的地方,因为这些都是文档里不会写、只有真正出过线上问题才记得住的教训。
5.1 自调用导致代理失效
这是代理模式第一大坑,几乎每个人都踩过。看这段代码:
@Service public class OrderService { public void createOrder() { // 这里直接调本类的另一个方法 this.doSomething(); } @Transactional public void doSomething() { // 期望有事务 } }你会惊讶地发现,@Transactional没生效。原因是:外部调用createOrder时,走的是代理没错,但代理调了真实对象的createOrder,然后在createOrder内部,this.doSomething()里的this是真实对象本身,不是代理对象。事务增强逻辑挂在代理上,绕过了代理,增强自然失效。
解决办法有几个:把doSomething挪到另一个 Bean 里,通过注入调;或者自己注入自己(@Resource OrderService self);或者用AopContext.currentProxy()拿当前代理。最推荐第一种,职责拆分也更干净。
注意:这个坑的本质是"代理只在入口生效,类内部调用不走代理"。凡是 AOP 增强失效的场景,先问一句"这次调用经过代理了吗",能省一半排查时间。
5.2 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
$Proxy0 cannot be cast to Xxx | 用实现类类型接收了 JDK 代理 | 改成用接口接收 |
@Transactional/@Async不生效 | 类内部自调用,绕过了代理 | 检查调用是否跨 Bean |
CGLIB 报Cannot enhance final class | 目标类是 final,或被 final 修饰方法 | 去掉 final,或改用接口 + JDK |
| 动态代理方法调用死循环 | invoke里调了自己的代理方法 | 确认调的是 target 不是 proxy |
| 代理对象注入失败 | Bean 被提前引用,代理还没创建 | 加@Lazy或调整依赖顺序 |
| JDK 高版本 dump 不出代理类 | 启动参数名字变了 | JDK 8 用sun.misc,9+ 用jdk.proxy |
这张表我建议收藏。排查代理问题时,从上往下对着看,能覆盖八成情况。
5.3 亲手实现一个迷你 AOP 框架
光看没用,动手写一遍才算真懂。我们来做一个极简版:扫描所有标了某个注解的方法,自动加上耗时统计。核心其实就是 JDK 动态代理:
public class MiniAopFactory { public static <T> T wrap(Class<T> interfaceType, T target) { return interfaceType.cast(Proxy.newProxyInstance( interfaceType.getClassLoader(), new Class[]{interfaceType}, (proxy, method, args) -> { long start = System.nanoTime(); try { return method.invoke(target, args); } finally { long cost = (System.nanoTime() - start) / 1000; System.out.printf("[MiniAop] %s 耗时 %d 微秒%n", method.getName(), cost); } })); } }用起来:
UserService target = new UserServiceImpl(); UserService proxy = MiniAopFactory.wrap(UserService.class, target); proxy.save("赵六");跑一下你会看到方法被自动计时了。这个三十行的东西,其实就是 Spring AOP 的雏形。差别只是 Spring 还做了切点匹配、通知类型(前置、后置、环绕)、代理方式选择这些工程化工作。理解了骨架,再去看 Spring 源码就不会觉得它是黑魔法了。
5.4 一些我摸出来的实操心得
最后分享几个我实际用下来觉得有用的经验。第一,日常业务开发里,优先用"接口 + JDK 动态代理",因为你几乎不会去代理那些没有接口的类,而且接口让代码更干净。第二,遇到 AOP 增强失效,第一反应是"是不是自调用",第二反应是"这个 Bean 是不是被 new 出来的"(手动 new 出来的对象永远不走代理)。第三,写InvocationHandler的时候,invoke里一定要调method.invoke(target, args),千万别手滑调成proxy的方法,否则就是无限递归,栈直接爆掉。
第四,别迷信"动态代理性能差"这种说法,我在高并发的查询接口上用过,代理那点开销在数据库面前根本不值一提。真要优化,先优化 SQL 和缓存。第五,如果你在做设计模式大作业或者期末复习,代理模式你只要把"静态代理、JDK 动态代理、CGLIB 三者对比 + 一个能跑通的例子 + 自调用坑"讲清楚,基本就能拿高分,因为这几个点覆盖了理解深度和实战细节两个维度。
代理模式看着简单,但把 JDK 和 CGLIB 的机制、Spring 的选型逻辑、以及那些隐蔽的失效场景都吃透,你会发现它其实是通往 AOP、RPC、ORM 这些大型框架的那把钥匙。我自己的体会是,每重读一遍$Proxy0的反编译代码,对框架的理解都能再深一层。