ARTICLE DETAIL

资讯详情

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

Spring核心三剑客:IOC、DI与AOP的源码机制与实战避坑

Spring核心三剑客:IOC、DI与AOP的源码机制与实战避坑 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的具体落地方式控制反转讲究的是容器来管理依赖但容器怎么把依赖给到业务类答案是依赖注入DIDependency 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依赖BB依赖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动态代理没有接口时必须用CGLIBSpring Boot 2.0以后默认强制用CGLIB一是有接口也用CGLIB二是JDK代理无法对没有接口的实现类生效统一更省心。3.3 Spring AOP的切点表达式和通知类型速查在实际开发中你不一定需要手写动态代理因为Spring AOP已经封装好了注解式的切面和通知。关键掌握以下部分切点表达式Pointcut定义在哪些方法上生效常见写法有execution表达式如execution(public * com.example.service.*.*(..))也有注解式切点如annotation(com.example.annotation.OperationLog)。通知类型AdviceBefore前置通知、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让事务管理器和切面都能从容器中拿到依赖。没有IOCAOP无从切入没有DI切面对象所需的依赖无法注入没有AOPTransactional不知道何时生效。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负责注入日志ServiceAOP负责横向切入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。这一行输出比任何日志配置都直观——如果类名不对代理一定有问题如果切面该生效但方法没被拦截优先检查调用方式是否经过了代理。这个习惯我保持了很多年排查效率特别高。三大特性看起来是概念题落地时全是对细节的把握希望你也能通过调试建立起属于自己的感知体系。
返回列表