你有没有遇到过这种场景:在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 |
| 回调机制 | InvocationHandler | MethodInterceptor |
| 方法调用特点 | 通过反射调用目标方法 | 通过FastClass索引调用父类方法 |
| 典型应用 | MyBatis Mapper、旧版Spring默认配置 | 无接口的Service、Spring Boot默认配置 |
| 限制 | 只能代理接口中声明的方法 | 无法代理final/static/private方法 |
选型思路其实很简单:目标类实现接口了,优先考虑JDK动态代理;目标类根本没接口,只能考虑CGLIB;Spring 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,或者配置了proxyTargetClass=true,或者用户没有额外提供代理接口,就进入“目标类判断”分支。第二,在目标类判断分支里,如果目标类本身是接口,或者已经是JDK代理类,或者是lambda类,就回落到JDK代理;否则才用CGLIB。所以网上很多人说“Spring Boot默认开启CGLIB就是所有Bean都走CGLIB”,这句话不准确。如果目标类是个接口,即使开了proxyTargetClass=true,Spring还是会退化成JDK动态代理。这是最容易在面试里被追问的细节。
3.3 拿到代理之后:拦截器链是怎么执行的
确定了代理类型后,getProxy()返回的就是一个代理对象。以JdkDynamicAopProxy为例,它实现了InvocationHandler,代理对象的所有方法调用都会进入它的invoke方法。在invoke里,Spring会把AdvisedSupport中配置的Advisor列表取出来,交给AdvisorChainFactory转成List<MethodInterceptor>,然后创建ReflectiveMethodInvocation,从索引0开始逐个调用拦截器,直到最后一个拦截器执行完,才真正通过反射调用目标方法。
整个过程就是一个责任链模式。每个拦截器都可以在调用proceed()之前做前置逻辑,在proceed()之后做后置逻辑,还可以抛异常、短路、放行。@Around通知里那个ProceedingJoinPoint.proceed(),底层就是这条拦截器链的触发点。你甚至可以在拦截器里改变参数、改变返回值,只要最后把结果交给下一个环节。
3.4 为什么Spring Boot默认用CGLIB
Spring Framework从4.0开始,如果目标类没有接口,默认使用CGLIB;Spring Boot 2.x开始,直接把spring.aop.proxy-target-class默认值设成了true,也就是说所有Bean默认启用CGLIB代理,除非你手动改成false。原因很简单:很多实际项目里的Service Bean不实现接口,JDK代理搞不定;统一用CGLIB能让所有Bean的代理行为可预期,避免“有的代理有的不代理”的混乱。
但这个默认值也带来了一些实际影响。以前用JDK代理时,你面向接口注入,代码里到处是接口类型;改成CGLIB后,你不小心把字段声明成实现类类型,反而能注入成功,因为CGLIB生成的代理类会继承实现类。这会让一些旧代码在切换配置后出现奇奇怪怪的问题。我在实际项目里建议:除非团队已经有明确的代理方式约定,否则不要轻易去改spring.aop.proxy-target-class=false,保持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里的三个Map:singletonObjects存完整单例,earlySingletonObjects存早期引用,singletonFactories存ObjectFactory工厂。Spring创建Bean的流程是:实例化原始对象 -> 属性填充 -> 初始化。在实例化完成之后,Spring会立刻把一个ObjectFactory放进三级缓存。为什么要这么早?为了处理循环依赖。
假设A依赖B,B依赖A。A实例化后还没填属性,B在填充A时发现A正在创建中,于是去三级缓存里拿A的ObjectFactory,调用getObject()。这一步如果A配置了切面,AbstractAutoProxyCreator的getEarlyBeanReference会提前生成代理对象,然后把这个代理对象放到二级缓存,同时删掉三级缓存里的工厂。B拿到的就是A的代理。如果没有AOP,getObject()返回的就是原始对象,同样放进二级缓存。所以三级缓存里存的不是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 List<MethodInterceptor> interceptors; private int current = 0; public MethodInvocation(Object target, Method method, Object[] args, List<MethodInterceptor> 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自己注入进来,用注入的那个代理对象去调用;也可以配置exposeProxy=true后使用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.aop=DEBUG。看到代理创建日志后,再配合切面表达式排查,问题范围就缩小了一半。
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的代理逻辑,你会觉得它亲切得像自己写的一样。