☰
Spring IOC核心机制:从DI到三级缓存,一次拆解Bean生命周期
2026/10/2 15:00:38 网站建设 项目流程

Spring作为Java后端开发事实上的标准,IOC(Inversion of Control,控制反转)一直是它最核心的设计思想。很多写了几年代码的朋友,天天用@Service、@Autowired,但被问起IOC到底解决什么问题、Bean是怎么new出来的、三级缓存是干嘛的,往往说不清楚。尤其面试场合,几乎绕不开IOC和AOP的原理。这篇我想把所有相关的核心机制,结合Spring Boot里的实际表现,好好拆开讲一遍,争取让刚入门的朋友能建立整体认知,让有经验的朋友能查漏补缺。

1. 先搞清楚IOC是什么,控制反转到底反转了什么

1.1 从传统new对象到Spring容器管理

在没有Spring的年代,代码里最常见的写法就是到处new。比如一个订单服务需要调用库存服务,直接在构造方法里new StockService(),订单服务就需要自己去管库存服务的创建过程。耦合紧接着就来了:如果库存服务换了实现类,或者构造方法里多了参数,所有new它的地方全都得改一遍。

IOC的核心逻辑,说白了就是把“创建对象、组装依赖”这件事从业务代码里抽出来,交给一个容器统一处理。这个容器管着你需要的一系列对象(在Spring里叫Bean),它会在合适的时机创建对象,并且把对象依赖的其他对象自动塞进去。业务代码不再需要new了,只是声明“我需要一个库存服务”,剩下的事情交给框架。这就是“控制反转”:对象创建和装配的控制权,从程序员手里反转到了容器手里。

1.2 控制反转和依赖注入是一回事吗

很多资料里IOC和DI(Dependency Injection,依赖注入)混着讲,容易把人绕晕。实际上,依赖注入是IOC的一种具体实现方式,也是Spring采用的方案。

  • IOC:一种设计理念,关注的是控制权交给谁。
  • DI:具体做法,容器通过构造器、Setter方法或者字段注入,把依赖传给对象。

打个生活化的比方。传统做法像你每次吃饭前自己买菜、洗菜、炒菜,吃什么、怎么做完全自己控制。引入IOC之后,你变成了订餐的人,只需要说“我要一份鱼香肉丝”,食堂(容器)负责把菜做好送到你面前。你不用关心今天的菜是谁种的、厨师怎么切肉,只关心自己能不能吃到这顿饭。

Spring里最常见的注入方式就是字段上的@Autowired,虽然日常写起来最省事,但严格来说构造器注入更值得推荐。构造器注入的好处在于,一创建对象依赖就完整,不容易出现对象还没装配完就被拿去用的情况,也方便后续做单元测试。字段注入的主要缺点是不容易看出来依赖顺序,测试的时候也麻烦些。

1.3 为什么Spring选择IOC而不是工厂模式硬扛

有人会问,我自己写个工厂类也能解耦,为什么非要用Spring容器?实际上工厂模式依然是面向对象层面的解耦,它解决的是“两个类之间不直接发生依赖”的问题。但是工厂模式本身也需要维护,每新增一个产品就要改工厂的代码。Spring容器比工厂更彻底的地方在于,它不只负责创建对象,还负责整个Bean的生命周期管理、代理增强、事件机制、配置解析,以及跟Spring Boot自动配置体系无缝衔接。

举个实际场景。你在Spring Boot项目里引入一个DataSource,自己不做任何配置,Spring Boot的自动配置就能通过@ConditionalOnMissingBean之类的条件,帮你装配一个默认的数据源。这种能力在纯工厂模式里基本无法想象。换句话说,Spring容器的定位已经超出了“对象工厂”,它是整个应用运行时的骨架。

2. Bean的生命周期与三级缓存,面试重点必须吃透

2.1 一个Bean从定义到销毁经历了什么

理解IOC只停留在“容器替我new对象”是远远不够的,Spring容器对一个Bean的管理是一个完整的过程。我把关键环节按顺序列一下:

阶段说明典型参与机制
定义解析读取配置类、XML或注解,生成BeanDefinition@Bean、@ComponentScan
合并定义处理父子BeanDefinition,继承覆盖属性ChildBeanDefinition
实例化前允许在真正new之前自定义逻辑InstantiationAwareBeanPostProcessor#postProcessBeforeInstantiation
实例化调用构造器创建实例构造器注入、工厂方法
属性填充给实例注入属性、依赖@Autowired、@Resource、@Value
初始化前BeanPostProcessor的postProcessBeforeInitialization@PostConstruct底层也依赖这个机制
初始化执行InitializingBean#afterPropertiesSet和自定义init-method@Bean(initMethod = "...")
初始化后BeanPostProcessor的postProcessAfterInitializationAOP代理正是在这里生成
使用阶段Bean就绪,交给业务代码使用正常的业务调用
销毁执行DisposableBean#destroy和自定义destroy-method@PreDestroy、@Bean(destroyMethod = "...")

从这个过程里能看出,Spring把Bean的“一生”切得非常细,几乎每个关键节点都留了钩子。这也是为什么Spring生态能有那么多扩展能力的原因。业务代码平时接触最多的@PostConstruct和@PreDestroy,本质上就是容器在特定阶段帮忙回调的机制。

2.2 三级缓存到底解决了什么问题

三级缓存这个词,基本是Spring面试绕不开的题。它对应Spring源码里的DefaultSingletonBeanRegistry中三个Map:

缓存层级存储内容存在目的
一级缓存singletonObjects,存放已经初始化完成的完整单例Bean获取最终成品 Bean
二级缓存earlySingletonObjects,存放提前曝光的半成品对象(尚未完成属性填充)保存半成品,隔离不同阶段的同一对象
三级缓存singletonFactories,存放能产生半成品的工厂(ObjectFactory)延迟创建代理对象,解决循环依赖时可能出现的代理问题

三级缓存的核心目标,说得直白些,是为了支持单例Bean之间的Setter循环依赖。什么叫循环依赖呢?A依赖B,B又依赖A,如果没有特殊处理,创建A时发现需要B,创建B时又发现需要A,两边等对方,就成了死循环。Spring用一个简单的机制打破了这个循环:A实例化之后,不急着完成所有初始化,先把刚才new出来的A包装成一个半成品工厂放到三级缓存里,然后用这个半成品去填充B的依赖。B创建时能看到A的半成品,把A填进自己的属性里,B初始化完成后,A再继续走完自己剩下的初始化,最后两个Bean都完整就绪。

2.3 二级缓存和三级缓存为什么必须分两层

不少人在看到三级缓存之后会有疑问:既然二级缓存里已经有半成品了,为什么还要多搞一个三级缓存?原因是AOP代理。Spring里很多Bean实际要替换成代理对象,代理的创建时机一般是初始化之后,也就是postProcessAfterInitialization阶段。如果提前到实例化后就创建代理,会破坏正常的初始化流程判断。三级缓存里放的不是对象本身,而是一个ObjectFactory,这个工厂可以在恰当的时候决定是返回原始对象还是生成代理对象。这样循环依赖发生的时候,能从三级缓存取出一个“还没有完全决定形态”的半成品,等真正需要的时候才确认到底要不要代理。

如果只有二级缓存,提前把原始对象存进去,等后续发现这个Bean需要代理,就得额外处理“二级缓存里的原始对象和最终代理对象不是同一个”的情况,很容易导致注入的是原始对象,而容器里最终保存的是代理对象,出现类型不一致的诡异问题。Spring用三级缓存,将“实例化”和“代理决策”延迟分离,既解决了循环依赖,又不破坏代理逻辑,这个设计可以说是整个IOC容器里最精妙的部分之一。

2.4 这些循环依赖Spring也解决不了

三级缓存的适用范围有限,不少人被面试官问到这一点时容易答错。Spring的方法级循环依赖(构造器注入循环依赖)是无解的,因为构造器调用是实例化阶段的一部分,A还没实例化完,B就没法拿到A的对象,此时三级缓存里还没东西可用。原型作用域的Bean循环依赖同样无法解决,因为Spring不会为prototype作用域实例缓存半成品。

此外,使用了@Async这类代理增强的循环依赖场景,也容易出现异常提示,原因在于异步代理的提前创建与三级缓存的设计有冲突。实际工作中,最稳妥的办法是重构依赖结构,尽量不要让两个类相互直接依赖。就算Spring基于Setter循环依赖能兜底,循环依赖本身也是设计上的坏味道。

3. 别只会用注解,Bean作用域与容器机制也得懂

3.1 单例和多例,以及更多作用域

Spring里最常用的两个作用域是singleton和prototype。默认情况是singleton,也就是同一个容器里同一个Bean只保留一个实例。以Spring Boot里常见的@Service服务类为例,绝大多数情况下用单例就够了,而且单例也最省内存。但需要特别注意的是,单例Bean里如果持有可变状态,就要小心并发问题,尽量把状态设计成无状态的。

prototype意味着每次获取Bean都会得到一个新实例。适合保存临时状态、不希望被共享的场景。但对应地,prototype的Bean生命周期管理也变轻了,Spring在创建之后就不会跟踪它的销毁阶段,需要使用者自己负责清理。

