ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JDK动态代理从原理到实战:Spring AOP与面试避坑全解析

JDK动态代理从原理到实战:Spring AOP与面试避坑全解析 JDK动态代理这个话题我每次面试别人或者被面试的时候几乎都会被问到。说它是Java开发绕不过去的坎一点也不夸张——Spring AOP、MyBatis的Mapper接口、Feign、Retrofit这些你用惯了的框架底层全是动态代理在撑腰。很多同学看源码的时候卡壳就是因为动态代理这块没吃透一看到Proxy.newProxyInstance就头皮发麻更别提InvocationHandler和Method那一坨参数了。这篇文章我会从最朴素的为什么要代理讲起把JDK动态代理和CGLIB这两条路的底层原理掰开揉碎配上可以直接跑起来的代码最后把面试最爱问的几道题和实际项目中踩过的坑一起整理出来。不管你是刚入门、正在准备面试还是写了几年业务代码想补补底层知识我相信这篇文章都能让你对动态代理有一个系统性的认识。1. 为什么需要动态代理从静态代理到动态代理的演进1.1 先看一段最朴素的代码问题假设你现在有个订单服务接口就一个创建订单的方法。代码写得很干净业务逻辑一目了然public interface OrderService { void createOrder(String orderNo); } public class OrderServiceImpl implements OrderService { Override public void createOrder(String orderNo) { System.out.println(订单创建成功订单号 orderNo); } }一切都挺好直到有一天产品经理跑过来说订单创建要记录日志耗时也要统计。你直接改实现类东加一行日志西加一个计时器改完之后发现代码开始变得臃肿。更要命的是如果系统里有十几个业务类都需要加日志和耗时统计每个类都这么加一遍重复代码满天飞。更麻烦的是这个需求说变就变。今天要记日志明天说日志太啰嗦去掉今天要统计耗时明天说只统计超过100毫秒的。每一次变动你都要把所有业务类翻出来改一遍。这种侵入式的修改让业务代码和技术代码彻底纠缠在一起维护起来非常痛苦。这正是代理模式出场的时候。代理模式的核心思想是不直接修改目标对象而是通过一个代理对象来访问目标对象在代理对象里增加额外的功能逻辑。对于调用方来说它面向的是接口根本感知不到背后是不是有代理在干活。1.2 静态代理的局限与动态代理的解法按着代理模式最简单的写法你会先做一个静态代理类public class OrderServiceStaticProxy implements OrderService { private final OrderService target; public OrderServiceStaticProxy(OrderService target) { this.target target; } Override public void createOrder(String orderNo) { long start System.currentTimeMillis(); System.out.println(静态代理准备创建订单 orderNo); target.createOrder(orderNo); long cost System.currentTimeMillis() - start; System.out.println(静态代理订单创建完成耗时 cost ms); } }调用方改成new OrderServiceStaticProxy(new OrderServiceImpl())日志和耗时统计不就有了吗业务类干干净净。但静态代理的毛病也很明显一个代理类只能代理一种接口。如果系统里有几十个服务接口你就得写几十个代理类每个代理类里的代码结构还几乎一模一样。而且如果目标类没有实现接口静态代理想代理都没办法——它必须要和目标类实现同样的接口才能无缝替换。动态代理就是要解决这个问题。它能在运行期间、在JVM加载类之后动态地为你生成一个代理对象。你只要写一个通用的增强逻辑不管什么接口、什么业务类都能复用同一套代理逻辑。这就像你雇了一个万能的中介不管是买房子、租办公室还是注册公司它都能以你的名义去办你只需要告诉它办完之后跟我汇报一声就行。从静态代理到动态代理本质上是把为每个类写一个代理变成了写一次代理逻辑运行时自动适配所有类这是一种根本性的思路转变。2. 动态代理的两大实现方案JDK与CGLIB2.1 JDK动态代理的底层模型JDK动态代理是Java原生就支持的能力核心就两个东西InvocationHandler接口和Proxy类。InvocationHandler是你要写的增强逻辑的载体它只有一个invoke方法Proxy类负责在运行时创建代理对象。public interface InvocationHandler { Object invoke(Object proxy, Method method, Object[] args) throws Throwable; }这个invoke方法里三个参数记清楚它们的含义proxy是生成的代理对象本身method是当前被调用的目标方法——注意这里是java.lang.reflect.Method对象它代表的是接口里的方法args则是调用这个方法的参数数组。Proxy的底层原理用一句话概括就是JVM在运行期间按照你传入的接口列表动态地生成一个实现了这些接口的Class字节码然后实例化出一个对象这个对象就叫代理对象。当你调用代理对象的任何接口方法时JVM都会把调用转发给你指定的InvocationHandler的invoke方法。这里有一个很多初学者都会误解的点JDK动态代理生成的代理类和你自己写的类没有任何继承关系它是凭空生成的一个新类。这个新类实现你的接口但不继承你的实现类。所以如果你的目标对象没有实现任何接口JDK动态代理就无能为力了——因为它生成的代理对象必须通过接口来假装是目标类型。JDK动态代理的调用链路是这样的调用方持有一个代理对象引用但类型是接口调用接口方法时代理对象内部持有InvocationHandler引用所以调用会进入invoke方法你在invoke里做增强逻辑然后再通过反射调用真正的目标方法。2.2 CGLIB为什么能代理类CGLIB走的是另一条路它不依赖接口而是利用字节码技术直接生成目标类的一个子类作为代理类。子类会重写目标类的非final方法在重写的方法里执行拦截器逻辑。CGLIB的核心是Enhancer和MethodInterceptor两个东西。Enhancer负责配置代理参数和生成代理对象MethodInterceptor和JDK的InvocationHandler类似都是写增强逻辑的地方。public interface MethodInterceptor extends Callback { Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable; }这里的MethodProxy是CGLIB特别提供的一个优化点——它不像JDK那样每个方法都走反射调用而是内部为方法生成了快速访问的索引直接调用目标方法性能比纯反射高不少。CGLIB生成子类代理意味着目标类必须能被继承类不能是final的方法也不能是final的。private方法不会被代理因为子类根本无法重写private方法。为什么CGLIB能做到因为ASM字节码库可以在运行时直接操作字节码、生成新的类文件并加载到JVM这个过程绕过了编译期所以才能凭空造出一个子类。2.3 选型对比谁更优选JDK还是选CGLIB这是面试官特别爱问的问题。直接说结论目标对象有接口优先用JDK动态代理。因为它是JDK原生能力没有额外依赖生成的代理类更轻量而且在JDK高版本中性能也在持续优化。目标对象没有接口或者希望直接代理类而不是接口就得用CGLIB。Spring的决策逻辑也是一样的Spring AOP默认使用JDK动态代理但如果目标对象没有实现接口就自动切换到CGLIB。Spring Boot 2.x之后默认强制使用CGLIB——哪怕你有接口也直接代理类。原因很简单CGLIB不需要接口就能代理适应的场景更广而且避免了JDK代理只能针对接口方法做增强的限制。性能方面很多人有误解觉得CGLIB反射调用少就一定快。其实在JDK 8之后的版本里JDK动态代理的生成成本和调用成本都已经大幅优化很多场景下和CGLIB不相上下甚至在方法调用频繁时反超。选择时把有没有接口作为首要决策依据就够了性能差异在绝大多数业务场景里属于可以忽略的级别。3. 手写一个完整的JDK动态代理3.1 项目完全不需要任何第三方依赖我这里的演示项目就是个纯Java工程不需要Spring不需要Maven额外依赖用JDK自带的API就能跑起来。先定义好接口和业务实现类然后再写一个通用的代理工厂。接口和实现类就用前面订单服务的例子这块不再重复。接下来直接写真正的代理工厂。public class LogProxyFactory { public static T T createProxy(T target) { // 拿到目标对象实现的接口列表 Class?[] interfaces target.getClass().getInterfaces(); // 创建代理对象三步类加载器、接口列表、调用处理器 return (T) Proxy.newProxyInstance( target.getClass().getClassLoader(), interfaces, new LogInvocationHandler(target) ); } }这个工厂的关键点在于getInterfaces()。我的target是OrderServiceImpl它实现了OrderService接口所以这里拿到的是一个包含OrderService的Class数组。如果这个类还实现了其他接口所有接口都会被代理。3.2 核心逻辑InvocationHandler实现接下来是最核心的增强逻辑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 { String methodName method.getName(); long start System.currentTimeMillis(); System.out.println( 日志增强开始 methodName ); System.out.println(参数列表 Arrays.toString(args)); // 反射调用目标对象的真实方法拿到返回值 Object result method.invoke(target, args); long cost System.currentTimeMillis() - start; System.out.println(方法耗时 cost ms); System.out.println( 日志增强结束 ); return result; } }mod.invoke(target, args)这一步是真正转发到业务逻辑的地方。method对象是接口上定义的Method通过反射调用target实例的该方法。注意target是真正的实现类对象不是代理对象所以不会造成二次代理的递归调用。这里顺便说一个细节invoke方法里的proxy参数实际工作中几乎用不到。它的存在是为了在特殊场景下——比如需要把代理对象传给别的地方时——你有地方可以取到当前这个代理对象。但如果你在invoke方法里尝试调用proxy的另一个方法那就可能造成递归调用要特别小心。3.3 代理对象到底长什么样写个main方法跑一下public class Main { public static void main(String[] args) { // 关键把生成的代理类字节码保存下来方便查看 System.setProperty(sun.misc.ProxyGenerator.saveGeneratedFiles, true); OrderService target new OrderServiceImpl(); OrderService proxy LogProxyFactory.createProxy(target); System.out.println(代理类型 proxy.getClass().getName()); proxy.createOrder(NO20250001); } }运行结果代理类型com.sun.proxy.$Proxy0 日志增强开始createOrder 参数列表[NO20250001] 订单创建成功订单号NO20250001 方法耗时1ms 日志增强结束 看到com.sun.proxy.$Proxy0这个类型名了吗这就是JVM运行时动态生成的那个代理类。它实现了OrderService接口所以能被赋值给OrderService类型的变量。如果你打开系统属性保存下来的字节码反编译一下会发现$Proxy0大致长这样它继承java.lang.reflect.Proxy然后实现了OrderService接口。类里有三个Method静态字段对应Object的equals/hashCode/toString和OrderService的createOrder。每个方法实现里都调用super.h也就是InvocationHandler的invoke方法把当前方法对应的Method对象和参数传过去。注意一个关键点$Proxy0继承的是Proxy类每个代理对象内部都有一个InvocationHandler字段。这就是为什么JDK动态代理的代理对象只能转成接口类型不能转成实现类类型——因为代理类和你业务实现类之间根本没有继承关系。很多人在实际项目中想强制把代理cast成业务实现类结果抛ClassCastException原因就在这。4. CGLIB动态代理实战与踩过的坑4.1 核心API用法CGLIB是第三方库需要引入依赖。Spring框架里已经内置了CGLIB的封装但你可以直接使用最底层的API。我先展示一种最简洁的使用方式。dependency groupIdcglib/groupId artifactIdcglib/artifactId version3.3.0/version /dependency然后写一个日志拦截器public class LogMethodInterceptor implements 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()); // 注意这里不是method.invoke而是proxy.invokeSuper Object result proxy.invokeSuper(obj, args); System.out.println(CGLIB耗时 (System.currentTimeMillis() - start) ms); return result; } }配置Enhancer并生成代理Enhancer enhancer new Enhancer(); // 设置父类目标类 enhancer.setSuperclass(OrderServiceImpl.class); // 设置回调拦截逻辑 enhancer.setCallback(new LogMethodInterceptor()); // 生成代理对象 OrderServiceImpl proxy (OrderServiceImpl) enhancer.create(); proxy.createOrder(NO20250002);注意这里的区别CGLIB生成的是OrderServiceImpl的子类所以代理对象可以直接强制转换为OrderServiceImpl类型。这正是CGLIB不需要接口的根本原因。还有一点要留意MethodInterceptor的intercept方法里拿到的方法有两种调用方式直接用method.invoke(obj, args)也行但CGLIB提供了MethodProxy.invokeSuper(obj, args)这种更高效的方案它通过索引定位到目标类的方法避免反射的性能开销。实际项目里优先用invokeSuper。4.2 final方法、final类和自调用问题CGLIB虽然强大但限制条件一个都不少。我列几个实战中经常踩到的坑每一个都是真实项目里能让你排查半天的第一个坑final类无法代理。Enhancer.setSuperclass一个final类直接报错告诉你无法生成子类。解决思路是重构代码去掉final或者换回JDK动态代理走接口。第二个坑final方法无法被增强。如果你的方法被子类重写时发现是final的那CGLIB生成的子类里只能原样调用父类逻辑拦截器根本不会触发。这在排查问题时最隐蔽——你以为切面生效了实际业务方法直接跑完没有任何日志输出。第三个坑自调用失效。假设OrderServiceImpl里有个方法AA内部调用了另一个方法B。你用CGLIB代理了OrderServiceImpl当你从外部调用代理对象的A方法时增强逻辑能触发但A方法内部调用this.b()时这个this是代理对象还是原始对象答案是原始对象。因为invokeSuper里面执行的是目标对象的方法逻辑方法内部的this指向的是目标对象不是代理对象。所以B方法的增强逻辑不会触发。这里我单独展开一下因为自调用问题在Spring AOP中也是高频故障点。你在invokeSuper里调用的是父类目标类的方法父类方法内部的this当然指向父类实例本身。想要让内部调用也走代理必须把调用方式改成通过代理对象来调用Spring里的解决方案是暴露代理对象或者用AopContext.currentProxy()这个后面再细说。第四个坑CGLIB对构造函数有要求。Enhancer生成子类时如果父类没有无参构造器创建代理对象就会报错。你必须使用Enhancer.create(Class[] argumentTypes, Object[] arguments)传入匹配的构造参数或者干脆给目标类加一个无参构造。这个在实践里也经常遇到特别是把一些老代码纳入代理体系时。5. 框架中的动态代理这手好牌是怎么被玩出花的5.1 Spring AOP的本质Spring AOP大概是动态代理最经典的应用。声明式事务Transactional、日志切面Aspect底层全部依托于动态代理。当你给一个Bean配置切面后Spring在创建Bean时会判断这个Bean是否满足切面条件如果满足就返回一个代理对象替换掉原本的Bean实例注入到其他依赖方手里。Spring的决策逻辑前面提过目标类有接口且没有被强制CGLIB就用JDK动态代理否则用CGLIB。Spring Boot 2.x默认spring.aop.proxy-target-classtrue也就是强制CGLIB。这个配置意味着哪怕你的Bean有接口Spring也直接生成CGLIB子类代理。值得琢磨的是为什么Spring Boot要这么改。JDK代理的局限是只能代理接口方法如果你的一个Bean是通过类上声明的Aspect来增强的而增强的方法不在接口里那JDK代理就无能为力。CGLIB统一了这种行为模式消除了很多为什么这个切面方法不生效的疑问。代价是CGLIB对final方法、自调用更敏感所以Spring还提供了EnableAspectJAutoProxy(exposeProxy true)来开放AopContext.currentProxy()解决自调用问题。5.2 MyBatis Mapper、Feign和Retrofit的代理思路MyBatis能让你的Mapper接口不需要写实现类就能执行SQL靠的也是动态代理。你在Mapper接口上定义方法标注上Select或者关联XML文件MyBatis在启动时通过MapperRegistry扫描接口为每个Mapper接口创建一个代理对象。调用任何方法时代理会把方法名和参数解析成SQL交还给SqlSession执行。注意MyBatis用JDK动态代理是有讲究的Mapper一定是接口所以天然满足JDK代理的接口要求。代理对象实现这个接口invoke方法里根据方法签名去配置里匹配SQL语句。这也是为什么MyBatis要求Mapper不能有实现类——有实现类反而会让代理没有任何存在的必要。Feign的思路类似你的FeignClient定义了一个远程调用的接口Feign在启动时通过JDK动态代理为每个接口创建代理当你调用接口方法时代理把方法参数拼成HTTP请求用HTTP客户端发出去再把响应反序列化回来。整个远程调用过程被包装成了本地方法调用开发者感受不到网络细节。Retrofit更不用说它为每个API接口生成代理把注解和方法参数翻译成OkHttp请求。微信小程序的Java后端、各种对接第三方开放平台的SDK好多都是这种模式定义一个接口框架动态生成代理把方法调用翻译成远端的HTTP调用。这类框架的共同点是接口方法是描述意图代理是执行意图。接口定义得越干净框架越有操作空间。这也解释了为什么这些框架都严格要求接口不能放实现类、不能加final方法因为一旦有了实体逻辑代理的生成和转发就不可控了。6. 面试高频题与企业级避坑清单6.1 面试涉及的高频考点动态代理在面试中的出现频率太高了我总结了几道必问题目和回答思路第一道JDK动态代理和CGLIB的区别。回答要点分三层。实现机制上JDK基于接口和反射动态生成实现接口的类CGLIB基于ASM字节码生成目标类的子类并重写方法。使用条件上JDK要求目标对象必须有接口CGLIB要求目标类可继承、方法非final。性能上JDK在JDK 8已经相当优秀CGLIB在生成代理类阶段更重但方法调用时的MethodProxy索引调用也很快。最后补一句Spring的决策逻辑就能把分拿满。第二道动态代理为什么能实现AOP。关键说清楚不改业务代码的前提下通过代理对象在方法调用前后插入逻辑。由于调用方持有的是代理对象调用方法时候会先进入增强逻辑然后才转发到真实业务方法。这个机制让切面逻辑可以在方法执行前、执行后、甚至异常后进行干预。第三道JDK动态代理生成的代理对象为什么不能被强转为实现类类型。因为$Proxy0只是实现了你的接口它和你目标类之间根本没有继承链条。这个问题的变种是为什么Spring默认不转成目标类。你要理解JDK代理里没有继承关系从接口到实现类都是通过反射、通过InvocationHandler的转发完成。第四道动态代理和静态代理的区别。答静态代理每个目标类都需要写一个代理类方法数量增长的时候维护成本爆炸是加分点再补一句动态代理把增强逻辑提取成InvocationHandler一份逻辑可以服务无数个目标类就是高分答案。第五道CGLIB的invokeSuper和method.invoke的区别。这个问题有陷阱。method.invoke(obj, args)里的obj是你传入的代理对象还是目标对象很多回答会翻车。CGLIB拦截到的Method是目标类的方法如果你传的是代理对象会再次触发拦截形成无限递归。正确做法是用proxy.invokeSuper直接调父类逻辑绕过拦截器根本走不到递归。6.2 线上真实踩坑实例写代码的时候大家都会线上出问题的时候才是真正考验。我把自己经历过的和听朋友讲过的高频故障整理成清单全是血泪经验第一个坑代理对象序列化失败。有个项目把查询到的用户信息塞到Redis里结果报序列化错误。查了半天发现塞进去的是Spring生成的代理对象而不是真实的实体类对象。原因就是某处把增强后的Bean直接序列化了。解决方法是序列化前转成真实对象或者用FastJson/Jackson的toJSONString先把代理转成JSON字符串再存避免直接序列化代理。第二个坑事务不生效。Service里方法A调方法B两个方法都标了Transactional但实际只有A方法的事务生效B方法捕获了异常却没有回滚。原因就是自调用——B被A内部this调用了没有走代理。解决办法是在类里注入自身代理对象或者用AopContext.currentProxy()。另有一种办法是把B抽到另外的Service里让跨类调用自然走代理。第三个坑CGLIB代理导致getDeclaredField失效。项目里有个工具类用反射拿目标类的私有字段去解析注解。换成CGLIB代理之后getDeclaredFields拿到的全是一堆代理类自己的字段比如CGLIB$CALLBACK_0原始字段全跑掉了。这个问题在旧版本Spring里相当常见我后来在阿里Java开发手册里也看到过相关的约定。解决方法是判断当前对象是否代理对象如果是就先取目标类。第四个坑把代理对象存到静态字段里。有个定时任务框架要求任务实例不能是代理对象结果某次配置了一把任务直接不执行。排查发现是Bean被Spring的异步注解代理了生成的对象结构与框架预期不符。以上这些问题系统性的排查思路是第一看类名是不是$Proxy开头或者是否有CGLIB字样第二检查是不是自调用导致的切面不生效第三想清楚当前你手里的对象究竟是目标对象还是代理对象。很多时候只要想到我可能拿到的是代理对象这个问题故障原因就浮出水面了。我在实际排查这类问题的时候最常用的一招是先打印对象.getClass().getName()。如果看到com.sun.proxy.$Proxy或者class com.xxx.xxx$$EnhancerBySpringCGLIB基本可以确认是代理出的问题。这比看几十行堆栈日志都快得多简单粗暴但非常有效。另一个习惯是在需要反射的地方先判断当前对象是否一个代理对象——Spring提供了AopUtils.isAopProxy(obj)工具方法用它来区分代理对象和目标对象能省掉很多无谓的排查时间。动态代理这个东西一开始学的时候会觉得抽象又是反射又是字节码又是ASM好像跟日常写的CRUD八竿子打不着。但当你真正开始读Spring源码、开始排查线上诡异问题的时候你会发现它就是那个串起所有框架的核心线索。搞懂它你眼里那些框架就不再是黑盒而是一套组合拳接口定义行为代理负责包装行为增强逻辑在包装层里自由发挥。我建议你学完之后顺手打开Spring的AbstractAutoProxyCreator源码看一遍你会发现之前看不懂的method.invoke、后置处理器、切面注册全部像拼图一样自己拼上了。这就是动态代理带给你的一个完整视角。
返回列表