尧图精选

Spring IoC/DI深度解析:三级缓存与Bean生命周期

🕒 发布时间:2026/10/2 4:18:33 📁 来源:尧图网络
说实话我刚用 Spring 那两年对 IoC 的理解就停留在“不用自己 new 对象”这个层面。面试官问 Spring 怎么解决循环依赖我还能背出“三级缓存”四个字可再追问一句“为什么是三级不是两级”整个人就卡住了。后来我专门花了两周把 Spring 容器相关源码从头读到尾又动手写了一个极简 IoC 容器才敢说自己真正理清了 Spring IoCDI 这条链路。这篇文章就是把我摸过的这些底拿出来分享。我会从 IoC 和 DI 这两个概念的本源讲起一路拆到 Bean 生命周期、三级缓存、配置演进、手写容器的简化实现以及日常开发里最常见的注入问题。内容不算短但每段都有实际场景和代码支撑适合刚学 Spring 想建立完整认知的读者也适合准备面试前做最后梳理的人。1. 先想明白IoC 解决的是“谁来创建对象”很多人第一次接触 Spring IoC听到的就是“控制反转把对象的创建交给容器”。字面上能听懂但代码里仍然只是惯性写Autowired。我建议先把“传统 new 的做法有什么问题”想清楚IoC 和 DI 才不再是抽象概念。1.1 从 new 到容器一段代码的演进过程假设有一个下单功能需要调用用户服务public class OrderService { private UserService userService new UserService(); }这段代码看起来人畜无害但问题藏在细节里当UserService的构造方法需要传入数据库连接池、消息队列客户端等参数时OrderService就必须知道完整构建过程。更麻烦的是如果UserService换成接口的另一个实现所有写了new UserService()的地方都要改一遍。单元测试就更难受了想 mock 一个假用户服务得想办法把 new 出来的对象替换掉代码会变得非常别扭。IoC 的思路是把“创建依赖”这件事从调用方手里拿走。所有的对象统一交给一个容器管理调用方只需要声明“我需要一个 UserService”由容器负责创建、组装、赋值。依赖关系的所有权从调用方转移到容器这就是“控制反转”最直接的体现。IoC 是一个很宽泛的设计原则而 DIDependency Injection依赖注入是 Spring 落地这个原则的具体手段。所以准确说IoC 是思想DI 是做法两者不是同一个层面的东西。这也是面试里常被问到的第一道辨析题。1.2 依赖注入的三种方式怎么选Spring 支持三种注入方式构造器注入、setter 注入、字段注入。我实际写代码时强烈推荐构造器注入但不代表其他方式没有存在价值。用一个表格对比会更直观注入方式代码写法优点缺点推荐度构造器注入构造方法参数依赖不可变对象创建即完整测试友好不容易产生循环依赖构造函数参数多时略显臃肿最推荐setter 注入属性 setter 方法可选依赖可以后续按需设置便于配置框架使用对象创建后仍可能处于不完整状态按需使用字段注入Autowired直接标字段写起来最简洁隐蔽依赖外部不可见测试 mock 困难容易掩盖循环依赖不推荐新代码使用以一个用户注册服务为例构造器注入写出来是这样的Service public class UserRegisterService { private final UserMapper userMapper; private final MailService mailService; public UserRegisterService(UserMapper userMapper, MailService mailService) { this.userMapper userMapper; this.mailService mailService; } }配合 Lombok 的RequiredArgsConstructor还可以更简练。字段注入看起来省事但当你把对象 new 出来写单元测试时会发现依赖完全没法替换只能靠 Spring 的ReflectionTestUtils硬塞测试体验非常差。1.3 看看依赖倒置原则DI 能生效底层离不开面向接口编程。OrderService不依赖UserServiceImpl这个具体类而依赖UserService接口容器才好把实现类注入进去。这也是 SOLID 原则里的 D——依赖倒置原则。日常开发中如果接口只有一个实现很多人嫌麻烦直接注入具体类短期不致命但一旦出现第二个实现改动成本就全回来了。2. 一个 Bean 是怎么在容器里诞生的理解 IoC只停在“容器帮我 new 对象”是不够的。Spring 容器里每一个 Bean 的诞生都要经历一条相当完整的流水线。我把这条流水线拆开你会发现很多之前觉得“玄学”的问题其实都可以用流程来解释。2.1 BeanDefinition容器的“配方”而不是成品容器不是只拿 Class 反射创建对象它还要知道这个 Bean 是单例还是原型是否懒加载构造器参数是什么依赖哪些其他 Bean初始化方法和销毁方法叫什么。这一堆元信息被封装成BeanDefinition——注意BeanDefinition 是“配方”不是“菜”。容器启动时会把配置里的每个bean、Bean、Component解析成一份份 BeanDefinition注册到BeanDefinitionRegistry。这也是很多人在第一个 Spring Boot 程序里感到神奇的地方我只写了一个SpringBootApplication什么都没配置为什么DataSource、RedisTemplate都能注入因为 Spring Boot 的自动配置在启动阶段往容器里注册了大量 BeanDefinition当你需要时才真正实例化。2.2 ApplicationContext 到底比 BeanFactory 强在哪很多人分不清BeanFactory和ApplicationContext的区别。简单说BeanFactory是 Spring 容器的底层最小接口提供最基础的 getBean、getType、containsBean 能力长按需实例化的原则直到调用 getBean 才创建对象。ApplicationContext是它的增强版加了事件发布、国际化、资源加载、环境配置管理还会在启动时就预先实例化所有非懒加载的单例 Bean常用的类路径扫描和注解解析也都是 ApplicationContext 家族提供的。实际项目里你基本只会接触ApplicationContext但面试时被问到底层别把两者讲混。比如AnnotationConfigApplicationContext就是常见的一个实现它既可以单独在普通 Java 项目里用也是 Spring Boot 内部容器的基础。2.3 使用 Spring IoC 容器获取 Bean 信息的正确姿势日常开发中“从容器里手动拿 Bean”的需求不算多但写工具类、写测试、做框架扩展时经常遇到。最简单的方式是直接注入ApplicationContext但更通用的是让工具类感知容器Component public class SpringUtils implements ApplicationContextAware { private static ApplicationContext applicationContext; Override public void setApplicationContext(ApplicationContext applicationContext) { SpringUtils.applicationContext applicationContext; } public static T T getBean(ClassT clazz) { return applicationContext.getBean(clazz); } public static Object getBean(String beanName) { return applicationContext.getBean(beanName); } public static String[] getBeanNames() { return applicationContext.getBeanDefinitionNames(); } }getBean有很多重载按类型、按名称、按名称加类型都可以。若想调试容器里到底注册了哪些 Bean直接调用getBeanDefinitionNames()打印一遍效果立竿见影。我调试启动异常时就经常用这个方法确认某个类是否真的被扫描到容器里。2.4 Bean 生命周期全景图拉长时间线看一个普通单例 Bean 从出生到销毁核心步骤大概是解析 BeanDefinition注册进容器。通过构造器反射实例化对象。执行属性填充Autowired、Resource、Value在这里生效。回调 Aware 系列接口比如BeanNameAware、BeanFactoryAware、ApplicationContextAware。执行BeanPostProcessor#postProcessBeforeInitialization。执行初始化回调PostConstruct、InitializingBean#afterPropertiesSet、自定义 init-method。执行BeanPostProcessor#postProcessAfterInitializationAOP 动态代理就是在这个阶段生成的。Bean 进入就绪状态供业务代码使用。容器关闭时执行销毁逻辑PreDestroy、DisposableBean#destroy、自定义 destroy-method。这个流程看起来繁琐但每一步都留了“钩子”。比如你想在 Bean 初始化前后统一做参数校验、日志埋点或生成代理就能通过BeanPostProcessor介入。理解生命周期之后再看 Spring 的 AOP、事务、MyBatis Mapper 代理等机制都会有一种“原来是挂在这个环节”的通透感。3. 循环依赖与三级缓存Spring 里最容易翻车也最常被问的设计循环依赖是面试必考点也是最容易暴露一个人是否真正理解 Spring 容器的问题。我面试别人的时候从来不要求背三级缓存而是先问一句“两个 Bean 互相依赖Spring 怎么保证不无限递归”能答上来的人通常是真的想过容器的工作流程。3.1 什么样的循环依赖能解决先定义问题。假设有两个单例 BeanService public class ServiceA { Autowired private ServiceB serviceB; } Service public class ServiceB { Autowired private ServiceA serviceA; }Spring 创建 ServiceA 时需要 ServiceB创建 ServiceB 又需要 ServiceA依赖形成闭环。解决的关键在于Spring 允许在 ServiceA 的属性还没填充完就提前暴露一个“早期引用”让 ServiceB 先拿着这个不完整的对象用等 ServiceA 继续完成后续步骤。但注意Spring 默认只能解决“单例 setter/字段注入”的循环依赖构造器注入造成的循环依赖无法解决。原因很直观构造器注入在 Bean 实例化阶段就必须拿到完整依赖这时对象都还不存在谈不上提前暴露半成品。所以我在项目里推构造器注入时总会补一句这不仅能保证依赖完整还能顺带避免构造器循环依赖让问题在启动时直接暴露。3.2 三级缓存各层到底放了什么三级缓存是三个 Map各司其职// 一级存放完整可用的单例 Bean MapString, Object singletonObjects new ConcurrentHashMap(); // 二级存放提前暴露的早期 Bean 引用还没完成属性填充 MapString, Object earlySingletonObjects new HashMap(); // 三级存放 ObjectFactory用来生成早期引用 MapString, ObjectFactory? singletonFactories new HashMap();一级缓存是最终产物所有人都能拿到。二级缓存存的是“半成品”。三级缓存存的是一个ObjectFactory函数式对象调用getObject()时才真正生成早期引用这一步是三级缓存的核心设计。我用一个普通场景走一遍流程创建 ServiceA实例化后把它的ObjectFactory放入三级缓存。填充 ServiceA 属性时发现需要 ServiceB于是调用 getBean(ServiceB)。创建 ServiceB实例化后同样把ObjectFactory放入三级缓存。填充 ServiceB 属性时发现需要 ServiceA这时一级、二级缓存都没有 ServiceA。容器查到 ServiceA 的singletonFactory调用getObject()拿到一个早期引用放入二级缓存并从三级缓存移除。ServiceB 成功拿到 ServiceA 的早期引用完成属性填充、初始化成为完整 Bean 放入一级缓存。ServiceA 继续执行也要从容器拿 ServiceB此时一级缓存已经有完整 ServiceB。ServiceA 完成初始化放入一级缓存。整个过程里ServiceB 实际拿到的确实是 ServiceA 的不完整引用但只要最终 ServiceA 引用地址不变后面属性补全不影响 ServiceB 使用。这正是 Spring 敢提前暴露对象的底气。3.3 为什么是三级不是两级面试里最刁钻的问题在这里。有人会说既然二级缓存已经能放早期引用三级缓存中的 ObjectFactory 是不是多余关键就在 ObjectFactory 的延迟能力。如果一个 Bean 需要被 AOP 代理Spring 最终放进一级缓存的是代理对象。如果只有二级缓存在实例化后立即生成早期引用那此时对象还没执行BeanPostProcessor代理还没生成ServiceB 拿到的很可能是一个“裸对象”。等 ServiceA 填完属性生成代理后内存里同一个 Bean 出现了两个不同对象ServiceB 持有裸对象容器里却是代理对象这就会导致 AOP 失效或类型不一致。三级缓存的 ObjectFactory 可以在“真正需要提前引用”时调用getEarlyBeanReference让后置处理器有机会生成代理。如果没有循环依赖这个 ObjectFactory 不会被调用代理就在正常生命周期最后阶段生成如果存在循环依赖提前暴露时会直接生成代理确保所有引用拿到的都是代理容器最终暴露的也是同一个代理。把选择延后到“确实需要”的时刻本质上是一个延迟决策的设计。protected Object getSingleton(String beanName, boolean allowEarlyReference) { Object singletonObject this.singletonObjects.get(beanName); if (singletonObject null this.singletonsCurrentlyInCreation.contains(beanName)) { singletonObject this.earlySingletonObjects.get(beanName); if (singletonObject null allowEarlyReference) { ObjectFactory? singletonFactory this.singletonFactories.get(beanName); if (singletonFactory ! null) { singletonObject singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } return singletonObject; }这段是从 Spring 5 之前的源码里提炼的简化版最新的 Spring 在加锁方式上有变化但核心思路没有变。能看懂这段代码基本比只背“三级缓存四个字”的候选人扎实得多。3.4 循环依赖与 AOP 的暗坑一旦循环依赖和 AOP 同时出现情况会更加微妙。我记得有次排查一个问题一个涉及事务的 Service A 与另一个普通 Service B 循环依赖结果 A 里调自己的方法时事务失效了。排查到最后发现B 持有的 A 是原始对象而不是 AOP 代理对象。原因就是上面讲的如果三级缓存的getObject()被提前触发生成的代理确实会被各方拿到但如果没有循环依赖代理会在初始化之后生成不会有问题。问题往往出在“这个 Bean 本身不需要循环依赖但某个 BeanPostProcessor 在早期阶段触发了引用”间接导致代理逻辑变得混乱。Spring 官方其实不太推荐循环依赖Jakarta 规范语境下的架构设计更是建议直接打破循环。我从实际开发里得到的经验循环依赖能不用就不用重构手段包括把互相依赖的方法拆分到不同服务、引入事件解耦、或者用Lazy把一边的依赖延迟加载来切断循环。4. 从 XML 到 Java ConfigDI 配置方式的演进Spring 早期的配置方式是 XML很多老项目现在还有bean标签我对 XML 时代的印象就是“写配置写到手抽筋”。但现在看XML 方式的演进其实很好地向我们展示了 IoC 容器解决问题的方式把对象关系变成可配置的数据而不是硬编码在类里。4.1 XML 时代一个典型的 XML 配置长这样bean iduserService classcom.example.service.UserService property nameuserDao refuserDao/ /bean bean iduserDao classcom.example.dao.UserDao/property标签表示 setter 注入constructor-arg表示构造器注入。这种方式的优点是配置与代码分离缺点是对象一多配置体积迅速膨胀而且没有编译期检查属性名写错了只能等运行时报错。4.2 注解驱动把 IoC 变成了“声明式”后来 Spring 2.5 引入了注解Component、Service、Repository、Controller本质上是同一个注解的不同语义变体核心都是标记这个类要被容器管理。配合ComponentScan扫描包路径把Autowired标在字段或构造器上Spring 会在属性填充阶段自动注入依赖。我一般会提醒新人Autowired默认按类型查找如果同一类型有多个候选 Bean就会抛NoUniqueBeanDefinitionException。解决办法有Primary指定主候选或者用Qualifier(beanName)精确指定名称。Resource是 JSR-250 标准里的注解默认按字段名查找再按类型查找。如果选Resource要注意它的语义和Autowired不完全一样别混用后凭感觉猜。4.3 Java Config 与 Spring Boot 自动装配Spring 3.0 引入了Configuration和BeanConfiguration public class AppConfig { Bean public UserService userService(UserDao userDao) { return new UserService(userDao); } }写法集中在 Java 代码里有编译期类型检查重构时也能被 IDE 正确识别。到了 Spring Boot配置进一步自动化SpringBootApplication里内置了ComponentScan和EnableAutoConfiguration自动配置通过ConditionalOnClass、ConditionalOnMissingBean等条件注解在类路径上发现相关依赖就把对应 Bean 注册进容器。这也是很多人的第一个 Spring Boot 程序能直接启动的原因你引入spring-boot-starter-web自动配置发现类路径有Servlet相关类于是帮你注册了DispatcherServlet和内嵌 Tomcat你引入spring-boot-starter-data-redis自动配置发现RedisTemplate相关类存在就帮你配置连接工厂。本质上这些全部都是 IoC 容器在背后批量注册 BeanDefinition 的产物。调试时可以在application.yml里加debug: true启动日志会打印一份自动配置报告能看到哪些自动化配置生效了、哪些被排除了强烈建议自己试一次。5. 手写一个简化版 IoC 容器到底有多难我曾经把“手写 Spring”当作验证自己理解程度的手段。写完之后最大的感受是框架里很多看似冗余的设计真的都是必要的。这里我把一个迷你容器的实现思路拆开你照着走一遍对 IoC 的理解会比读十篇原理文章都牢。5.1 定义最小功能目标不追求完整先做四件事扫描指定包下所有带Component注解的类。把它们注册成类似 BeanDefinition 的元信息。实例化对象并注入Autowired标注的依赖。用三级缓存解决单例循环依赖。我用一个MiniApplicationContext承载全部逻辑内部字段包括三个缓存 Map、一个“当前正在创建”的集合、一个 BeanDefinition 的注册表public class MiniApplicationContext { // Bean 名称 - BeanDefinition简化版用 Class 代替 private final MapString, Class? beanDefinitions new ConcurrentHashMap(); // 三级缓存 private final MapString, Object singletonObjects new ConcurrentHashMap(); private final MapString, Object earlySingletonObjects new ConcurrentHashMap(); private final MapString, ObjectFactory? singletonFactories new ConcurrentHashMap(); private final SetString singletonsCurrentlyInCreation ConcurrentHashMap.newKeySet(); public MiniApplicationContext(Class? configClass) throws Exception { // 扫描 configClass 所在的包注册 BeanDefinition scan(configClass.getPackageName()); // 模板方法逐个实例化非懒加载单例 Bean for (String beanName : beanDefinitions.keySet()) { getBean(beanName); } } public Object getBean(String beanName) throws Exception { // 第一级缓存命中直接返回 if (singletonObjects.containsKey(beanName)) { return singletonObjects.get(beanName); } // 第二级缓存命中说明正在创建中实际上是早期引用 if (earlySingletonObjects.containsKey(beanName)) { return earlySingletonObjects.get(beanName); } // 第三级缓存存在则调用 ObjectFactory 获取并升级到二级缓存 if (singletonFactories.containsKey(beanName)) { Object earlyObject singletonFactories.get(beanName).getObject(); earlySingletonObjects.put(beanName, earlyObject); singletonFactories.remove(beanName); return earlyObject; } // 如果正在创建则说明发生了重复创建 return createBean(beanName); } }扫描包本来可以用 Spring 现成的ClassPathScanningCandidateComponentProvider但手写时我用最简单的方式递归读取文件系统里 class 文件路径再通过反射加载类检查是否有Component注解。这部分代码不复杂但能让你体会到“扫描包”背后其实没有魔法。5.2 实例化与注入的反射套路拿到一个 BeanDefinition 后创建对象分两步实例化、填充属性。private Object createBean(String beanName) throws Exception { Class? clazz beanDefinitions.get(beanName); // 标记正在创建用于循环依赖判断 singletonsCurrentlyInCreation.add(beanName); // 实例化 Object instance clazz.getDeclaredConstructor().newInstance(); // 提前暴露 ObjectFactory 到三级缓存 singletonFactories.put(beanName, s - instance); // 属性填充 populateBean(instance); // 完成创建升级到一级缓存移除中间缓存 singletonObjects.put(beanName, instance); singletonFactories.remove(beanName); earlySingletonObjects.remove(beanName); singletonsCurrentlyInCreation.remove(beanName); return instance; } private void populateBean(Object instance) throws Exception { for (Field field : instance.getClass().getDeclaredFields()) { if (field.isAnnotationPresent(Autowired.class)) { field.setAccessible(true); // 按字段类型找到 Bean 名称再递归 getBean Object dependency getBean(field.getType().getSimpleName()); field.set(instance, dependency); } } }如果两个 Bean 互注走到populateBean时会递归 getBean此时三级缓存的早期引用就会兜底。我这个极简版本里 ObjectFactory 只是返回原始对象没有做 AOP 处理真正的 Spring 会在工厂方法里调用getEarlyBeanReference生成代理。你用这个简化模型去反推 Spring 的源码会发现很多逻辑完全对得上。5.3 手写之后我才明白的事写完后我第一时间翻了一遍 Spring 的DefaultSingletonBeanRegistry之前觉得绕的三级缓存代码一下子就能看懂了。手写项目不需要很大核心逻辑加起来两三百行就能跑起来但写之前你要能回答清楚几个问题Bean 是怎么被发现的默认实现和别名怎么处理容器怎么判断 Bean 是否创建到一半这些恰好是把 IoC 从“知识点”变成“能力”的关键。很多人在学习阶段喜欢背面试题但“手写 Spring”类的问题一旦出现立刻露馅。我的建议是如果时间有限至少要动手写一个极简版本哪怕只处理扫描、实例化、注入三步也足够你在面试时从容地说出容器工作流程。6. 常见问题与排查技巧实录这部分是我在项目里踩过、也帮别人排查过的真实问题按出现频率从高到低整理。理解了前面原理这些错误基本都能定位到位。6.1 注入失败类错误这类报错通常出现在启动阶段核心是找不到对应的 Bean 或者找到了多个 Bean。异常信息关键片段对应问题处理思路NoSuchBeanDefinitionException容器里根本没有这个类型的 Bean检查包扫描路径、类上有没有Component家族注解、Bean方法是否被Configuration包裹NoUniqueBeanDefinitionException同一类型找到多个 Bean在候选 Bean 上加Primary或注入处用Qualifier(beanName)指定BeanCurrentlyInCreationException构造器循环依赖把其中一个依赖改成 setter/字段注入或加Lazy拆环UnsatisfiedDependencyException泛化错误底层嵌套真实失败原因不要只看外层异常从 Cause 往深处读才是真正问题BeanNotOfRequiredTypeException类型不匹配比如动态代理导致了类型不一致检查是否把代理类当成原始类使用必要时用接口接收读异常信息时有一个习惯很关键Spring 的异常链往往很长初学者看到第一行就慌了。实际排查时我会优先看 Caused by 层那里才是第一次抛错的真正位置。6.2 Autowired 注入后是 null 怎么办这是我被问过最多的问题之一几乎每个 Spring Boot 教程下面都会有留言问“为什么我拿到的 service 是 null”。常见的几个原因对象不是由 Spring 管理的而是直接new出来的。new出来的对象不会经过容器Autowired自然无效。静态字段或静态代码块里使用注入对象。Spring 不支持直接注入静态字段常见做法是把容器工具类注册进 Spring再通过静态方法访问。在PostConstruct之前的某个时机访问了注入对象此时属性填充可能都还没完成。异步线程或定时任务里通过new创建的类访问注入对象。最常见也最容易踩的其实是第一和第三种。解法也很直接能用 Spring 管理就让 Spring 管不要在普通工具类中硬编码依赖需要静态访问时通过实现ApplicationContextAware持有容器再获取 Bean这才是正确姿势。6.3 面试高频追问怎么接“IoC 和 DI 的关系”是最基础的能分清“IoC 是设计原则、DI 是落地手段”就够了。“BeanFactory 和 ApplicationContext 的区别”要把懒加载和增强能力讲清楚。“三级缓存原理”重点说清三级缓存各自的职责以及为什么第三级要放 ObjectFactory。如果面试官再追问“AOP 和 IoC 的关系”可以结合BeanPostProcessor在初始化后阶段生成代理来讲说明 AOP 其实是嵌套在 Bean 生命周期里的插件机制。“Spring 创建 Bean 的流程”这个问题我会按生命周期主线回答从BeanDefinition注册、实例化、属性填充、Aware 回调、BeanPostProcessor、初始化一直讲到销毁。边回答边结合源码里的典型类名比如AbstractAutowireCapableBeanFactory#doCreateBean面试官一听就知道你是真读过源码。最后再分享一点实际体会写这篇文章之前我又把自己那个迷你容器项目重新看了一遍发现很多当时觉得“很简单”的设计后来都在 Spring 源码里找到了对应原型。Spring IoCDI 要说难难在理解容器创建 Bean 时的决策顺序要说简单只要你动手写过一次很多问题都会自然消解。我给你的建议很简单不要只刷面试题去写一个能跑起来的手写 Spring 迷你版。遇到报错就翻源码遇到不懂就打断点把DefaultSingletonBeanRegistry和AbstractAutowireCapableBeanFactory这两个类的关键方法打印出来看。等你亲手处理过一个“循环依赖导致启动失败”的案例再回头看这些原理会有一种完全不同的感觉。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →