先说个真实的场景。我刚毕业那会儿维护一个老项目,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 里面直接创建了 PayService,PayService 又直接创建了 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与BeanDefinition
2.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 BeanDefinition:Spring眼里的Bean长什么样
很多人以为 Spring 容器里存的就是对象,其实真正存的是 BeanDefinition。BeanDefinition 可以理解成 Bean 的“档案”,记录了类名、作用域、是否懒加载、初始化方法、销毁方法、属性值、构造函数参数等元信息。容器拿到这份档案之后,才知道怎么创建这个 Bean、创建几个、何时创建。
为什么要单独搞一层定义?因为 Spring 要延迟决策。看到@Component注解时,Spring 并不立刻 new 对象,而是先生成 BeanDefinition;后面统一根据这些定义批量实例化。这也是为什么你可以用 XML 配置、注解配置、JavaConfig 三种方式混合描述同一个系统——最终都会转换成 BeanDefinition。
如果你调试过 Spring 源码,会发现getBean的流程首先是找对应的 BeanDefinition,再进入创建逻辑。我已不止一次看到有人把 Spring 的 Map 理解成Map<String, 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从诞生到销毁的完整路径
每次面试问 Spring,Bean 生命周期几乎都会被提到。但很多人只知道“实例化-属性填充-初始化”三个词,真到了排查问题时就抓瞎。我把完整链路按我理解的顺序列一下。
容器得到 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 依赖 B,B 又依赖 A,如果对象之间互相 new,那就是死循环。Spring 三级缓存能解决这个问题,但我发现很多人只记住了“三级缓存”四个字,讲不清楚每一级的含义和为什么需要三级。
三级缓存分别是:一级缓存singletonObjects存放完整的单例 Bean;二级缓存earlySingletonObjects存放早期的 Bean 引用,这个 Bean 的属性还没完全填充;三级缓存singletonFactories存放的是 ObjectFactory,也就是一个能产生早期引用的工厂。依赖注入发生时,创建 A 先进入三级缓存;A 填充属性发现需要 B,于是转去创建 B;B 填充属性发现需要 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 Map<String, Class<?>> beanDefinitions = new ConcurrentHashMap<>(); private final Map<String, 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 作用域、代理场景 |
BeanCreationException | Bean 创建过程异常 | 看 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 就不再是一个抽象概念,而是项目里可以随手使用和排查的工具。这套方法我用了很多年,对自己的效率提升确实非常明显。