除了这两种,Web场景还有request、session、application这几个作用域,分别对应一次HTTP请求、一个会话、整个ServletContext生命周期。实际开发中用得不多,但理解它们能帮你判断一个Bean在什么范围内是共享的,可以避免出现“我的变量怎么跨请求了”之类的困惑。作用域选错,往往是Spring应用里状态混乱的根源。

3.2 BeanFactory和ApplicationContext是什么关系

源码初看时,容易被这两个概念搞懵。BeanFactory是Spring容器的顶层接口,定义了最基本的getBean、containsBean这些方法。ApplicationContext在它之上扩展了国际化、事件发布、资源加载、环境配置等很多能力。日常开发里我们用的基本都是ApplicationContext,因为功能完整,适合真实应用。

Spring Boot里注入一个ApplicationContext也很常见,比如在工具类中通过它获取某个Bean。做法通常是用@Autowired注入,甚至是通过ApplicationContextAware回调在Bean初始化时拿到容器引用。这里有个小提醒:拿到容器引用不是什么大问题,但如果业务代码里大量用applicationContext.getBean()去获取对象,往往说明依赖注入没有做彻底。正确姿势是把依赖关系声明成属性,而不是到处去容器里找。

3.3 Spring Boot自动配置背后还是IOC

很多人学Spring Boot时忽略了它跟IOC的紧密关系。Spring Boot的自动配置,本质就是一堆被条件化控制的@Configuration类和@Bean方法。容器的职责从XML时代的手工注册,变成了基于Classpath里的依赖、配置文件和条件注解自动决定装配什么。这也意味着,你理解了IOC,再看@ConditionalOnClass、@ConditionalOnMissingBean这些自动配置注解,思路会清晰很多。

Spring Boot提供了感知IOC的绝佳入口:启动日志里能看到好多BeanDefinition注册信息,用得到debug=true配置或者spring-boot-actuator的beans端点,都能查到容器里到底有哪些Bean。遇到“我的方法没执行”这类由Bean装配失败引起的问题,直接从Bean定义和自动配置角度排查,比单纯搜异常消息会更有效率。

4. 手写一个极简IOC容器,加深理解

4.1 设计和思路

有时候听原理总觉得明白了,但手一写就露馅。我建议初学者可以自己实现一个迷你IOC容器,不需要完整复刻Spring,关键是把“容器维护Bean定义、实例化对象、注入依赖”这几件事说清楚。我设计了一个非常精简的版本,支持按类名注册和查找Bean,依赖注入通过字段上的自定义注解完成。

基本的流程是这样:

  1. 维护一个Map<String, Object> beanMap保存实例化完成的单例对象。
  2. 通过一个register(Class<?> clazz)方法,扫描类上的自定义@SimpleComponent注解。
  3. 使用反射创建对象实例。
  4. 遍历字段,找到标了@SimpleAutowired的字段,再递归获取依赖对象完成注入。
  5. 所有对象创建完成后,把Bean放回容器。

4.2 核心代码实现

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface SimpleComponent { } @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface SimpleAutowired { }

然后是容器的核心逻辑:

public class SimpleIocContainer { private final Map<String, Object> beanMap = new ConcurrentHashMap<>(); public void register(Class<?> clazz) throws Exception { if (!clazz.isAnnotationPresent(SimpleComponent.class)) { return; } String beanName = Character.toLowerCase(clazz.getSimpleName().charAt(0)) + clazz.getSimpleName().substring(1); Object instance = clazz.getDeclaredConstructor().newInstance(); beanMap.put(beanName, instance); } public void autowire() throws Exception { for (Object bean : beanMap.values()) { populateProperties(bean); } } private void populateProperties(Object bean) throws Exception { for (Field field : bean.getClass().getDeclaredFields()) { if (field.isAnnotationPresent(SimpleAutowired.class)) { String fieldBeanName = Character.toLowerCase(field.getType().getSimpleName().charAt(0)) + field.getType().getSimpleName().substring(1); Object dependency = beanMap.get(fieldBeanName); if (dependency == null) { throw new RuntimeException("找不到可注入的依赖: " + field.getName()); } field.setAccessible(true); field.set(bean, dependency); } } } public <T> T getBean(String beanName) { return (T) beanMap.get(beanName); } }

用起来大概是这个效果:

@SimpleComponent public class UserService { @SimpleAutowired private UserDao userDao; public void save() { userDao.insert(); } } @SimpleComponent public class UserDao { public void insert() { System.out.println("插入一条用户数据"); } }

启动时手动注册并装配:

