
老早之前接手一个老系统改造里面有十几个业务类每个方法开头都是计时日志、参数校验、权限判断一模一样的代码抄了上百遍改一个校验规则要翻几十个文件。后来我把这些横切逻辑抽出来用代理模式统一挂到业务方法上业务类一下子瘦回它本来该有的样子只剩纯粹的领域逻辑。这件事让我真正理解了代理模式的价值——它不是为了炫技而是给代码做减法。这篇文章就把我这些年对代理模式的理解、手写实现、底层细节和踩过的坑一次性理清楚从静态代理到动态代理从 JDK 原生到字节码增强再到框架里的真实落地。不管你是刚学设计模式的在校生还是天天和 Spring 打交道的后端工程师看完至少能做到两件事自己手写一个可用的动态代理以及在代码出问题时知道该往哪儿查。1. 代理模式到底解决什么问题从一次需求变更说起1.1 一个朴素需求引出的重复代码先说一个我遇到的真实场景。系统里有个OrderService最开始只有下单和查询两个方法后来产品要加操作日志再后来要加接口耗时监控再再后来要加数据权限过滤。每来一个需求我都要在OrderService的每个方法里插一段代码。三个月后这个类有 700 多行真正和订单业务相关的不到 200 行剩下的全是日志、计时、权限、事务这些和业务无关的横切逻辑。更麻烦的是测试。因为业务逻辑和这些附加逻辑焊死在一起想单测下单金额计算是否正确我得先把日志框架、权限上下文全都 mock 一遍才跑得起来。某个周一早上因为权限上下文初始化顺序变了整个订单模块的单元测试全红排查了两个小时才发现是测试环境的问题代码一行没错。这就是代理模式要解决的第一个问题把核心业务和附加动作分开。核心业务是真实主题干的活附加动作是代理干的活两者各管各的通过一个共同的调用入口组合起来。调用方只会看到代理感知不到背后是谁在处理。1.2 代理模式的四个角色与调用链代理模式的标准角色其实只有三个我在实际使用时会拆成四个来理解因为第四个角色能帮你看清很多框架行为。抽象主题Subject对外暴露的接口定义了代理和真实对象共同遵守的契约。Java 里通常是一个 interface。真实主题RealSubject真正干活的对象承载核心业务逻辑。代理Proxy持有一个真实主题的引用在调用真实对象前后插入附加逻辑。调用方Client只依赖抽象主题完全不知道代理的存在。一次完整调用是这样的调用方拿到的是代理对象的引用调用orderService.createOrder(...)请求先进入代理代理做参数校验、记开始时间、检查权限然后通过内部持有的真实对象引用转发给OrderServiceImpl.createOrder(...)真实对象处理完返回代理再记结束时间、写审计日志最后把结果还给调用方。注意代理和真实对象必须实现同一个接口或者存在继承关系否则调用方就没法用统一的方式引用它们。这一点是代理模式成立的前提也是后面 JDK 动态代理和 CGLIB 分道扬镳的根本原因。1.3 为什么不用继承或装饰器选型背后的考量有人会问加日志加权限用继承不就行了写一个OrderServiceWithLog extends OrderServiceImpl。短期确实能跑但问题很快暴露如果还要加监控、加权限、加缓存你是继承OrderServiceWithLog还是再写一个新的四个附加功能两两组合就是几十个类这叫继承爆炸。那装饰器模式呢装饰器和代理在代码结构上几乎一模一样都是持有一个同类型的引用然后转发。区别在意图装饰器是为了给对象动态叠加功能强调的是增强代理是为了控制对对象的访问强调的是控制权。我在工程里判断该用哪个的标准很简单——如果附加逻辑是调用方主动想加的比如我想给这个流加缓冲、加加密用装饰器如果附加逻辑对调用方是透明的、是框架或中间件强加的比如事务、权限用代理。还有一个常被忽略的原因代理可以在不修改原有类的前提下生效。老系统的类不能动测试覆盖率要求高直接改代码风险太大。代理模式让你把新逻辑挂在外层原有类一行不改出问题把代理摘掉就回滚了。这种可插拔的特性在遗留系统改造里价值极高。2. 代理模式的几种典型形态与实际用途2.1 静态代理最直白也最啰嗦静态代理就是手写代理类编译期就确定好了代理关系。先定义接口public interface UserService { User findById(Long id); void save(User user); }真实实现public class UserServiceImpl implements UserService { Override public User findById(Long id) { return new User(id, 张三); } Override public void save(User user) { System.out.println(保存用户: user.getName()); } }代理类public class UserServiceProxy implements UserService { private final UserService target; public UserServiceProxy(UserService target) { this.target target; } Override public User findById(Long id) { long start System.nanoTime(); try { return target.findById(id); } finally { System.out.println(findById 耗时 (System.nanoTime() - start) ns); } } Override public void save(User user) { long start System.nanoTime(); try { target.save(user); } finally { System.out.println(save 耗时 (System.nanoTime() - start) ns); } } }静态代理的好处是结构清晰、完全没有运行时代理开销方法调用就是普通的方法调用JIT 优化效果最好。坏处也明显接口每加一个方法代理类就得同步加一个一个接口十个方法就要写十遍重复的计时模板。我维护过一个有 30 多个方法的接口它的静态代理类有 400 多行其中 380 行是复制粘贴的。所以静态代理只适合接口稳定、方法很少的场景比如只代理一两个关键方法。2.2 动态代理把模板代码交给运行时动态代理的思路是重复的转发代码不该让人来写应该让程序在运行期自动生成一个类。Java 里有两条主流路线。一条是JDK 原生动态代理它在运行时生成一个实现了指定接口的类所有方法调用都会被路由到一个InvocationHandler你在 handler 里写一次模板代码所有方法就都带上了。另一条是CGLIB它通过继承目标类来生成子类在子类里重写方法并插入回调所以不需要接口。两者的取舍是动态代理选型的核心话题JDK 走接口CGLIB 走继承。接口是 Java 里更干净的契约走接口意味着耦合度低、代理类和目标类没有血缘关系但现实里大量遗留代码没有接口或者用 Spring 的时候懒得抽接口这时候 CGLIB 就成了唯一选择。2.3 虚拟代理、保护代理、缓存代理在工程里的影子代理模式按用途分有一堆听起来很学术的名字其实都能在工程里找到对应。虚拟代理延迟创建开销大的对象。Hibernate 的懒加载就是典型你拿到的是一个代理真正访问属性时才去数据库查。保护代理在转发前做权限判断不符合条件的直接抛异常压根不让请求到达真实对象。很多网关的鉴权就是这么做的。缓存代理先查缓存命中就返回不命中才转发给真实对象并把结果写回缓存。远程代理调用方感觉像在调本地方法实际上代理内部把参数序列化后发到远端。RPC 框架的客户端存根就是远程代理。智能引用代理在访问对象时做额外计数比如引用计数为 0 就释放资源。这些名字不用背记住一条凡是在真实对象前面挡一层的设计本质上都是代理。理解了这个你看 Spring 的事务、MyBatis 的 Mapper、RPC 的存根时就不会觉得它们是三件不相干的事了。3. JDK 动态代理的底层细节与手写实现3.1 Proxy.newProxyInstance 的三个参数怎么理解JDK 动态代理的入口就一个静态方法public static Object newProxyInstance(ClassLoader loader, Class?[] interfaces, InvocationHandler h)三个参数我逐个拆。loader是类加载器用来加载生成的代理类。通常传目标接口的类加载器也就是UserService.class.getClassLoader()。这里有个坑如果你传的类加载器和接口的类加载器不是同一个、且没有父子关系代理类会加载失败或者类型转换失败。我遇到过一次在 OSGi 或者 Tomcat 这种多类加载器环境里用线程上下文类加载器去加载结果代理类和接口不在同一个加载器里强转直接ClassCastException。稳妥做法是用接口本身的类加载器。interfaces是代理类要实现的接口数组。JDK 代理生成的类会implements这里列出的所有接口。注意几个限制数组里不能有非接口类型不能有重复接口接口的可见性必须是 public非 public 接口只有在同一个包下才能代理。另外代理类只会实现这些接口里声明的方法接口没声明的方法即使真实对象有代理也用不了。h是InvocationHandler所有方法调用最终都会走到它的invoke方法。这是你写附加逻辑的唯一位置。3.2 InvocationHandler 里那几个容易踩雷的方法InvocationHandler只有一个方法public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;看着简单坑不少。第一个坑proxy 参数是你自己别拿它去调用业务方法。很多人第一次写会写成method.invoke(proxy, args)这就死循环了——proxy 上的方法调用又会回到 invoke无限递归直到栈溢出。正确的写法是调用你持有的真实对象引用。第二个坑equals、hashCode、toString 也会被代理。这三个方法来自Object而代理类继承自ProxyProxy重写了它们。所以当有人对代理对象调equals或hashCode时请求同样会进到你的 invoke 里此时method.getDeclaringClass()是Object.class。如果你在 invoke 里无脑转发给真实对象逻辑上通常没问题但如果你在 invoke 里做了参数校验或者假设第一个参数是业务参数就可能空指针。比较稳的写法是先判断if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); }这样equals、hashCode、toString就交给 handler 自己处理不进业务转发逻辑。第三个坑受检异常的包装。代理类的方法签名是按接口声明的如果invoke里抛出的异常不在接口方法的throws列表里代理类只能把它包装成UndeclaredThrowableException抛出。这个异常特别迷惑人日志里看到它真正的业务异常被藏在getUndeclaredThrowable()里。排查时先调这个方法把原始异常挖出来。3.3 生成的 $Proxy0 长什么样想看明白代理类到底生成了什么可以把生成的字节码 dump 出来。JDK 8 加启动参数-Dsun.misc.ProxyGenerator.saveGeneratedFilestrueJDK 9 之后改成了模块化的属性名-Djdk.proxy.ProxyGenerator.saveGeneratedFilestrue跑一遍程序当前目录会出现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 User findById(Long id) { try { return (User) super.h.invoke(this, m3, new Object[]{id}); } catch (RuntimeException | Error e) { throw e; } catch (Throwable t) { throw new UndeclaredThrowableException(t); } } Override public final void save(User user) { try { super.h.invoke(this, m4, new Object[]{user}); } catch (RuntimeException | Error e) { throw e; } catch (Throwable t) { throw new UndeclaredThrowableException(t); } } static { m3 Class.forName(com.example.UserService).getMethod(findById, Long.class); m4 Class.forName(com.example.UserService).getMethod(save, User.class); } }看完这段代码动态代理的所有魔法就没了。代理类持有InvocationHandler每个接口方法都是把Method对象和参数打包然后调h.invoke。Method对象是静态字段类加载时初始化一次避免每次调用都反射查找。异常处理里能看到UndeclaredThrowableException是怎么来的。顺带说一句这些生成的类会被缓存Proxy内部用WeakCache以类加载器和接口数组为 key 缓存代理类所以相同接口不会重复生成。但newProxyInstance每次调用都会创建一个新的代理实例实例创建本身开销不大主要开销在第一次生成类的那一下通常几毫秒到几十毫秒。3.4 一个完整可跑的计时代理把前面的东西拼起来写一个能直接跑的通用计时代理import java.lang.reflect.*; import java.util.Arrays; public class TimingProxyFactory { SuppressWarnings(unchecked) public static T T wrap(T target, ClassT iface) { return (T) Proxy.newProxyInstance( iface.getClassLoader(), new Class?[]{iface}, new TimingHandler(target) ); } static class TimingHandler implements InvocationHandler { private final Object target; TimingHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } String name method.getName(); long start System.nanoTime(); try { Object result method.invoke(target, args); return result; } catch (InvocationTargetException e) { // 反射调用会把业务异常包一层必须拆出来抛原始异常 throw e.getCause(); } finally { long cost System.nanoTime() - start; long ms cost / 1_000_000; if (ms 100) { System.out.printf([SLOW] %s(%s) 耗时 %d ms%n, name, Arrays.toString(args), ms); } } } } }用法UserService target new UserServiceImpl(); UserService proxy TimingProxyFactory.wrap(target, UserService.class); proxy.findById(1L);这段代码里有几个细节值得单独强调。首先method.invoke抛出的异常会被包成InvocationTargetException如果不拆开直接往外抛上层就永远拿不到真正的业务异常这是 JDK 反射的固定行为。其次耗时统计放在 finally 里即使业务抛异常也能记录这在排查哪个方法失败前卡了很久时特别有用。第三只打印超过阈值的慢调用避免日志被淹没这个阈值我在不同系统里调过一般设 100ms 到 500ms 之间比较合适。提示method.invoke的性能在现代 JVM 上已经优化得不错但仍有反射开销。如果代理的方法是超高频调用每秒百万次级别考虑用MethodHandle或者字节码生成方案别硬扛反射。4. CGLIB 与 JDK 动态代理的差异与选型4.1 CGLIB 用继承换接口无关性CGLIB 的思路和 JDK 完全不同。它在运行时生成目标类的子类重写所有非 final 的 public 和 protected 方法在重写的方法里回调你提供的拦截器。因为是继承所以不需要目标类实现任何接口。import net.sf.cglib.proxy.*; public class CglibProxyFactory { SuppressWarnings(unchecked) public static T T wrap(ClassT clazz) { Enhancer enhancer new Enhancer(); enhancer.setSuperclass(clazz); enhancer.setCallback(new TimingInterceptor()); return (T) enhancer.create(); } static class TimingInterceptor implements MethodInterceptor { Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { long start System.nanoTime(); try { // 注意用 invokeSuper 而不是 invoke走的是生成的 FastClass更快 return proxy.invokeSuper(obj, args); } finally { System.out.printf(%s 耗时 %d ns%n, method.getName(), System.nanoTime() - start); } } } }CGLIB 的限制来自继承本身final 类不能被继承final 方法不能被重写。如果你代理的类里有 final 方法那个方法上的附加逻辑会静默失效不报错只是不生效——这是最阴险的一类问题因为代码跑得好好的只有你期望的日志或事务没出现。另外类的构造方法也会被调用两次一次是生成代理类实例时父类构造一次是目标对象自身构造如果构造函数里有副作用比如注册回调、开资源会出问题。4.2 FastClass 机制与性能账CGLIB 生成的不只是代理类还有一个 FastClass。普通反射调用method.invoke需要做访问检查、参数装箱、方法查找FastClass 通过给目标类的每个方法分配一个索引把调用变成一次数组查找加直接调用避开部分反射开销。这也是 CGLIB 在早期被吹成性能碾压 JDK 代理的原因。但这笔账要重新算。JDK 8 之后JDK 动态代理内部做了大量优化反射调用本身也引入了 inflation 机制和字节码生成两者在同一量级。我做过一组粗略的基准测试单线程预热 10 万次循环 1000 万次调用一个空方法代理方式平均单次调用耗时说明直接调用约 2 ns基准线JDK 动态代理约 15-25 ns有反射和装箱开销CGLIB约 10-18 nsFastClass 略占优静态代理约 3-5 ns可被内联数字仅供量级参考不同 JVM 版本和硬件差异很大。结论是在绝大多数业务系统里这两者的性能差异根本不构成选型理由。一次数据库查询几毫秒代理那几十纳秒完全可以忽略。选型真正该看的是下面这些维度。4.3 什么时候用哪个一张对比表维度JDK 动态代理CGLIB前提条件目标类必须实现接口目标类不能是 final生成方式生成实现接口的类生成目标类的子类final 方法不受影响接口方法都可代理无法代理构造函数不调用目标类构造会调用有副作用风险依赖JDK 内置零依赖需要引入第三方库JDK 版本影响稳定高版本 JDK 需处理模块访问限制典型使用者Spring 有接口时的默认选择Spring 无接口、MyBatis 部分场景我的实践经验是这样的能抽接口就抽接口优先 JDK 动态代理。接口是对外契约抽出来本身就有设计价值而且代理类和目标类之间没有继承耦合重构时更自由。只有在完全无法改动的第三方类、或者历史包袱太重不愿抽接口时才用 CGLIB。Spring Boot 2.x 之后默认spring.aop.proxy-target-classtrue也就是默认走 CGLIB这是为了规避同一个类里注入自己代理类型转换失败的常见困惑但代价是所有被代理的类都不能有 final 方法。注意CGLIB 在高版本 JDK 上会碰到模块系统限制。如果报错信息里出现Unable to make ... accessible或者InaccessibleObjectException说明反射访问被模块边界挡住了需要在启动参数里加--add-opens java.base/java.langALL-UNNAMED之类的开放选项具体包名看报错里的类。5. 代理模式在框架中的真实落地5.1 Spring AOP 的代理选择逻辑Spring AOP 是代理模式最大的应用场景它的核心就是给 Bean 生成代理把切面逻辑织入方法调用。代理选择逻辑在DefaultAopProxyFactory里如果目标类实现了至少一个接口默认用 JDK 动态代理如果没实现任何接口用 CGLIB如果配置了proxyTargetClasstrue强制用 CGLIB。这里有个曾经坑过我的细节Spring 用 JDK 动态代理时你从容器里getBean拿到的对象类型是$Proxy而不是原来的实现类所以如果代码里有(UserServiceImpl) context.getBean(userService)这样的强转会ClassCastException。解决办法要么是按接口类型注入要么是切到 CGLIB。这也是 Spring Boot 默认切 CGLIB 的现实原因——它降低了新手的困惑门槛。5.2 MyBatis Mapper 接口的秘密MyBatis 里你只写了一个 Mapper 接口没写任何实现类但userMapper.selectById(1L)就是能跑。这件事的秘密就在 JDK 动态代理。MapperProxyFactory用Proxy.newProxyInstance为接口生成代理MapperProxy实现InvocationHandler。当你调用接口方法时invoke里做这几件事先判断是不是Object的方法是就直接处理然后从方法上找Select、Update这类注解或者去 XML 映射文件里按接口全限定名 方法名找对应的MappedStatement找到之后把参数整理成 SQL 需要的格式交给SqlSession执行最后把结果集映射成返回类型。所以 Mapper 接口本质上不是接口的实现被省略了而是实现被一个动态生成的代理替代了。理解这一点你就能解释很多现象为什么 Mapper 接口不能重载方法因为 MapperProxy 的缓存 key 是基于方法名的、为什么 Mapper 接口不能有默认方法以外的方法体、为什么两个不同包下的同名 Mapper 需要不同的命名空间。5.3 事务与缓存为何总在自调用时失效这是代理模式最经典的陷阱几乎每个用 Spring 的人都踩过。场景OrderService里有两个方法createOrder调用了this.calcPrice()而calcPrice上标了Transactional。你期望事务生效结果没有。原因代理模式下外部调用createOrder走的是代理对象代理能织入逻辑。但方法内部this.calcPrice()里的this是真实对象不是代理对象调用压根不经过代理附加逻辑自然不触发。同理Cacheable、Async、自定义切面在自调用时都会失效。解决方式有三条我按推荐程度排拆类。把calcPrice挪到一个独立的 Bean 里通过依赖注入调用。这是最干净的做法副作用最少。注入自己。在OrderService里注入OrderService自己Spring 会注入代理对象然后self.calcPrice()就能走代理。要注意不能循环依赖导致的启动失败通常加Lazy解决。AopContext.currentProxy()。开启exposeProxytrue后可以拿到当前代理代码写成((OrderService) AopContext.currentProxy()).calcPrice()。这个写法侵入性强还会硬编码依赖 Spring我不太推荐。提示自调用失效不是 Bug是代理模式的必然结果。理解调用是否经过代理这个判断标准所有类似问题都能自行推导出来不用死记。6. 常见问题与排查技巧速查6.1 问题速查表现象可能原因排查方向附加逻辑不生效方法自调用未经过代理检查调用方持有的引用是代理还是真实对象附加逻辑不生效方法是 final 或类被 final 修饰改用 JDK 动态代理或去掉 finalClassCastExceptionJDK 代理只能转成接口类型按接口注入或开启 proxyTargetClassUndeclaredThrowableExceptioninvoke 抛出了接口未声明的受检异常调getUndeclaredThrowable()挖原始异常栈溢出invoke 里调用了 proxy 自身改为调用持有的目标对象引用InaccessibleObjectException高版本 JDK 模块访问限制添加对应的--add-opens参数构造函数被执行两次CGLIB 生成子类时会调用父类构造构造函数避免副作用或改回 JDK 代理equals/hashCode 行为异常Object 方法也被代理在 invoke 里单独判断 declaringClass6.2 我实际踩过的几个坑坑一Transactional加在 private 方法上。Spring AOP 基于代理代理只能拦截 public 方法更准确地说是只能重写可被继承和覆盖的方法private 方法压根不会被代理类重写。方法上标了注解代码不报错事务就是不生效。我在一个项目里排查了整整一个下午最后发现是方法修饰符的问题。后来我养成习惯事务注解只在 public 方法上用。坑二在InvocationHandler里做参数校验时忘了空参数。有一次写校验逻辑直接取args[0]判空结果某个无参方法被调用时args是 null直接 NPE。invoke的args参数在方法无参时确实是 null不是长度为 0 的数组这个细节文档里不显眼但必须处理。坑三CGLIB 代理后instanceof判断出问题。CGLIB 生成的是子类所以proxy instanceof TargetClass是 true但proxy.getClass() TargetClass.class是 falseproxy.getClass().getName()会是一个带$$EnhancerByCGLIB$$后缀的奇怪名字。如果有代码依赖类名做路由或序列化就会翻车。我在一个用类名做消息路由的模块里遇到过最后改成按注解或者接口类型路由。坑四动态代理和序列化一起用。代理对象序列化时InvocationHandler和持有的目标对象都得可序列化否则反序列化后 handler 为 null调用直接崩。分布式场景下代理对象最好别跨节点传传数据对象就行。6.3 调试代理类时我会用的几个手段第一把生成的类 dump 出来看。JDK 代理用前面提到的saveGeneratedFiles参数生成的文件在com/sun/proxy/下面。CGLIB 可以设置System.setProperty(cglib.debugLocation, ./cglib-debug)把生成的类写到磁盘。第二打印代理对象的实际类型。log.info(proxy class {}, bean.getClass().getName())一眼就能看出是 JDK 代理com.sun.proxy.$ProxyXX或jdk.proxy2.$ProxyXX还是 CGLIB带$$EnhancerBySpringCGLIB$$或$$EnhancerByCGLIB$$。这一行日志能省掉大量猜测。第三用AopUtils.isAopProxy(bean)和AopUtils.isJdkDynamicProxy(bean)/isCglibProxy(bean)做程序化判断Spring 提供了这些工具类比字符串匹配类名靠谱得多。第四怀疑代理没生效时直接打断点在invoke方法上。如果断点不进来说明调用根本没经过代理问题在调用方而不是代理配置。我个人在这些年做代理相关改造时最大的体会是代理模式真正的难点从来不在怎么生成代理类那些 API 半天就能学会而在搞清楚哪些调用会经过代理、哪些不会。把代理对象和真实对象在脑子里分清楚把每一次方法调用在脑子里走一遍调用链90% 的诡异问题都能自己定位。再补一个实用建议写自定义切面时尽量让附加逻辑保持无状态、可重入别在里面缓存和业务强相关的对象因为代理对象可能被多个线程共享也可能因为 Bean 生命周期变化被重建状态一旦挂上去问题会非常难查。