做 Java 开发的基本绕不开一套组合:Spring + Spring Boot。而在 Spring 这套体系里,IoC 是绝对的地基。我见过不少工作两三年的人,面试时能背出“控制反转”四个字,但问起 BeanFactory 和 ApplicationContext 的关系,问起三级缓存为什么必须设计成三级,就会明显卡壳。这篇内容的目标只有一个:把 IoC 的原理讲透,再把原理落成你能自己动手做的代码,最后整理一份面试向的问答笔记,方便你查漏补缺。
IoC 本身不复杂,复杂的是它延伸出来的一整套机制。你不妨把 Spring 容器想象成一家中央厨房:各个菜品由厨房统一采购、统一备料、统一出餐,你要吃饭只需要跟服务员下单。传统写法里,每个菜都是你自己买菜、洗菜、炒菜,做完还得自己洗碗。IoC 做的事,就是把这些“杂活”全部收编进了容器。下面我从概念开始,一步步拆到源码机制,再到手写一个迷你容器,帮你把这条链路彻底打通。
1. IoC 到底是什么,它解决了什么问题
1.1 “控制反转”四个字怎么理解
很多教程喜欢举“组装电脑”的例子:以前你想用一台电脑,得自己挑 CPU、显卡、内存,一一插到主板上,这叫自己控制组装过程;后来你直接找品牌机厂商,说“我要一台能剪辑视频的”,剩下的配置由厂商搞定,这叫控制反转。
这个类比基本准确,但还不够贴近代码。更直白的说法是:对象的创建权和绑定权,从调用方手里移交到了容器手里。调用方不再写new ServiceImpl(),不再管理ServiceImpl内部又依赖了谁,而是声明一句“我需要一个 Service”,容器就会把完整可用的对象送到你手上。专业术语里,这叫做依赖注入(Dependency Injection,DI)。
依赖注入一般有四种姿势:构造器注入、Setter 注入、字段注入(比如@Autowired标注在字段上)和方法参数注入。其中构造器注入最推荐,因为它能保证对象一旦创建出来,所有依赖就已经就绪,不会出现“半个对象”的状态。字段注入写起来最简洁,但单元测试时得先反射塞字段,容易让人忽略依赖关系的重要性。
1.2 一直 new 会造成什么局面
你可能觉得,我写了几年代码,天天new也没出过大事。那是因为你的项目规模还没到一定量级。一旦系统变大,直接new会带来三个很现实的问题。
第一是耦合扩散。假设你的订单服务里这么写:
public class OrderService { private UserService userService = new UserService(); private InventoryService inventoryService = new InventoryService(); private CouponService couponService = new CouponService(); }今天UserService的构造器从无参变成了需要一个UserRepository,你就要跑来改OrderService;明天CouponService又要依赖一个远程配置中心,你还得改。改到后面你会发现,系统改一个底层实现,牵动十几个上层类,修改成本成倍增长。
第二是兼容场景不好切换。用new写死实现类之后,测试时想用一个 Mock 对象替代真实的数据库服务,就没法在运行期动态切换。你得把代码改成接口,再想办法找个地方把实现类“塞”进去——这其实就是你手工造轮子的第一步,也是 IoC 容器替你做的第一步。
第三是实例的生命周期没人统一管理。谁负责创建、谁负责销毁、什么时候初始化、什么时候释放连接,如果全靠业务代码写,很容易出现资源泄漏或者重复创建。容器接管之后,单例 Bean 由容器统一持有,销毁时有序调用 destroy 方法,开发者的心智负担会小很多。
2. Spring 容器的两大支柱:BeanFactory 与 ApplicationContext
2.1 BeanFactory:所有容器的老祖宗
BeanFactory是 Spring 容器的根接口,它承担了最核心的职责:根据 Bean 定义,生产并管理 Bean 实例。它的getBean(String name)方法是最原始的取 Bean 入口,所有高级容器最终都绕不开它。
你可以把BeanFactory看成一家小作坊:能生产豆子(Bean),但生产流程比较朴素。它默认采用延迟初始化策略,也就是你调用getBean()的时候,它才真正去创建实例。这在 Spring 早期资源敏感的场景下很有意义,但在日常 Web 项目里,我们通常更希望容器启动时就把所有单例都预创建好,尽早发现配置错误。
BeanFactory还有一个很关键的能力:管理 Bean 的父子关系。HierarchicalBeanFactory支持子容器先查自己的 Bean,查不到再向父容器要。这在框架整合场景里很常见,比如 Spring MVC 中,父容器管理 Service、DAO,子容器管理 Controller,子容器可以访问父容器的 Bean,反向则不行。
2.2 ApplicationContext:站在祖宗肩膀上的高级容器
ApplicationContext继承了BeanFactory,但它是绝大多数项目里真正使用的容器。它比小作坊多了四件事:预初始化单例、事件发布、资源加载、国际化消息支持。
这四个能力对应了四个接口:ConfigurableApplicationContext管理生命周期,ApplicationEventPublisher做事件监听,ResourceLoader加载配置文件,MessageSource处理 i18n。Spring Boot 项目里常见的AnnotationConfigApplicationContext,就是ApplicationContext的实现类。
日常开发中,我们感慨“Spring 好强大”的那些体验,其实大多来自ApplicationContext,而不是最底层的BeanFactory。比如你在启动日志里看到Root of context hierarchy,启动完成后容器已经把所有能预创建的单例都建好了;又比如你在任意 Bean 里注入ApplicationContext,就能用它发布一个自定义事件,监听方立刻感知到。这些便利全靠这个高级容器。
下面这个表格可以帮你快速对比两者:
| 维度 | BeanFactory | ApplicationContext |
|---|---|---|
| Bean 创建时机 | 懒加载,getBean 时才创建 | 默认启动时预创建单例 |
| 事件发布 | 不支持 | 支持 |
| 资源加载 | 能力有限 | 统一 ResourceLoader |
| 国际化 | 不支持 | 支持 |
| 内置增强 | 无 | 集成 BeanPostProcessor 等机制 |
| 实际使用 | 框架内部、嵌入式场景 | 绝大多数应用场景 |
2.3 BeanDefinition:容器的“建造图纸”
你有没有想过一个问题:容器怎么知道该创建哪些 Bean?Bean 是单例还是原型?构造参数是什么?这个信息容器是靠BeanDefinition保存的。BeanDefinition 就是 Bean 的图纸。里面记录类名、作用域、是否懒加载、初始化方法、销毁方法、属性值等元数据。
Spring 启动时,大概会经过这么几件事:
- 扫描配置文件或注解,把一个个
BeanDefinition注册进BeanDefinitionRegistry。 - 遍历所有
BeanDefinition,调用BeanFactoryPostProcessor做修改完善。 - 真正实例化前,容器会检查是否存在
InstantiationAwareBeanPostProcessor,允许你拦截实例化过程。 - 对于正常单例 Bean,进入创建流程,塞进一级缓存
singletonObjects。
理解了BeanDefinition,你就能想通很多问题。比如为什么配置类上的@Configuration(proxyBeanMethods = false)能提升启动速度——因为不用生成 CGLIB 子类去拦截@Bean方法,少做了很多额外工作,但代价是同一个@Bean方法被多次调用时可能返回不同实例。
3. Bean 生命周期与三级缓存:Spring 最精妙的部分
3.1 Bean 生命周期全景图
Spring 文档里没有直接给你一张大图,但源码流程很清晰。一个普通单例 Bean 从创建到销毁大致经历以下阶段:
- 容器读取
BeanDefinition,根据类信息构造实例。 - 属性填充:把配置好的属性值、
@Autowired依赖注入进去。 - 处理各种
Aware接口:如BeanNameAware、BeanFactoryAware、ApplicationContextAware。 - 调用
BeanPostProcessor#postProcessBeforeInitialization。 - 调用初始化逻辑:
@PostConstruct方法、InitializingBean#afterPropertiesSet、配置的initMethod。 - 调用
BeanPostProcessor#postProcessAfterInitialization,这一步也是AOP 代理生成的最佳时机。 - Bean 进入就绪状态,供业务调用。
- 容器关闭时,依次执行
@PreDestroy、DisposableBean#destroy、自定义destroyMethod。
之前有同事问我,为什么@PostConstruct会执行,InitializingBean里的方法也会执行,这两者到底什么顺序?这里明确说一下:@PostConstruct在InitializingBean#afterPropertiesSet之前执行。实际上,Spring 内部通过CommonAnnotationBeanPostProcessor拦截了postProcessBeforeInitialization,在那里调用@PostConstruct;而InitializingBean则是在初始化阶段由invokeInitMethods调用。两者出现顺序别搞反。
3.2 三级缓存为什么能兜住循环依赖
循环依赖是最常被问的原理之一。典型场景是 A 依赖 B,B 又依赖 A。Spring 单例 Bean 为什么能处理这种循环引用?靠的就是三级缓存。
三级缓存的三个 Map 分别是:
| 缓存 | 作用 | 存放内容 |
|---|---|---|
一级缓存singletonObjects | 存放创建完成、可用的成品 Bean | 最终实例 |
二级缓存earlySingletonObjects | 存放提前暴露的半成品 Bean | 早期实例,可能还不是最终代理 |
三级缓存singletonFactories | 存放 ObjectFactory,用于生成早期引用 | 工厂对象,而不是直接对象 |
创建 A 的时候,Spring 会先把 A 的ObjectFactory放进三级缓存singletonFactories,然后才开始填充属性。填充时发现 A 需要 B,于是去创建 B;B 又需要 A,此时 B 从三级缓存里拿到 A 的ObjectFactory,调用getObject()得到一个提前暴露的 A 实例,放进二级缓存,同时把三级缓存里的工厂移除。B 用这个半成品完成自己的创建,最终返回给 A,A 再把属性 B 填上,A 也走向完整。整个流程里,二级缓存的存在让提前公开的引用可以复用,而三级缓存里的工厂,是为了在必要时生成代理对象。
源码里最核心的getSingleton逻辑大概长这样:
protected Object getSingleton(String beanName, boolean allowEarlyReference) { Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null && isSingletonCurrentlyInCreation(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; }看到这个结构你就明白了:先从成品缓存查,查不到再看是否正在创建中;二级缓存查不到,才动用三级缓存里的工厂生成早期引用。
3.3 构造器循环依赖和原型循环依赖为什么救不了
三级缓存不是万能的。构造器循环依赖就处理不了。因为构造器注入发生在 Bean 实例化之前,没有对象可供提前暴露。A 的构造器需要 B,B 的构造器需要 A,两边都还没创建出半成品,自然无从解套。解决办法一般是改用 Setter 或@Lazy打破一环,或者在设计上避免这种结构。
原型作用域的循环依赖同样处理不了。原型 Bean 每次获取都创建新实例,Spring 根本不会把没有创建完的原型 Bean 放入任何缓存,因此无法相互感知。你写了A -> B -> A的原型依赖,运行时会直接抛出BeanCurrentlyInCreationException。
还有一个典型误区:Spring Boot 2.6 开始默认禁止循环依赖。实话讲,Spring 官方并不鼓励你把循环依赖当作正常设计,因为它往往意味着职责没有拆干净。如果你确实需要兼容旧的循环依赖写法,可以配置spring.main.allow-circular-references=true,但更好的是重构依赖关系,让调用方向保持单向下沉。
4. 手写一个迷你 IoC:200 行代码理解容器
4.1 思路与类结构设计
理论说多了容易飘,我建议你亲手写一个迷你 IoC。写完之后再回头看 Spring,你会觉得大多数源码都在你的射程范围之内。这个小项目的目标很简单:支持包扫描、支持@MyComponent注册 Bean、支持@MyAutowired字段注入、支持单例 Bean 缓存。
核心类就三个:
MyApplicationContext:容器入口,负责扫描、创建、缓存。ClassPathScanner:扫描指定包下所有类。- 两个自定义注解:
@MyComponent和@MyAutowired。
我先定义注解:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface MyComponent { String name() default ""; }@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface MyAutowired { }然后写容器核心。这里面最考验人的是“扫描指定包下所有类”,原理是ClassLoader读取资源目录,再递归遍历文件名,最后用Class.forName加载。
public class ClassPathScanner { public Set<Class<?>> scan(String basePackage) throws Exception { Set<Class<?>> classes = new HashSet<>(); String path = basePackage.replace('.', '/'); Enumeration<URL> resources = Thread.currentThread() .getContextClassLoader().getResources(path); while (resources.hasMoreElements()) { URL url = resources.nextElement(); File dir = new File(url.toURI()); scanDir(dir, basePackage, classes); } return classes; } private void scanDir(File dir, String packageName, Set<Class<?>> classes) throws Exception { File[] files = dir.listFiles(); if (files == null) return; for (File file : files) { if (file.isDirectory()) { scanDir(file, packageName + "." + file.getName(), classes); } else if (file.getName().endsWith(".class")) { String className = packageName + "." + file.getName() .substring(0, file.getName().length() - 6); classes.add(Class.forName(className)); } } } }4.2 核心容器实现
MyApplicationContext内部维护两个 Map,一个放BeanDefinition信息,一个放单例成品。扫描完成后,对所有标注了@MyComponent的类做注册,然后在创建阶段做字段注入。
public class MyApplicationContext { private Map<String, Class<?>> beanDefinitions = new HashMap<>(); private Map<String, Object> singletonObjects = new HashMap<>(); public MyApplicationContext(String basePackage) throws Exception { new ClassPathScanner().scan(basePackage).forEach(clazz -> { if (clazz.isAnnotationPresent(MyComponent.class)) { String beanName = decapitalize(clazz.getSimpleName()); beanDefinitions.put(beanName, clazz); } }); } public Object getBean(String beanName) throws Exception { if (singletonObjects.containsKey(beanName)) { return singletonObjects.get(beanName); } // 缓存里没有,则新建 Object instance = createBean(beanName); singletonObjects.put(beanName, instance); return instance; } private Object createBean(String beanName) throws Exception { Class<?> clazz = beanDefinitions.get(beanName); if (clazz == null) { throw new RuntimeException("Bean not found: " + beanName); } Object instance = clazz.getDeclaredConstructor().newInstance(); injectFields(instance, clazz); return instance; } private void injectFields(Object instance, Class<?> clazz) throws Exception { for (Field field : clazz.getDeclaredFields()) { if (field.isAnnotationPresent(MyAutowired.class)) { field.setAccessible(true); // 按属性类型匹配一种简单的实现 Class<?> fieldType = field.getType(); Object dependency = singletonObjects.values().stream() .filter(fieldType::isInstance) .findFirst() .orElse(null); if (dependency == null) { String depName = decapitalize(fieldType.getSimpleName()); dependency = getBean(depName); } field.set(instance, dependency); } } } private String decapitalize(String name) { return Character.toLowerCase(name.charAt(0)) + name.substring(1); } }这里我刻意没有加入循环依赖处理和BeanPostProcessor,因为那一层一旦加进来,代码量会膨胀三倍。但这个版本已经足够演示 IoC 的本质:容器统一注册、统一创建、统一注入。
4.3 测试效果:自己写一个服务验证
我写两个类来测试,一个UserService,一个OrderService,让OrderService依赖UserService。
@MyComponent public class UserService { public String getUser() { return "itwanger"; } }@MyComponent public class OrderService { @MyAutowired private UserService userService; public String createOrder() { return "order for " + userService.getUser(); } }入口测试:
public class Main { public static void main(String[] args) throws Exception { MyApplicationContext context = new MyApplicationContext("com.demo"); OrderService orderService = (OrderService) context.getBean("orderService"); System.out.println(orderService.createOrder()); } }运行结果会输出order for itwanger。这时候你回头理解 Spring,脑子里就会自动映射出几个概念:beanDefinitions对应BeanDefinitionRegistry,singletonObjects对应一级缓存,injectFields对应属性填充,new ClassPathScanner().scan()对应包扫描。原理再复杂,内核也就是这条路。
注意:真正的 Spring 要处理循环引用、作用域、代理、生命周期钩子、条件装配、BeanPostProcessor 等一大堆扩展点。手写迷你容器只用于建立心智模型,不要在真实项目里重复造轮子。
5. 实战踩坑与排查技巧实录
5.1 字段注入导致 null 的典型场景
很多新手都会遇到一个诡异问题:@Autowired注入的字段明明是有的,但运行时就是null。最常见原因有三个。
第一,这个 Bean 不是由 Spring 管理的。你可能在普通new出来的对象里注入了@Autowired,Spring 看不到它,自然不负责注入。Spring 容器只对自身管理的 Bean 做依赖注入。
第二,静态字段注入失败。@Autowired不能直接注静态字段,Spring 会注入成功但为 null,因为静态变量不属于任何实例。要注入静态依赖,得通过 Setter 或@PostConstruct里手动赋值。
第三,类没有被扫描到。默认扫描范围是从启动类所在包开始的,如果你的某个类放在别的包目录,却没有额外指定@ComponentScan,容器就不会把它注册成 Bean。
排查这个问题的思路我也分享一下:先看BeanFactory里有没有这个 Bean,再看创建顺序和实例类型,最后确认注入点本身是不是容器管理的类。用 IDE 的 Debug 观察 Spring 容器对象里的singletonObjects,是最快的方式。
5.2 @Configuration 里 @Bean 方法执行两次
这个坑在面试题里经常出现。假如你在@Configuration类里定义了一个@Bean对象,然后又在这个方法内部手动调用了另一个@Bean方法,为什么最终结果是同一个实例?因为@Configuration注解会让类被 CGLIB 代理,Spring 在代理逻辑里去缓存查询,同一个 Bean 不会重复创建。
但如果把@Configuration换成@Component,同一个@Bean方法被调用两次,结果就是两个不同实例。源码里就体现在ConfigurationClassPostProcessor是否增强了配置类。所以设计上要让人一眼看懂依赖,我建议直接通过参数注入来获取依赖,不要依赖方法内部二次调用。
5.3 循环依赖触发却报 BeanCurrentlyInCreationException
有时候你明明读写过三级缓存相关文章,但项目还是抛了BeanCurrentlyInCreationException。这时候先别急,递归分析一下报错日志里的Singleton bean currently in creation。大概率是下面四种情况:
- 构造器循环依赖,不在三级缓存处理能力之内。
- 循环依赖链路里有一个 Bean 是原型作用域。
@Async注解触发了代理提前生成,和循环依赖叠加后踩到 Spring 的校验逻辑。- Spring Boot 2.6 及更高版本默认禁止循环依赖,需要在配置里显式允许。
我一次次遇到的是@Async+ 循环依赖的组合。服务 A 调用服务 B,B 又回到 A,且其中一个方法标了@Async。这种情况即使单例 Bean,也会因为代理机制变得很棘手。建议把@Async拆到独立接口或者新增中间层,不要让异步方法和循环依赖挤在同一个对象里。
5.4 高频问题速查表
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 注入的字段是 null | 对象不是容器管理的,或静态字段注入,或扫描范围不对 | 交由 Spring 管理,避免静态字段注入,调整包扫描 |
| 同一 @Bean 被调用两次得到不同对象 | 配置类没有被 CGLIB 增强 | 使用 @Configuration 而非 @Component |
| BeanCurrentlyInCreationException | 构造器循环依赖 / 原型 Bean 循环依赖 / 默认禁止循环依赖 | 改用 Setter 注入、加 @Lazy、开启 allow-circular-references |
| @PostConstruct 里注入的对象还未初始化 | 初始化顺序判断错误 | 确认下游 Bean 的初始化顺序,必要时用 @DependsOn 显式指定 |
| 单例 Bean 内状态被多线程共享 | 单例天然只实例化一次 | 把可变状态抽到方法局部变量,使用无状态 Bean |
6. 面试高频问答与学习建议
6.1 面试官最爱问的十个问题
我把历年面试中围绕 IoC 的高频问题整理成一个清单,每个附上极简答题思路,你可以对着自查:
- 什么是 IoC?控制反转是一种设计原则,将对象创建和依赖绑定交给容器管理;DI 是它的实现方式。
- BeanFactory 和 ApplicationContext 的区别?一个懒加载一个预加载;后者支持事件、国际化、资源加载,功能更丰富。
- Spring 如何解决循环依赖?单例 Bean 场景下,三级缓存配合提前暴露,构造器注入和原型作用域的循环依赖不支持。
- 为什么是三级缓存而不是二级?兼顾“提前暴露引用”和“必要时生成代理”,延迟创建代理,保证最终注入的是完整对象。
- Bean 生命周期中有什么钩子?
@PostConstruct、InitializingBean、initMethod、BeanPostProcessor、@PreDestroy、DisposableBean、destroyMethod。 - @Autowired 和构造器注入选哪个?优先构造器注入,保证不可变和依赖完整;字段注入适合快速原型但可读性和可测性较差。
- Bean 作用域有哪些?singleton、prototype、request、session、application、websocket。
- 单例 Bean 线程安全吗?单例本身不保证线程安全,要避免共享可变状态;无状态 Bean 天然安全。
- Configuration 和 Component 有什么区别?Configuration 会被 CGLIB 增强,单例语义更强;Component 不增强。
- 如何手写一个 IoC?包扫描、BeanDefinition 注册、反射实例化、依赖注入、单例缓存,这就是最小闭环。
回答这些问题时,别只背概念,尽量加一句“我之前在项目里遇到 xxx,就是因为 xxx 机制”,这样面试官会觉得你是真用过,而不是只会背八股。
6.2 我给学习者的三个实操建议
如果你现在正处于“看懂了但记不住”的阶段,我给你的建议很朴素,但都是我验证过有效的方法。
第一,把 Spring 的源码当目录来查,而不是当书来读。遇到一个问题就直接定位对应类。想了解 Bean 创建,看AbstractAutowireCapableBeanFactory;想了解循环依赖,看DefaultSingletonBeanRegistry;想了解配置类增强,看ConfigurationClassPostProcessor。带着问题看源码,效率远高于从头读到尾。
第二,动手做一遍手写 IoC。不用做得像 Spring 那么完整,像我上面那样能扫描、能注入、能缓存,就足够加深理解。写完你自然能回答“为什么容器需要 BeanDefinition”“为什么缓存 Map 要放在这里”这类问题。
第三,做一次“从 Spring Boot 项目启动到接口调用”的演示。在一个空 Spring Boot 项目里,手动打断点观察容器启动时单例 Bean 的创建过程,再观察BeanPostProcessor被哪些内部组件调用。当年我就是靠这套连点成线的方法,把原本割裂的知识点串起来的。
我个人从这些实操里最大的体会是:IoC 不是“背出来”的知识,而是“用出来”的经验。当你有一天能不看文档,自己解释清楚容器注入的完整链路,你就不会再恐惧 Spring 相关的任何面试题了。