ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring AOP底层原理:从代理模式到JDK动态代理与CGLIB

Spring AOP底层原理:从代理模式到JDK动态代理与CGLIB 你有没有遇到过这种场景在Service方法上标了一个Transactional方法里的数据库操作自动就有了事务加了一个Log注解方法一调用日志自动打印。你什么都没做方法却像被“监听”了一样。很多人管这叫“Spring魔法”其实哪有什么魔法底层就是代理模式在撑场子。代理模式、动态代理、Spring AOP这三个词在面试题里出现频率极高但真正能把它们串起来讲清楚的人不多尤其是Spring AOP底层到底怎么选择JDK动态代理和CGLIB以及三级缓存和代理到底有什么关系网上讲得七零八落。这篇我按静态代理 - JDK动态代理 - CGLIB动态代理 - Spring AOP源码 - 容器/三级缓存 - 手写最小AOP 的顺序完整过一遍。适合想搞懂Spring AOP底层、准备面试或者被Transactional失效折磨过的人。看完你会发现Spring AOP无非就是“提前给你造一个代理对象把通知排成拦截器链”而已。1. 代理模式到底解决了什么问题1.1 从一个加日志的需求说起先讲一个最朴素的场景。你维护着一个老项目UserService里十几个方法每个方法体里都是业务逻辑。某天老板说要给所有增删改操作加审计日志你打算怎么办第一种方案硬改在UserServiceImpl里每个方法开头加一行日志结尾加一行日志。改完以后代码里到处是日志逻辑业务逻辑被淹没。下次要做事务、要做权限又得再改一遍。第二种方案写一个包装类UserServiceLogProxy让它实现UserService接口内部持有真正的UserServiceImpl在addUser这些方法里调用真对象前后输出日志。这就是静态代理不改变目标类把横切逻辑放到代理类里客户端面向接口编程实际拿到的是代理对象。很多新人会问这跟装饰器模式有什么区别差别主要看意图。装饰器模式重点在于“增强功能”比如给咖啡加牛奶代理模式重点在于“控制访问”比如在调用目标之前校验权限、记录日志、管理事务。但落到代码上两者结构非常像都是组合一个目标对象、实现同一个接口。在实际项目里不必过分纠结名字只要知道这种“用一个对象替另一个对象挡在前面”的思路就是代理的核心思想。代理对象对外暴露的接口和原对象一致调用方无感知但是真正干活的还是原对象代理只是在前后插入了一些横切逻辑。1.2 静态代理示例与优缺点静态代理的代码长这样。定义接口和目标类public interface UserService { void addUser(String name); } public class UserServiceImpl implements UserService { Override public void addUser(String name) { System.out.println(保存用户 name); } }然后手写一个代理类public class UserServiceProxy implements UserService { private final UserService target; private final TransactionManager tx; public UserServiceProxy(UserService target, TransactionManager tx) { this.target target; this.tx tx; } Override public void addUser(String name) { tx.begin(); try { target.addUser(name); tx.commit(); } catch (Exception e) { tx.rollback(); throw e; } } }这个写法确实很直白但用起来十分别扭。第一一个代理类只能服务一个接口如果系统里有几十个Service接口就得写几十个代理类。第二目标类里每个方法都得手工同步到代理类里接口一旦增加方法代理类和目标类要一起改。第三横切逻辑一多比如日志、事务、权限都要加你怎么组合是写LogProxy、TxProxy、SecurityProxy层层包吗还是在同一个Proxy里塞一大堆逻辑无论哪种代码量都会爆炸。所以静态代理不是没用而是在某个局部、接口稳定、横切逻辑固定的时候最直接。比如接入一个老SDK不想改SDK里的类只想在调用前埋点写一个实现同接口的包装类就够了。不用引入Spring不用学AOP概念代码直白到没人看不懂。但一旦规模上来就必须让代理类由机器在运行时生成这就是动态代理的出场前提。2. 动态代理运行时生成代理类2.1 JDK动态代理基于接口的代理JDK在java.lang.reflect包下提供了Proxy和InvocationHandler。代理类不用你手写而是运行时由JVM生成。你要做的只是告诉它代理对象要实现哪些接口方法被调用时回调哪个处理器。调用Proxy.newProxyInstance时JVM会动态生成一个$Proxy0类这个类实现了你传入的接口并且继承了java.lang.reflect.Proxy。因为Java是单继承所以它没法再继承业务类这就是JDK动态代理只能基于接口的根源。UserService target new UserServiceImpl(); UserService proxy (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), (p, method, args) - { System.out.println(before method.getName()); Object result method.invoke(target, args); System.out.println(after method.getName()); return result; }); proxy.addUser(张三);代码里的第三个参数是InvocationHandler所有代理对象的方法调用都会进入这里。method.invoke(target, args)这行才是真正调用目标对象的方法前后想塞什么逻辑都行。这里有个容易被忽略的点如果方法来自Object类比如toString、hashCode、equals需要特殊处理。否则每次调用toString也会走一遍代理逻辑某些场景下会发生诡异递归。我在手写框架时会单独判断Object.class.equals(method.getDeclaringClass())直接放行。JDK动态代理的应用非常广泛。MyBatis的Mapper接口就是经典案例你只写一个接口不写实现类MyBatis在运行时为每个Mapper接口生成代理对象方法调用被MapperProxy拦截翻译成SQL执行。没有JDK动态代理MyBatis这种“接口即DAO”的玩法根本没法实现。2.2 CGLIB动态代理基于继承的代理CGLIB走的是另一条路把目标类当作父类用ASM字节码技术在运行时生成一个子类再重写父类的方法。所有对代理对象方法的调用都会进入MethodInterceptor回调。因为没有“必须继承Proxy基类”的限制所以它不需要接口只要目标类和目标方法不是final就能代理。Enhancer enhancer new Enhancer(); enhancer.setSuperclass(UserServiceImpl.class); enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) - { System.out.println(before method.getName()); Object result proxy.invokeSuper(obj, args); System.out.println(after method.getName()); return result; }); UserServiceImpl proxy (UserServiceImpl) enhancer.create(); proxy.addUser(李四);注意一个特别容易踩的坑在CGLIB回调里调用目标方法时要用proxy.invokeSuper(obj, args)不要用method.invoke(obj, args)。因为obj已经是代理对象了method又是父类的方法声明如果通过反射直接调用obj方法调用会再次进入拦截器形成无限递归最后栈溢出。invokeSuper走的是FastClass机制直接定位到父类的原始方法实现不会触发拦截器。CGLIB能力很强但也有边界。目标类不能是final的否则没法生成子类final方法不会被子类重写所以拦截不到private方法在字节码层面是静态绑定同样拦截不到static方法也不行。在很多框架中CGLIB被重打包进了org.springframework.cglib包所以Spring项目里你往往不需要额外引入依赖直接用Spring内部的Enhancer即可。2.3 两种动态代理选型对比对比维度JDK动态代理CGLIB动态代理代理对象生成方式运行时生成实现接口的类运行时生成目标类的子类目标要求必须有接口目标类不能是final目标方法不能是final回调机制InvocationHandlerMethodInterceptor方法调用特点通过反射调用目标方法通过FastClass索引调用父类方法典型应用MyBatis Mapper、旧版Spring默认配置无接口的Service、Spring Boot默认配置限制只能代理接口中声明的方法无法代理final/static/private方法选型思路其实很简单目标类实现接口了优先考虑JDK动态代理目标类根本没接口只能考虑CGLIBSpring Boot默认帮你选了CGLIB因为它要让所有Bean行为一致。但这不代表CGLIB一定更好。JDK创建代理对象更快因为生成接口实现类比生成子类简单CGLIB在方法调用阶段因为有FastClass索引某些场景下调用开销更小。在Spring里两者最终都会被包装成统一的AOP代理框架性能差异远没有网上传的那么夸张不必过分纠结。3. Spring AOP的代理选择与底层实现3.1 AOP编程模型几个必须记住的名字在深入源码前先把Spring AOP的几个核心术语过一遍。Aspect是切面Pointcut是切点Advice是通知Advisor是把切点和通知绑在一起的对象。你可以把切点理解成“哪些方法要拦”通知理解成“拦下来之后干什么”Advisor就是一张配置表写着“这个切面在哪些方法上做哪些事”。Spring的Around注解对应MethodInterceptor是环绕通知Before、AfterReturning、AfterThrowing、After最终都会被适配成对应的MethodInterceptor统一放进一个拦截器链。这意味着你写多个切面的时候它们的执行顺序本质上就是拦截器链上等待处理的MethodInterceptor列表的顺序。理解了这一点再看AOP失效问题、看多次嵌套切面的执行顺序就不会发懵。3.2 源码说话DefaultAopProxyFactory到底怎么选代理Spring AOP不像上面那样直接调Proxy.newProxyInstance或Enhancer.create它封装了ProxyFactory。你可以用编程方式创建AOP代理ProxyFactory factory new ProxyFactory(); factory.setTarget(new UserServiceImpl()); factory.addInterface(UserService.class); factory.addAdvice((MethodInterceptor) invocation - { System.out.println(环绕前); Object result invocation.proceed(); System.out.println(环绕后); return result; }); UserService proxy (UserService) factory.getProxy();getProxy()内部会调用DefaultAopProxyFactory.createAopProxy(AdvisedSupport config)。我把Spring 5.3的核心逻辑简化贴在下面public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException { if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) { Class? targetClass config.getTargetClass(); if (targetClass.isInterface() || Proxy.isProxyClass(targetClass) || ClassUtils.isLambdaClass(targetClass)) { return new JdkDynamicAopProxy(config); } return new ObjenesisCglibAopProxy(config); } else { return new JdkDynamicAopProxy(config); } } private boolean hasNoUserSuppliedProxyInterfaces(AdvisedSupport config) { Class?[] ifcs config.getProxiedInterfaces(); return ifcs.length 0 || (ifcs.length 1 SpringProxy.class.isAssignableFrom(ifcs[0])); }这段逻辑非常关键。第一如果配置了optimize或者配置了proxyTargetClasstrue或者用户没有额外提供代理接口就进入“目标类判断”分支。第二在目标类判断分支里如果目标类本身是接口或者已经是JDK代理类或者是lambda类就回落到JDK代理否则才用CGLIB。所以网上很多人说“Spring Boot默认开启CGLIB就是所有Bean都走CGLIB”这句话不准确。如果目标类是个接口即使开了proxyTargetClasstrueSpring还是会退化成JDK动态代理。这是最容易在面试里被追问的细节。3.3 拿到代理之后拦截器链是怎么执行的确定了代理类型后getProxy()返回的就是一个代理对象。以JdkDynamicAopProxy为例它实现了InvocationHandler代理对象的所有方法调用都会进入它的invoke方法。在invoke里Spring会把AdvisedSupport中配置的Advisor列表取出来交给AdvisorChainFactory转成ListMethodInterceptor然后创建ReflectiveMethodInvocation从索引0开始逐个调用拦截器直到最后一个拦截器执行完才真正通过反射调用目标方法。整个过程就是一个责任链模式。每个拦截器都可以在调用proceed()之前做前置逻辑在proceed()之后做后置逻辑还可以抛异常、短路、放行。Around通知里那个ProceedingJoinPoint.proceed()底层就是这条拦截器链的触发点。你甚至可以在拦截器里改变参数、改变返回值只要最后把结果交给下一个环节。3.4 为什么Spring Boot默认用CGLIBSpring Framework从4.0开始如果目标类没有接口默认使用CGLIBSpring Boot 2.x开始直接把spring.aop.proxy-target-class默认值设成了true也就是说所有Bean默认启用CGLIB代理除非你手动改成false。原因很简单很多实际项目里的Service Bean不实现接口JDK代理搞不定统一用CGLIB能让所有Bean的代理行为可预期避免“有的代理有的不代理”的混乱。但这个默认值也带来了一些实际影响。以前用JDK代理时你面向接口注入代码里到处是接口类型改成CGLIB后你不小心把字段声明成实现类类型反而能注入成功因为CGLIB生成的代理类会继承实现类。这会让一些旧代码在切换配置后出现奇奇怪怪的问题。我在实际项目里建议除非团队已经有明确的代理方式约定否则不要轻易去改spring.aop.proxy-target-classfalse保持Boot默认值把精力放在切面本身的设计上。4. 与Spring容器结合自动代理与三级缓存4.1 谁在容器里创建代理对象前面讲的是手动创建代理Spring容器里不会让每个Bean自己调ProxyFactory。它引入了一个叫AbstractAutoProxyCreator的BeanPostProcessor。以EnableAspectJAutoProxy为例它会把AnnotationAwareAspectJAutoProxyCreator注册到容器里这个类继承自AbstractAutoProxyCreator。任何一个Bean在生命周期走到初始化完成后postProcessAfterInitialization会调用wrapIfNecessary检查当前Bean是否有匹配的Advisor有匹配就创建代理并返回代理对象替换原Bean。这一步发生在Bean返回给调用方之前所以Autowired注入到的、ApplicationContext.getBean拿到的已经是代理对象。原Bean可能被遗忘在某个角落唯一的作用就是作为代理的target被反射调用。这就是为什么你用debug打断点看到controller里注入的service对象class名带着$$EnhancerByCGLIB$$或者$Proxy而不是你写的UserServiceImpl。4.2 三级缓存与代理的关系三级缓存和AOP的关系是最容易被讲乱的部分。三级缓存指的是DefaultSingletonBeanRegistry里的三个MapsingletonObjects存完整单例earlySingletonObjects存早期引用singletonFactories存ObjectFactory工厂。Spring创建Bean的流程是实例化原始对象 - 属性填充 - 初始化。在实例化完成之后Spring会立刻把一个ObjectFactory放进三级缓存。为什么要这么早为了处理循环依赖。假设A依赖BB依赖A。A实例化后还没填属性B在填充A时发现A正在创建中于是去三级缓存里拿A的ObjectFactory调用getObject()。这一步如果A配置了切面AbstractAutoProxyCreator的getEarlyBeanReference会提前生成代理对象然后把这个代理对象放到二级缓存同时删掉三级缓存里的工厂。B拿到的就是A的代理。如果没有AOPgetObject()返回的就是原始对象同样放进二级缓存。所以三级缓存里存的不是Bean本身而是一个“创建早期引用”的工厂函数。这里有一个关键点二级缓存明明能存早期引用了为什么还要三级缓存因为A在实例化后、属性填充前Spring并不知道A最终会不会被AOP代理。如果一开始就把原始对象放到二级缓存等后面再创建代理B手里已经攥着原始对象了增强就彻底失效。三级缓存存的是工厂每次需要早期引用时才执行一次getEarlyBeanReference动态决定返回原始对象还是代理对象。一旦返回过一次结果会被放进二级缓存下次请求直接拿缓存保证同一个Bean只产生一个早期暴露对象不会每次依赖注入都生成新代理。4.3 无循环依赖时代理发生在哪里没有循环依赖时AOP代理发生在Bean初始化完成后的postProcessAfterInitialization阶段也就是PostConstruct、InitializingBean.afterPropertiesSet这些都执行完然后才返回代理对象。有循环依赖时代理可能提前到实例化刚完成就被创建。这个差异正好解释了为什么有些循环依赖场景下构造器注入或字段注入拿到的对象和预期不一样。网上常说“三级缓存解决循环依赖”严格说应该是三级缓存配合SmartInstantiationAwareBeanPostProcessor让AOP代理在正确的时机生成避免引用方拿到原始对象之后失去增强能力。如果你自己手写一个Spring不考虑AOP只解决循环依赖其实两级缓存就够了。正因为有AOP这种“初始化完成后要替换最终对象”的机制存在才需要先放一个工厂延迟决定要不要提前造代理。我把这段放在这里是想让你把三级缓存和AOP当成一对组合来理解而不是死记三个Map的名字。5. 实战手写一个最小AOP框架5.1 设计拦截器链看源码容易飘自己写一遍才知道水有多深。我基于JDK动态代理实现一个极简的责任链AOP直接跑起来就能看到效果。先定义一个拦截器接口public interface MethodInterceptor { Object intercept(MethodInvocation invocation) throws Throwable; }然后定义调用链对象MethodInvocation它的核心职责是维护当前拦截器执行到第几个以及什么时候真正调用目标方法public class MethodInvocation { private final Object target; private final Method method; private final Object[] args; private final ListMethodInterceptor interceptors; private int current 0; public MethodInvocation(Object target, Method method, Object[] args, ListMethodInterceptor interceptors) { this.target target; this.method method; this.args args; this.interceptors interceptors; } public Object proceed() throws Throwable { if (current interceptors.size()) { return method.invoke(target, args); } return interceptors.get(current).intercept(this); } }proceed()方法就是责任链的入口如果已经走到链尾就用反射调用目标方法否则取出下一个拦截器执行。每个拦截器可以在调用invocation.proceed()前后插入自己的逻辑类似Spring AOP里Around通知的写法。这个玩具模型已经把ReflectiveMethodInvocation最核心的思路复刻出来了。5.2 代理工厂封装有了调用链对象接下来用JDK动态代理做一个MiniAop.wrap方法public class MiniAop { public static T T wrap(T target, MethodInterceptor... interceptors) { Class?[] interfaces target.getClass().getInterfaces(); if (interfaces.length 0) { throw new IllegalArgumentException(演示代码基于JDK动态代理目标类需要实现接口); } return (T) Proxy.newProxyInstance( target.getClass().getClassLoader(), interfaces, (proxy, method, args) - { if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(target, args); } return new MethodInvocation(target, method, args, Arrays.asList(interceptors)).proceed(); }); } }注意我单独判断了Object类的方法这样toString、hashCode、equals不会误入拦截器链。如果你去掉这个判断很可能会看到代理对象被打印时触发了日志切面那感觉非常酸爽。真实生产框架里也会做类似处理只是藏得比较深。5.3 写个测试用例验证责任链现在用这个迷你框架给UserService加两个“切面”一个事务逻辑一个日志逻辑。UserService userService MiniAop.wrap(new UserServiceImpl(), invocation - { System.out.println(开启事务); try { return invocation.proceed(); } finally { System.out.println(关闭事务); } }, invocation - { System.out.println(记录日志); return invocation.proceed(); }); userService.addUser(王五);执行结果如下开启事务 记录日志 保存用户王五 关闭事务两个拦截器的执行顺序和数组顺序一致先执行第一个第一个调用proceed()进入第二个第二个调用proceed()真正进入目标方法然后依次返回。如果你想调换顺序把wrap里的拦截器参数换一下就行。这个模型虽然简陋但已经能解释Spring AOP多切面执行顺序、以及proceed()为什么能层层穿透。如果你还想支持自定义注解做切点可以在拦截器里判断method.isAnnotationPresent(Log.class)命中后执行日志逻辑思路和AnnotationAwareAspectJAutoProxyCreator一脉相承。6. 常见问题与排查技巧6.1 事务注解不生效的经典现场知道代理怎么工作很多“玄学Bug”就有了解释。Transactional失效最典型的场景就是自调用同一个类里this方法调用另一个标注事务的方法。注意这里的this是Bean原始对象不是代理对象所以方法上再有注解也拦不到。解决办法有几种最简单的就是拆Bean把需要事务的方法放到另一个Service里注入调用或者把Bean自己注入进来用注入的那个代理对象去调用也可以配置exposeProxytrue后使用AopContext.currentProxy()。更隐蔽的是private方法上加事务注解CGLIB生成子类时无法重写private方法JDK代理的接口方法里也没有它所以必然失效。同理final方法、final类也无法被代理增强。很多老代码重构时顺手把敏感方法设置成private或final事务就悄悄失效了排查起来特别坑。遇到这类问题先确认方法修饰符再确认是不是自调用90%的“事务不生效”都出在这两个点上。6.2 如何判断当前Bean到底是不是代理排查AOP问题第一件事是确认目标对象到底有没有被代理。你可以用AopUtils.isAopProxy(bean)直接判断也可以看class名JDK代理类名通常以$Proxy开头CGLIB代理类名里含有$$EnhancerByCGLIB$$。如果是在Idea里调试直接把注入的对象打印出来看getClass().getName()一目了然。Spring启动日志里也会暴露线索。开启org.springframework.aop的DEBUG日志后能看到类似generated proxy target class的提示。Spring Boot项目可以在application.properties里加一行logging.level.org.springframework.aopDEBUG。看到代理创建日志后再配合切面表达式排查问题范围就缩小了一半。6.3 CGLIB和JDK代理各自的坑CGLIB不是万能的。目标类没有无参构造、目标是final类、方法是final方法都会翻车。Spring Boot默认开CGLIB之后很多老代码因为构造器参数注入的方式也踩过坑。另一个容易忽略的问题是CGLIB生成的子类会继承目标类的所有非私有方法toString、equals这些也会被重写如果切面表达式写得太宽这些“意外方法”也会被拦截导致日志里出现莫名其妙的输出。JDK代理的限制则主要反映在类型上一个Service实现类实现了一个接口Spring如果选JDK代理你注入时只能声明接口类型不能声明实现类类型。很多人把字段写成UserServiceImpl结果一启动就ClassCastException本质就是代理对象不是UserServiceImpl的子类而是Proxy的子类。改成接口注入问题立刻消失。理解原理后这类报错基本不用查资料就能定位。6.4 真实框架里的代理远不止Spring AOP代理模式不只是Spring AOP的地基。MyBatis的Mapper接口就是用JDK动态代理实现的每个MapperProxy把接口方法调用翻译成SqlSession操作所以你才能只写接口不写实现类。Spring Security的方法级安全注解PreAuthorize也是通过AOP代理在方法调用前做权限校验底层链路和事务一样依赖拦截器链。Spring AI里的模型客户端内部同样大量使用代理机制封装调用细节让你面向一个接口就能发起模型调用不需要关心底层的序列化和网络请求。所以我说代理不是一个孤立的设计模式而是整个Java生态的地基。你今天搞明白的InvocationHandler和Enhancer明天在看MyBatis源码、看Spring Security源码时还会反复遇到。理解它相当于拿到了一把能打开好多框架的钥匙。我个人在实际操作中的体会是别再死背“JDK代理需要接口CGLIB不需要”这种结论了把今天这段代码自己敲一遍再跟着DefaultAopProxyFactory源码走一遍比背十遍面试题都管用。源码拿到手不要通读直接找ProxyFactory - AopProxy - MethodInterceptor这条线其他旁路一概不看效率最高。等你亲手造出那个迷你AOP框架再回头看Spring的代理逻辑你会觉得它亲切得像自己写的一样。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进