public class DemoApplication { public static void main(String[] args) throws Exception { SimpleIocContainer container = new SimpleIocContainer(); container.register(UserService.class); container.register(UserDao.class); container.autowire(); UserService userService = container.getBean("userService"); userService.save(); } }

整个过程非常粗糙,没考虑循环依赖、作用域、代理这些高级特性,但能帮你直观感受IOC的核心是什么。你会发现,所谓的容器不过就是一个Map加反射加一点注册逻辑。Spring干的事情比这复杂得多,但骨架思路是相通的。

4.3 手写之后,再看Spring源码就轻松多了

有过手写经验后,再翻开DefaultListableBeanFactory和AbstractAutowireCapableBeanFactory的源码,很多方法名虽然长,但你能看出它们对应的是哪个环节。doCreateBean方法里,从createBeanInstance到populateBean再到initializeBean,跟我们手写的流程是一一对应的。三级缓存里的getSingleton和getEarlyBeanReference,其实就是换了一种更精巧的方式处理Bean之间的依赖。

我还记得第一次看Spring源码时被各种类名和回调方法吓到,后来自己动手写了一个两百行的迷你容器,再回头读才豁然开朗。建议想深入源码的朋友都可以先做这一步,效率会高很多。

5. 高频面试问题和实际排查经验

5.1 高频知识点速查

结合我经历的和见到的面试题,IOC相关的内容翻来覆去就问这几点:

高频问题参考答案要点
IOC是什么,好处是什么控制反转,解耦、集中管理、易于扩展、方便测试
循环依赖怎么解决三级缓存,一级存完成品,二级存半成品,三级存ObjectFactory
为什么二级缓存不够需要延迟决定是否创建代理对象,避免代理与实际对象不一致
BeanPostProcessor的作用在Bean初始化前后做处理,是AOP代理的关键入口
BeanFactory和ApplicationContext区别ApplicationContext是BeanFactory的子接口,功能更全
作用域有哪些主要是singleton和prototype,Web场景有request、session
构造器注入为什么推荐依赖完整、方便测试、避免字段注入随意性

如果面试官让你画图说明循环依赖流程,重点画“实例化A -> 放入三级缓存 -> 填充B -> B获取A半成品 -> B完成 -> A继续完成”这条线,比背定义有说服力得多。

5.2 我踩过的坑:注入没生效、代理失效和懒加载

第一个高频问题:字段加了@Autowired但运行时报空指针。原因往往不是Spring坏了,而是这个类本身没有被Spring管理。比如直接new了一个Service,或者这个Service没有加@Service注解。排查思路很简单,看类上有没有被容器扫描到,或者到Actuator的beans端点里查有没有这个Bean。

第二个坑:作用域配错造成的状态污染。有人把某个带缓存状态的组件设置成singleton,结果多个用户请求共享同一个可变成员变量,数据互相串了。解决办法要么把状态抽走,要么改用更适合的作用域,或者干脆设计成无状态类。

第三个坑:懒加载@Lazy使用不当时,可能导致依赖注入的代理由一个延迟代理对象代替,有人调试时发现调用方法没有执行到真实实现,其实是注解加错了位置。

还有AOP相关的问题,比如自己写的Service里this.save()调用同类另一个@Transactional方法,事务不生效。这不是IOC本身的问题,而是代理机制导致的。Spring实现事务等能力时,给Bean生成了代理,外部调用走代理,内部调用直接访问原始对象,于是增强逻辑失效。理解了初始化阶段之后生成代理这点,基本就能明白为什么内部调用不走代理了。

5.3 面对源码别硬刚,带着问题读

如果想深入Spring源码,我的建议是别从头到尾硬读。找一个具体问题作为抓手,比如“循环依赖是怎么解决的”,然后跟着AbstractAutowireCapableBeanFactory的执行路径走一遍,记录关键方法名和分支。这样效率高,而且理解深刻。第一遍不懂的类名记下来,回去手写模拟流程,再回来看源码。

调试时也可以利用IDEA的断点,在DefaultSingletonBeanRegistry.getSingleton里观察三级缓存里三个Map的变化,比空想理解快得多。有一次我就是通过断点看到earlySingletonObjects里存的是半成品,才明白二级缓存存在的必要性。

写在最后的个人体会

IOC这个设计,真正厉害的地方不在于让程序员少写几行new,而在于它为整个框架的扩展提供了土壤。没有容器统一管理Bean生命周期,就没有后面的AOP、事务管理、Spring Boot自动配置这些生态能力。我的建议是,不管是写业务还是准备面试,都值得花时间把它作为理解和审视整个Spring的钥匙。先会用,再懂原理,最后能自己复现一个迷你容器,这个进阶路径走下来,你面对复杂问题时的底气和普通CRUD程序员是完全不一样的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询