Spring框架的面试中,十有八九会被问到IOC、DI和AOP。但大多数人回答时只知道"控制反转是对象交给容器管""AOP是面向切面编程",再深入问一句"容器到底怎么管Bean的?""AOP的代理是怎么生成的?"就卡壳了。这篇博文我不打算重复教科书式的概念定义,而是从源码机制和实际落地两个层面,把三大特性的来龙去脉、互相之间的协作关系,以及日常开发中最容易踩的坑一次讲透。无论你是准备面试的初级开发,还是已经在项目里用Spring但没空深入研究的老手,这篇内容都能帮你把知识体系补完整。
1. 先搞清楚IOC和DI到底在说什么
很多教程把IOC和DI放在一起讲,导致不少人以为它们是同一个东西的两面。严格来说,IOC是一种设计思想,DI是它的一种实现方式,但这两者的分工差异值得掰开揉碎说清楚。
1.1 控制反转:把new对象的权力交出去
在没有Spring的年代,写Java业务代码是这样的:
public class OrderService { private UserService userService; public OrderService() { // 自己创建依赖对象 this.userService = new UserService(); } }OrderService对UserService的创建和使用完全由自己控制。一旦UserService的构造方法改变,OrderService里面的new也需要跟着改。更麻烦的是,如果UserService自己还有依赖(比如依赖了DataSource),整个对象图的创建逻辑会无限膨胀,代码被new塞满,测试时想替换一个Mock实现更是困难。
IOC的核心思路就是把"创建对象的控制权"从业务代码手里夺过来,交给外部容器。业务类只需要声明自己需要什么,容器负责把对应的实例准备好再送进来。用生活化的例子理解:以前你想吃东西得自己种菜、切菜、做饭,现在你只管坐下点单,厨房(容器)把菜端上来。你的关注点从"怎么做出这盘菜"变成了"这盘菜是否合我胃口"。
所谓"反转",反转的就是这个控制权:从"自己掌控对象生命周期"反转为"容器掌控对象生命周期"。这是IOC最本质的变化,也是理解Spring容器一切行为的基础。
1.2 DI是IOC的具体落地方式
控制反转讲究的是"容器来管理依赖",但容器怎么把依赖给到业务类?答案是依赖注入(DI,Dependency Injection)。在Spring框架中,最常见的注入方式有三种:
- 构造器注入:通过构造方法参数传入依赖,Spring容器在创建Bean时自动匹配参数并传入。这是官方推荐的方式,因为能保证依赖不可变、启动时就能发现缺失依赖。
@Service public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService = userService; } }- Setter注入:通过setter方法传入依赖。适合可选依赖或需要后期重新设置的场景,但可能造成依赖不完全初始化的状态。
@Service public class OrderService { private UserService userService; @Autowired public void setUserService(UserService userService) { this.userService = userService; } }- 字段注入:直接在字段上加@Autowired,代码最简洁,但缺点也明显:外部无法看到依赖关系,单元测试时依赖只能靠反射注入,容易产生隐藏依赖。我建议新代码尽量不用这种,老项目中大多是这个写法,重构时可逐步替换。
DI做的事情概括起来就两步:第一,容器创建Bean实例;第二,在Bean创建过程中把依赖的Bean实例塞进对应位置。IOC给了设计思想,DI给了实现方案,两者配合才构成了Spring IoC容器完整的能力。
1.3 为什么说看源码不如先理解这层关系
我见过不少人一上来就翻Spring源码的AbstractAutowireCapableBeanFactory,结果被几千行的复杂逻辑绕晕,然后彻底放弃。说实话,如果你不理解IOC和DI的关系,源码里的每一个方法都只是面条代码。
源码的阅读顺序应该反过来:先理解容器干了什么——创建对象、解析依赖、执行初始化和销毁回调,再去看代码是怎么分步实现这些事的。Spring的Bean工厂核心逻辑无非就是:根据BeanDefinition反射创建实例,然后进行属性填充。所谓属性填充,翻译成人话就是DI落地。你先有了这个宏观认知,再去看doCreateBean方法里那些步骤,每一步都能对上号。
反过来,有些开发者不理解IOC和DI的区别,写代码时动不动new一个对象然后自己管理生命周期,完全绕开了容器。这时候Spring的AOP、事务管理、懒加载这些能力就统统失效了——容器对对象没有控制权,自然没法切进去做任何增强。理解了这层关系,你就会明白为什么Spring应用里所有业务组件都应该交给容器管理。
2. IOC容器是怎么"管"Bean的
理解了控制权反转之后,下一个值得深挖的问题是:容器内部到底怎么操作Bean的?这涉及到依赖的Bean信息从哪里来、Bean的生命周期分几步、遇到循环依赖时容器如何兜底。
2.1 从BeanDefinition到单例池:Bean的完整生命周期
Spring容器中,每个Bean的"出生"依据不是一个类文件,而是一个BeanDefinition——它抽象描述了Bean的类名、作用域、生命周期回调方法、属性值等信息。你可以把它理解成一份"制造说明书":容器不关心你到底长什么样,只按说明书一步步把你创建出来。
Bean的生命周期大致如下:
- 容器读取配置(XML、注解、JavaConfig),构建BeanDefinition并注册到BeanFactory中。
- 对单例Bean执行实例化前检查,触发InstantiationAwareBeanPostProcessor的postProcessBeforeInstantiation;如果返回了代理对象,则直接跳过后续常规流程。
- 通过构造器反射创建实例。
- 对实例进行属性填充(DI阶段),这一步会递归解析依赖,触发依赖Bean的创建。
- 执行Aware接口回调(如BeanNameAware、BeanFactoryAware)。
- 调用BeanPostProcessor的postProcessBeforeInitialization。
- 如果实现InitializingBean,调用afterPropertiesSet;如果配置了init-method,执行对应方法。
- 调用BeanPostProcessor的postProcessAfterInitialization。AOP核心逻辑就在这一步注入,通过它生成代理对象。
- 单例Bean进入单例池(singletonObjects),等待被使用。
- 容器关闭时,依次执行DisposableBean的destroy方法和destroy-method。
这里面最容易被忽略的是第8步。很多人以为Spring在创建Bean后就直接用原对象,实际上大多数场景拿到的都是经过增强的代理对象。我遇到过几次排查很久的BUG,最后发现是没搞明白这个阶段发生了什么,导致对Bean的本质产生误判。
2.2 注入时机和依赖解析:setter、构造器、字段注入怎么选
初学阶段最常见的疑问是:配置了@Autowired以后,容器什么时候开始注入?答案在下单例Bean创建流程的第4步——实例创建完成但还没初始化前,Spring会扫描当前Bean的所有依赖点。
如果依赖还没创建,Spring会递归调用getBean方法先把依赖Bean创建出来,再填充到当前Bean中。这个递归过程保证了一件事:当Bean初始化时,它依赖的那些Bean都已经完全可用了。这也解释了为什么构造器注入最安全——构造阶段就在强约束依赖可用,而字段注入是在对象构造完才填充,中间有空窗期。
从实际经验看,我的建议是:
- 业务代码中优先用构造器注入。好处是依赖以final字段形式存在,编译期就能发现缺失,代码更利于测试和阅读。
- Spring配置类(@Configuration)中也可用构造器或@Bean方法的参数注入,等价于构造器注入。
- 老项目改造时如果字段注入太多,优先改核心Service层,一条链路改完了再逐步向外推进,避免一次性改动过大导致回归风险。
2.3 三级缓存:为什么它不只是性能问题
"Spring三级缓存"是网络热词,面试必考。三级缓存对应的是singletonObjects(一级)、earlySingletonObjects(二级)、singletonFactories(三级),专门解决单例Bean的循环依赖问题。
假设A依赖B,B依赖A。容器创建A时,发现需要B;创建B时,又发现需要A。如果没有缓存机制,这就是无限递归。三级缓存的思路是:A在实例化完成后、属性填充前,先把一个ObjectFactory放入三级缓存。之后B创建时依赖A,就可以从这个工厂拿到A的早期引用提前注入。
为什么是三级而不是两级?关键在AOP。如果A需要被代理,理论上可以在一级缓存里放原始对象,后面再生成代理替换;但对B来说,它拿到的引用必须是正确的最终对象。三级缓存存储的是工厂(工厂能动态决定返回原始对象还是代理对象),这就是比二级多一层的核心原因。当然,构造器循环依赖、原型Bean循环依赖是没法靠三级缓存解决的,这两种场景Spring会直接抛BeanCurrentlyInCreationException,只能通过调整设计来规避。
我在实际项目中遇到过最常见的循环依赖场景就是定时任务互相回调。建议优先梳理依赖方向,把相互调用的部分抽成独立服务,而不是依赖三级缓存硬撑——虽然能跑,但依赖关系混乱的代码维护成本极高,三级缓存只是兜底方案,不是设计上的合理选择。
3. AOP:面向切面的本质是"代理"
如果说IOC解决的是对象管理,AOP解决的就是横切逻辑复用。日志、事务、权限校验、性能监控这些逻辑如果散落在每个业务方法里,代码会越来越臃肿。AOP把这部分代码从业务逻辑中剥离出来,做成一个可插拔的切面,运行时统一织入。这一切离不开两个关键实现:JDK动态代理和CGLIB动态代理。
3.1 场景切入:日志、事务、权限这些横切逻辑为什么需要代理
拿登录系统举个最直观的例子。不加AOP时,每个Controller方法里都要手动判断当前用户权限:
@PostMapping("/order") public String createOrder(@RequestBody OrderDTO dto) { if (!permissionService.hasPermission("order:create")) { return "无权限访问"; } // 业务逻辑 orderService.create(dto); auditService.log("创建订单", dto); return "成功"; }这样的代码会把大量与业务无关的逻辑写在业务方法里,而且一旦权限规则变化,所有方法都要改。
通过AOP,横切逻辑被挪到切面中:
@Aspect @Component public class PermissionAspect { @Before("@annotation(requiresPermission)") public void checkPermission(JoinPoint joinPoint, RequiresPermission requiresPermission) { if (!permissionService.hasPermission(requiresPermission.value())) { throw new ForbiddenException("无权限访问"); } } }业务方法只保留纯业务逻辑。整个过程中最关键的是:方法调用者拿到的对象不是一个普通实例,而是一个由Spring AOP生成的代理对象——它能在真正执行业务逻辑前,先执行切面逻辑。
AOP的典型应用场景还包括:@Transactional事务管理、日志切面、缓存切面、接口耗时统计等。它们都有一个共同特征:逻辑是与具体业务无关的通用能力,适合横切在方法执行的前后。
3.2 JDK动态代理与CGLIB动态代理的差异
AOP实现的核心是动态代理。Spring AOP基于两种代理机制:
- JDK动态代理:基于接口实现。通过java.lang.reflect.Proxy生成一个实现了目标接口的代理类,调用方法时进入InvocationHandler的invoke方法。
public class JdkProxyFactory { public static Object getProxy(Object target) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), (proxy, method, args) -> { System.out.println("前置增强"); Object result = method.invoke(target, args); System.out.println("后置增强"); return result; }); } }- CGLIB动态代理:基于继承实现。它通过在运行时动态生成目标类的子类,将方法调用分发到切面逻辑中。CGLIB的底层使用了ASM字节码操作框架,由于生成的代理类会拦截方法调用并调用父类方法,因此目标类和方法不能被final修饰。
JDK代理生成速度更快、反射调用开销相对高;CGLIB生成代理类时需要生成字节码,创建速度较慢,但一旦生成,方法调用时是直接调用父类方法的,性能反而更好。两者的核心取舍在于:目标对象是否有接口。有接口时Spring默认用JDK动态代理,没有接口时必须用CGLIB(Spring Boot 2.0以后默认强制用CGLIB,一是有接口也用CGLIB,二是JDK代理无法对没有接口的实现类生效,统一更省心)。
3.3 Spring AOP的切点表达式和通知类型速查
在实际开发中,你不一定需要手写动态代理,因为Spring AOP已经封装好了注解式的切面和通知。关键掌握以下部分:
- 切点表达式(Pointcut):定义"在哪些方法上生效",常见写法有execution表达式,如
execution(public * com.example.service.*.*(..)),也有注解式切点,如@annotation(com.example.annotation.OperationLog)。 - 通知类型(Advice):@Before(前置通知)、@AfterReturning(返回后通知)、@AfterThrowing(异常通知)、@After(最终通知)、@Around(环绕通知)。
@Around是最强大的通知类型,能完全控制目标方法的执行过程,包括是否继续执行、修改返回结果、捕获异常。它也是最容易出错的,因为你需要手动调用joinPoint.proceed(),忘记调用会导致业务方法不执行,而且参数传递错误时排查起来比较费劲。
切面声明示例:
@Aspect @Component public class LogAspect { @Around("execution(* com.example.service.*.*(..))") public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); try { Object result = joinPoint.proceed(); long cost = System.currentTimeMillis() - start; System.out.println("方法执行耗时: " + cost + "ms"); return result; } catch (Throwable t) { System.err.println("方法执行异常: " + joinPoint.getSignature().getName()); throw t; } } }这里要注意:Spring AOP默认只拦截Spring容器管理的Bean的public方法,private方法、protected方法虽然表达式可以匹配到,但不会被代理拦截。这个坑我后面还会细说。
4. 代理创建背后的核心流程与常见坑
很多人在实际用AOP时遇到"切面不生效"的问题,然后怀疑是自己的切点表达式写错了。其实很多时候问题出在对Spring AOP的创建机制理解不到位上。这一节我会把代理创建的完整流程、以及最常见的三个坑梳理清楚。
4.1 Spring AOP的BeanPostProcessor机制
回想第一节提到的Bean生命周期第8步:postProcessAfterInitialization。Spring AOP在这里通过AbstractAutoProxyCreator判断当前Bean是否匹配切面规则。如果匹配,就会创建代理对象返回,替换掉容器中原本的单例对象。
整个过程中,Spring不会改动业务类本身,而是生成一个新的代理实例,这个代理实例与目标对象实现相同接口或继承目标类,然后以"最终Bean"的身份被缓存和注入。所以你会发现:通过Spring拿到的OrderService,打印Class时往往不是原始类,而是一个带有$$EnhancerBySpringCGLIB$$或jdk.proxy后缀的类。
这张机制图可以手画理解一下:容器创建Bean -> 调用BeanPostProcessor后置处理器 -> 判断是否匹配切面 -> 匹配则生成代理 -> 返回代理作为最终Bean。
理解了这一点,很多AOP失效的问题都能定位到原因:你拿到的根本不是Spring容器管理的那个Bean,或者这个Bean没有被代理,自然没有任何切面效果。
4.2 自调用失效问题的完整排查链路
先说结论:同一个类的内部方法调用时,this指向的是目标对象,不是代理对象,所以通过this调用的方法是不会经过拦截的。
看个经典案例:
@Service public class UserService { @Transactional public void createUser(User user) { // 插入用户 insert(user); // 给用户发欢迎消息 sendWelcomeMessage(user); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void sendWelcomeMessage(User user) { // 发送消息的独立事务 } }看起来两个方法都有事务,但实际运行后,你发现sendWelcomeMessage的事务配置不生效。原因是createUser方法内直接通过this.sendWelcomeMessage调用,这个this是原始UserService对象,没有被代理,所以第二个方法上的@Transactional根本不会经过拦截器。
排查这个问题的完整链路是:
- 在UserService的构造方法中输出当前对象类型,确认是通过代理还是原始对象调用。
- 检查调用方式:是
this.sendWelcomeMessage还是从Spring容器中重新获取Bean。 - 确认类被Spring管理,并且AOP有效(比如在方法内输出日志)。
- 将内部调用替换为从ApplicationContext获取代理对象,或者拆分为两个独立的Bean。
常见的修法有三种:
- 拆分为两个独立的Service,互相通过注入调用,保证调用方拿到的都是代理对象。
- 注入自身代理:
@Autowired private UserService userService;,然后通过userService.sendWelcomeMessage()调用(Spring 4.3后支持字段注入自身,但配置复杂,不建议大量使用)。 - 使用AopContext.currentProxy()获取当前代理对象,要求配置
@EnableAspectJAutoProxy(exposeProxy = true)。
实际项目里,我建议用第一种,拆分职责还能让代码更清晰,硬绕代理只会在后续维护里埋更多坑。
4.3 同类内部方法调用为什么走不到代理
这个问题出现频率太高,单独拿出来说。Java的this调用是本类内部的方法调用,不会经过任何代理。你可以在被调方法里加一行日志,如果日志直接打印而切面没有拦截,基本就是这个原因。
还有一种情况是方法被final修饰。CGLIB生成子类覆盖方法时,final方法无法被覆盖,Spring只能调用父类的原始方法,切面自然失效。JDK代理同样存在这个限制:final方法、static方法不会被代理拦截。
我遇到过因为Mapper接口方法被误加到切面表达式而排查半天的场景。MyBatis的Mapper本身就是一个接口动态代理,如果你在切面表达式中对Mapper的方法做拦截,由于Mapper代理对象本身就是JDK代理生成的,再叠加Spring AOP代理,调用链路复杂后很容易出现ClassCastException或返回值异常。这类问题归纳起来就是一句话:搞清楚你处理的对象是该代理、该Bean还是该特殊代理链上的对象。
5. 三大特性的实际协作:一个事务切面的完整链路
很多人以为IOC、DI和AOP是三条独立的知识线,但它们在实际运行时是深度协作的。我拿@Transactional这个最常用的注解做一个串联,你会发现三大特性在这里被完整地链接了起来。
5.1 从@Transactional看AOP、IOC如何联动
Spring事务功能通过@EnableTransactionManagement开启。开启后,容器中会注册一个BeanFactoryTransactionAttributeSourceAdvisor,它的职责是解析@Transactional注解上的事务属性,判断一个Bean是否需要创建代理。
Bean创建过程中的协作流程:
- IOC容器根据配置创建目标Bean实例(比如OrderService)。
- 属性填充(DI)完成后,进入BeanPostProcessor处理阶段。
- TransactionInterceptor所在的Advisor开始判断:目标Bean的类或方法上是否标注了@Transactional。
- 如果匹配,Spring利用JDK动态代理或CGLIB生成代理对象。
- 代理对象中的事务方法调用会进入TransactionInterceptor的invoke方法,该方法从IOC容器中取出DataSource TransactionManager,开启或挂起事务。
- 目标方法正常返回时提交事务,抛出异常时回滚事务。
从这条链路看出:IOC提供了Bean和依赖管理,AOP为Bean增加了代理能力,DI让事务管理器和切面都能从容器中拿到依赖。没有IOC,AOP无从切入;没有DI,切面对象所需的依赖无法注入;没有AOP,@Transactional不知道何时生效。
5.2 与Spring Boot、Spring MVC项目的结合点
在Spring Boot项目中,这些特性开箱即用。Spring Boot的自动配置会默认创建事务管理器(如果classpath中存在对应的DataSource),默认开启CGLIB代理,并且允许通过spring.aop.proxy-target-class属性切换代理方式。
和Spring MVC结合时,AOP最常用于日志记录、接口鉴权、异常统一处理。Spring MVC中的@ControllerAdvice风格处理和AOP其实是两个层面:@ControllerAdvice是Spring MVC自身的异常处理机制,AOP是更通用的切面机制。实际项目中常将两者结合:用AOP拦截Service层的业务操作,用@ControllerAdvice统一兜底Controller层的异常。
一个典型的场景是操作日志:通过AOP在Service方法上记录用户操作(拿到方法参数、执行结果、耗时),再把日志通过异步队列写入数据库。这个场景里IOC负责注入日志Service,AOP负责横向切入,DI负责把日志Service注入切面类。
可以在自定义注解上配合AOP实现操作日志:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OperationLog {}切面感知注解后,从JoinPoint中拿到方法入参、操作方法,再调用日志服务落库。这个方法在各类企业后台中非常常见。
6. 面试与实战中最值得记住的经验
这一节不讲新概念,纯粹分享我在学习Spring和业务落地过程中积累的经验,也是面试里最常被追问的几个细节。
6.1 几个必考概念题怎么答不虚
"IOC和DI有什么区别?"不要只说"控制反转是思想,依赖注入是方案",可以补充:控制反转针对的是对象生命周期的控制权,依赖注入针对的是依赖关系的描述和装配方式。Spring通过DI实现了IOC理念,两者是设计目标和实现手段的关系。
"为什么需要用代理?"直接回答:代理用于在不修改业务代码的情况下,为对象方法调用增加横切逻辑。Spring AOP通过代理完成事务管理、权限校验等功能,本质是装饰器模式在运行时层面的扩展。
"JDK代理和CGLIB代理在Spring中如何选择?"可以这样答:默认情况下如果目标Bean实现了接口,优先使用JDK动态代理;如果未实现接口则使用CGLIB。但Spring Boot 2.0+默认强制CGLIB,原因是一来接口代理无法覆盖非接口方法,二来统一策略减少混乱。当JDK代理遇到接口方法新增场景,所有代理对象无需重新生成,只有接口方法会被拦截,非接口方法即使加了注解也不会生效;CGLIB代理则可以覆盖大多数普通方法。
"Spring AOP和AspectJ有什么不同?"Spring AOP是运行时代理方式,基于代理对象实现,只能拦截Spring容器管理的Bean的public方法;AspectJ是编译期/加载期织入,能力强但使用复杂。大多数业务场景Spring AOP已经足够,需要拦截构造器、static方法等时才考虑AspectJ。
6.2 学习路径建议:从使用到源码的递进路线
结合我的经验,建议学习Spring三大特性的路径采用三层递进:
第一层是会用。理解注解的语法和常用功能,能用@Autowired注入、能写切面拦截指定方法。
第二层是能追溯。通过IDE的debug,观察Bean创建的完整生命周期,亲眼看到BeanPostProcessor介入的时机,理解代理对象的生成过程。这一层最推荐的做法是通过断点调试一个最简单的Spring Boot项目,打断点分别在Constructor、@Autowired、BeanPostProcessor、代理创建等位置,逐个查看调用栈。
第三层是能手写。尝试实现一个基于JDK代理的极小IOC容器和AOP框架,不需要完整功能,只需实现:扫描注解标注的Bean、根据注解解析依赖并注入、通过Proxy动态生成代理对象、让切面生效。这个"手写Spring"的练习能让你彻底打通三大特性。
经常有朋友问我是不是一定非要读那些源码解析书。我自己的经验是:源码阅读一定要带着问题去读,比如"Bean属性填充发生在哪一步""为什么postProcessAfterInitialization能改变最终返回的对象"。如果只是通读全文,很难记住。我强烈建议每个读者都动手写一个几十行的迷你IOC,远比背十遍概念有效。
最后再分享一个小技巧。排查Spring AOP相关问题的时候,最快的方法是先在启动类里加一行代码:`ApplicationContext ctx = SpringApplication.run(...)",然后找一个被代理的Bean打印它的class。这一行输出比任何日志配置都直观——如果类名不对,代理一定有问题;如果切面该生效但方法没被拦截,优先检查调用方式是否经过了代理。这个习惯我保持了很多年,排查效率特别高。三大特性看起来是概念题,落地时全是对细节的把握,希望你也能通过调试建立起属于自己的感知体系。