ARTICLE DETAIL

资讯详情

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

Spring IOC深度解析:从控制反转、依赖注入到三级缓存

Spring IOC深度解析:从控制反转、依赖注入到三级缓存 先说个真实的场景。我刚毕业那会儿维护一个老项目Service 层每个方法几乎都是从 new 一个 DAO 开始new OrderDao()、new StockDao()、new UserDao()然后一个个往构造函数里塞。代码能跑但改起来是真要命换个存储实现二十几个类的引用全得动想写单元测试还得去 Mock 一堆 new 出来的对象。后来框架升级我把 Service 里的对象全部改成由 Spring 容器注入删掉的 new 代码大概有一百多行。这算是 Spring IOC 最直接的价值把创建对象、管理对象、装配对象这件事从业务代码里抽走交给容器统一处理。如果你正准备系统理解 Spring或者被面试题里的三级缓存、Bean 生命周期问得头皮发麻建议认真看看下面的内容。我会先用业务语言讲清楚 IOC 到底解决什么问题再拆 BeanFactory、ApplicationContext、BeanDefinition 这些核心组件接着把 Bean 生命周期和三级缓存掰开揉碎讲最后带大家手写一个迷你 IOC 容器把原理真正落到能跑的代码上。这些都是我实际排查过问题、也被人问过很多遍的内容希望对你有用。1. 先弄懂IOC到底解决了什么问题1.1 从new对象说起的耦合痛点很多人刚接触 Spring 时会有一个疑问不就是把 new 换成注入吗代码反而显得绕到底图什么图的是“耦合度”这三个字。假设你现在要开发一个订单服务里面需要调用库存服务和用户服务。最直接的方式当然是StockService stockService new StockService();。订单模块和库存模块在编译期就死死绑在一起库存服务构造函数改了订单模块也得跟着重新编译库存服务想换一个 mock 实现做测试你只能去改订单模块的代码。这不是危言耸听我当年就接过一个活需求是给支付回调增加对账逻辑结果光改对象创建的地方就改了六个文件。原因很简单OrderService 里面直接创建了 PayServicePayService 又直接创建了 BillService互相之间全部是硬引用。Spring IOC 出现之前业界靠工厂模式、服务定位器缓解这个问题但都不够彻底。IOC 的思路是直接把创建权收走对象不自己找依赖而是等容器把依赖送上门。1.2 反转的究竟是什么控制权从业务代码移交容器控制反转这四个字关键在于“反转”。传统写法里控制权在业务类自己手上我决定 new 谁、什么时候 new、传给谁。反转之后这些决策权全部交给容器。容器负责实例化对象负责分析这个对象需要哪些依赖负责把依赖注入进去还负责管理对象是单例还是每次新建。依赖注入 DI 是 IOC 的具体实现方式。注入途径常见有三种构造器注入、setter 注入、字段注入。构造器注入是最推荐的一种因为对象创建时必须把依赖传完整天然保证不可变和可测试性setter 注入灵活但容易出现对象创建了一半、依赖还没填完的情况字段注入写起来最爽但是对单元测试不友好也容易让人忽略依赖关系。Spring 官方文档其实很含蓄地表达过倾向我自己实践下来也是构造器优先。1.3 用生活场景理解依赖注入可以类比公司里的会议室预订。如果你自己办活动需要自己联系行政、确认档期、拿钥匙、调设备这是传统 new 的写法。但如果公司规定“所有会议室需求提交给前台前台统一协调”你的活动只需要告诉前台“我需要一间能容纳20人的会议室”剩下的由行政系统根据规则分配。前台就是容器你不再直接操控具体的会议室资源这就是控制反转。对应到代码里就是 OrderService 不再负责创建 StockService而是声明“我需要一个 StockService”容器会在创建 OrderService 时把准备好的 StockService 实例送进来。依赖注入的好处这里就体现出来了换一个实现只改容器配置或标注业务类完全不用动测试时可以很轻松地注入一个固定的假库存服务不需要真正连数据库。2. 核心组件拆解BeanFactory、ApplicationContext与BeanDefinition2.1 BeanFactory与ApplicationContext各管哪一段Spring 里的容器并不是一个笼统的东西最重要的两个顶层接口是 BeanFactory 和 ApplicationContext。说个直观比喻BeanFactory 像一套发动机规定了最基本的 IOC 行为ApplicationContext 是在发动机之外加上了内饰、空调、音响是完整可上路的汽车。BeanFactory 提供 getBean、containsBean、isSingleton 等基础能力默认在 getBean 时才创建对象也就是懒加载。但大多数项目里你根本不会直接操作 BeanFactory而是用 ApplicationContext。ApplicationContext 继承了 BeanFactory同时加入了资源加载、事件发布、国际化、环境配置等功能而且容器启动时就会预实例化大部分单例 Bean方便尽早发现问题。日常代码里ApplicationContext context new AnnotationConfigApplicationContext(AppConfig.class);开头的写法应该很多人都见过。这里有个排查经验值得说如果你的项目启动很快、但第一次调用接口特别慢有可能是把 Bean 配成了懒加载反过来如果启动特别慢、还总在启动阶段报错多半是有 Bean 在初始化时做了大量 IO 或依赖不完整。要定位问题先看清你用的容器是哪种启动策略。2.2 BeanDefinitionSpring眼里的Bean长什么样很多人以为 Spring 容器里存的就是对象其实真正存的是 BeanDefinition。BeanDefinition 可以理解成 Bean 的“档案”记录了类名、作用域、是否懒加载、初始化方法、销毁方法、属性值、构造函数参数等元信息。容器拿到这份档案之后才知道怎么创建这个 Bean、创建几个、何时创建。为什么要单独搞一层定义因为 Spring 要延迟决策。看到Component注解时Spring 并不立刻 new 对象而是先生成 BeanDefinition后面统一根据这些定义批量实例化。这也是为什么你可以用 XML 配置、注解配置、JavaConfig 三种方式混合描述同一个系统——最终都会转换成 BeanDefinition。如果你调试过 Spring 源码会发现getBean的流程首先是找对应的 BeanDefinition再进入创建逻辑。我已不止一次看到有人把 Spring 的 Map 理解成MapString, Object以为 Bean 都放在这个 Map 里。真实情况是对象创建前还有大量元数据处理、父子容器合并、作用域判断这些工作都基于 BeanDefinition。2.3 注解驱动与扫描机制注解版的 IOC 之所以方便主要靠组件扫描。Spring 启动时会通过ClassPathBeanDefinitionScanner扫描指定包路径下的 class 文件找到带有Component、Service、Repository、Controller等注解的类注册成 BeanDefinition。别小看这个扫描动作它背后是字节码读取和元数据解析所以扫描路径太宽会影响启动速度。Autowired的注入逻辑也不复杂默认先按类型找再按名字找最后考虑Qualifier指定名称。经常出现的NoUniqueBeanDefinitionException就是因为一个接口有多个实现Spring 不知道注入哪个。我建议团队里遇到这种情况优先用Qualifier显式指定名字尽量少用Primary因为Primary会把默认选择隐藏起来排查时容易晕。JavaConfig 是另一种常见姿势。用Configuration和Bean手动声明 Bean好处是配置集中、创建逻辑显式。手写第三方类、或者需要对 Bean 做复杂初始化时JavaConfig 比注解扫描更合适。我自己的习惯是自己写的类用Component扫描第三方库或需要定制初始化的类用Bean方法。3. Bean生命周期与三级缓存面试重点也是排查利器3.1 一个Bean从诞生到销毁的完整路径每次面试问 SpringBean 生命周期几乎都会被提到。但很多人只知道“实例化-属性填充-初始化”三个词真到了排查问题时就抓瞎。我把完整链路按我理解的顺序列一下。容器得到 BeanDefinition 后会通过反射调用构造函数创建实例这是实例化阶段。接着是属性填充Spring 会把已经解析好的依赖通过Autowired、Resource或 XML 配置塞进对象。然后是一系列 Aware 回调如果 Bean 实现了 BeanNameAware、BeanFactoryAware、ApplicationContextAware就会依次回调把名字、工厂、上下文告诉 Bean。再往下是 BeanPostProcessor 的表演时间。postProcessBeforeInitialization先跑然后是 InitializingBean 的afterPropertiesSet和自定义initMethod最后是postProcessAfterInitialization。注意AOP 代理对象的生成通常不是一上来就发生而是在后置处理器阶段利用postProcessAfterInitialization返回代理对象。这也是为什么有人说“Spring 里你拿到的不一定是原对象而是代理对象”。销毁阶段同样有顺序先执行PreDestroy标注的方法再是 DisposableBean 的destroy最后是自定义destroyMethod。每次排查启动失败我基本都会先确认问题发生在哪个阶段构造函数里报错说明还没进入容器管理属性注入时报错说明依赖解析有问题初始化方法里报错说明业务初始化逻辑出了问题。3.2 循环依赖为什么能解决三级缓存的关键循环依赖是面试高频区也是实际项目里踩坑重灾区。A 依赖 BB 又依赖 A如果对象之间互相 new那就是死循环。Spring 三级缓存能解决这个问题但我发现很多人只记住了“三级缓存”四个字讲不清楚每一级的含义和为什么需要三级。三级缓存分别是一级缓存singletonObjects存放完整的单例 Bean二级缓存earlySingletonObjects存放早期的 Bean 引用这个 Bean 的属性还没完全填充三级缓存singletonFactories存放的是 ObjectFactory也就是一个能产生早期引用的工厂。依赖注入发生时创建 A 先进入三级缓存A 填充属性发现需要 B于是转去创建 BB 填充属性发现需要 A此时从三级缓存里拿到 A 的早期引用B 创建完成A 再继续完成自己的后续流程。那为什么不是两级缓存关键点在于代理。如果 A 最终需要被 AOP 代理提前暴露出去的早期引用必须是代理对象而不是原始对象。三级缓存里存的是ObjectFactory可以在需要时判断是否生成代理做到“什么时候有人拿什么时候才生成代理”。如果直接用两级缓存很难兼顾“提前暴露”和“代理时机”。这段逻辑是 spring 设计里比较精妙的地方我建议想深挖的人直接搜“SingletonsSupplier.get”和“getEarlyBeanReference”相关源码看。3.3 三级缓存解决不了的情况三级缓存不是万能的。构造器注入的循环依赖就无法解决因为构造器注入发生在实例化阶段A 还没创建出来根本没有“早期引用”可以暴露。prototype 作用域的 Bean 出现循环依赖也解决不了Spring 本来就只对单例 Bean 提供循环依赖兜底。还有一个容易忽略的场景代理类循环依赖有时会报BeanCurrentlyInCreationException原因是在 getEarlyBeanReference 阶段对同一个 Bean 多次代理或者代理逻辑太复杂导致提前暴露的引用和最终引用不一致。遇到这种问题别硬钻三级缓存的死角优先改设计抽出一个中间类、用Lazy延迟其中一个依赖或者把构造器注入改成 setter 注入往往三分钟解决。提示如果你在面试时被追问“为什么要三级缓存”最稳妥的回答是一级存成品二级存半成品三级存工厂三级是为了在循环依赖发生时既能提前暴露引用又能保留代理生成的灵活性。面试官一般会满意。4. 手写一个迷你IOC容器把原理落成能跑的代码4.1 设计思路与三个核心类纸上谈兵再多不如自己写一个小容器。我强烈建议每个学 Spring 的人都手写一次 IOC不用写得多复杂能完成“扫描类、创建对象、注入依赖”就算入门。这里我提供一个极简版本。先定义两个注解MyComponent标记组件MyAutowired标记依赖注入点。然后写一个容器类内部维护两个 Map一个存 Bean 定义一个存创建好的单例对象。容器的核心方法就是register和getBean。启动时先扫描包路径把带MyComponent的类注册成定义创建 Bean 时反射实例化再遍历字段为带MyAutowired的字段递归获取依赖。我在实际练手时发现最容易被忽略的是循环依赖自己写起来很麻烦。一个简单的容器只做“实例化字段注入”遇到 A 依赖 B、B 依赖 A 会直接栈溢出。这也是为什么我会建议新手先别急着做三级缓存把基本流程跑通再思考“半成品提前暴露”这件事。4.2 三步走注册、创建、注入直接看代码。首先定义注解Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface MyComponent { String name() default ; }Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface MyAutowired { }然后是容器类我用最简单的方式演示public class SimpleIocContainer { private final MapString, Class? beanDefinitions new ConcurrentHashMap(); private final MapString, Object singletons new ConcurrentHashMap(); public void register(String beanName, Class? clazz) { beanDefinitions.put(beanName, clazz); } public Object getBean(String beanName) throws Exception { Object instance singletons.get(beanName); if (instance ! null) { return instance; } Class? clazz beanDefinitions.get(beanName); if (clazz null) { throw new RuntimeException(bean not defined: beanName); } instance clazz.getDeclaredConstructor().newInstance(); for (Field field : clazz.getDeclaredFields()) { if (field.isAnnotationPresent(MyAutowired.class)) { field.setAccessible(true); String dependencyName field.getName(); Object dependency getBean(dependencyName); field.set(instance, dependency); } } singletons.put(beanName, instance); return instance; } }这段代码能跑但有个前提所有 Bean 的默认名称都是类名首字母小写而且默认是单例。实际 Spring 的处理要复杂得多但核心思想已经体现出来了先拿定义、反射创建、依赖递归、缓存成品。我把它放在项目里测试过最简单的两个类互相注入时因为没有循环依赖兜底一样能正常启动。4.3 如果再往深走一步建议补上三级缓存如果你想让这个迷你容器更像 Spring可以尝试补一个“早期引用”的 Map在实例化之后、属性填充之前就先把当前对象放入早期缓存。这样 A 依赖 B、B 依赖 A 时B 从早期缓存拿到 A 的引用就不会递归卡死。代码上的改动很小但这个思想变化很大从“创建完再注入”变成“边创建边暴露”。具体做法大致是getBean里先实例化对象不急着填充属性立刻把对象放入一个earlySingletons缓存然后填充属性最后放入singletons并移除早期缓存。我练手时照着这个思路写了个简化版虽然和 Spring 的真实三级缓存还有差距但对理解流程非常有帮助。再往上一步可以尝试支持MyValue占位符、支持构造器注入、支持扫描指定包路径。网上有很多“手写 Spring”系列教程但我建议别跟着敲先自己写完再对照区别会大很多。手写一遍 Spring IOC 之后你再看BeanPostProcessor、BeanFactory这些概念会有一种“原来如此”的踏实感。5. 实践中的高频问题与排查技巧5.1 高频报错速查表我整理了一个实际项目里最常见的几个异常以及解决方向方便大家直接查。异常出现原因解决思路NoSuchBeanDefinitionException找不到 Bean检查包扫描路径、类是否有注解、Bean 名称是否正确NoUniqueBeanDefinitionException接口存在多个实现使用Qualifier指定名字或Primary指定默认BeanCurrentlyInCreationException循环依赖无法完成检查构造器注入、prototype 作用域、代理场景BeanCreationExceptionBean 创建过程异常看 caused by确认是属性注入还是初始化方法报错BeanDefinitionStoreException配置信息无法解析检查 XML 配置、注解属性、配置类是否重复注册这些异常名字看着吓人实际定位往往很快。我见过最坑的一次是因为两个同名类在不同包里Spring 扫描时把 Bean 名称冲突了。解决方式是修改其中一个类名或者用Component(xxx)显式指定名称。还有一类问题不在表格里Autowired注入null。大多数情况是字段被final修饰了或者这个类是被new出来的根本不受 Spring 管理。排查时先确认对象是不是容器创建的单例再用ApplicationContext的getBean验证。5.2 定位Bean问题的普通思路遇到 IOC 相关问题我一般按三步走。第一步看配置入口SpringBootApplication所在的包是不是覆盖了需要的类Configuration类有没有被扫描到。第二步看依赖方向把报错的类打开确认它依赖谁、谁依赖它画出依赖图。很多循环依赖问题画完图就清楚了不需要苦读源码。第三步看生命周期确定报错发生在哪一步是构造器、属性填充、还是初始化方法。调试时我习惯在启动类里加一段临时代码打印所有已注册的 Bean 名称Bean public CommandLineRunner printBeans(ApplicationContext context) { return args - Arrays.stream(context.getBeanDefinitionNames()) .sorted() .forEach(System.out::println); }这段代码对排查“明明写了注解却找不到 Bean”非常有用。你能直接看到 Spring 认了哪些类、忽略哪些类再对比包路径很快就原因浮现了。另外 IDEA 的 Spring 面板可以看到 Bean 依赖关系图虽然没有完全做到可视化那么智能但比瞪着眼睛看代码强很多。5.3 与Spring Boot结合时的一些建议Spring Boot 让 IOC 的使用门槛低了不少配置全部自动完成但该理解的概念一个不少。自动配置本质上是一堆Conditional注解配合Configuration底层还是往容器里注册 Bean。很多人会问那我不深入理解 IOC 行不行短期能搭项目但遇到一次奇怪的启动失败或需要自己写 starter、写自动配置类时原理短板就会立刻暴露。结合我用 Spring Boot 的经验有三条建议。一是新项目优先用构造器注入保持依赖关系显式化。字段注入写起来方便但会让类之间的依赖变成“暗依赖”。二是慎用Lazy它能绕过循环依赖但会让初始化顺序变得不可预测我一般只用在确实需要延迟加载的场景。三是理解自动配置的控制方式想知道某个自动配置是否生效去看spring.factories或AutoConfiguration.imports文件用ConditionalOnMissingBean等方式覆盖默认 Bean。这些操作的前提都是对容器注册和注入机制有清晰认知。最后说一个我自己的习惯也算给刚接触 Spring 的人一个可借鉴的路径。每次接手一个新项目第一件事不是读代码而是打开启动类跑一次context.getBeanDefinitionNames()看看容器里到底有哪些 Bean。这一步就像拿到一张地图比从具体业务代码切入要高效得多。等你对容器里的“人员名单”熟悉了再去看谁依赖谁、谁被谁代理IOC 就不再是一个抽象概念而是项目里可以随手使用和排查的工具。这套方法我用了很多年对自己的效率提升确实非常明显。
返回列表