中午十二点,小饭馆迎来第一批客人。
顾客翻开菜单,看见"番茄炒蛋盖饭,18元"。他点了一份。几分钟后,后厨端出一盘热气腾腾的盖饭。顾客吃到了菜,而不是吃到了菜单。
这两个东西一个是真实存在、可以吃下去的食物,另一个是描述食物信息的纸张。食物由厨房生产,菜单只是说明。顾客最终得到的是食物,但餐厅准备阶段挂在墙上、递到手里的,往往是菜单。
Spring 容器也有类似的两面。
我们经常说"把对象交给 Spring 管理"。这句话很容易让人以为,容器一旦启动,所有对象都已经创建好并整齐地放在那里。但如果你在调试时打开一个刚注册完毕、尚未刷新的容器,会发现里面并没有那么多实例。容器确实管理着东西,只是那东西更像一份份说明书,而不是一件件成品。
这一章不急着背源码。我们先从一个很小的实验入手,逐步看清楚:BeanDefinition 是什么时候出现的,Bean 实例又是什么时候出现的,二者之间隔着怎样一条流水线。
6.1 你拿到的是对象,容器登记的却是说明书
很多初学者第一次调试 Spring 应用时,会在 DefaultListableBeanFactory 里看到 beanDefinitionMap 这个字段。里面有 menuController、menuService、orderService 等一堆名字。大家很容易产生一个直观印象:容器里已经放好了这些对象,随时可以取用。
但事实并不完全如此。
beanDefinitionMap 里存放的,是 BeanDefinition,不是直接可以调用的业务对象。BeanDefinition 描述了一个 Bean 的类型、作用域、构造方式、初始化方法、销毁方法、是否懒加载等信息。它回答了"如果将来要制造一个 Bean,应该按什么规格制造",但没有直接回答"这个 Bean 现在在哪里"。
在既定的 IocLabTest 中,有一个真实实验叫 definitionAndLazyInstanceAreDifferentStages。它做了这样几件事:
java
try (var context = new AnnotationConfigApplicationContext()) {
context.registerBean("ticket", Object.class, () -> {
creations.incrementAndGet();
return new Object();
}, definition -> definition.setLazyInit(true));
assertThat(context.containsBeanDefinition("ticket")).isTrue();
assertThat(context.getBeanFactory().containsSingleton("ticket")).isFalse();
context.refresh();
assertThat(creations).hasValue(0);
Object first = context.getBean("ticket");
assertThat(context.getBean("ticket")).isSameAs(first);
assertThat(creations).hasValue(1);
}
这一步非常关键。调用 registerBean 之后、refresh 之前,containsBeanDefinition("ticket") 是 true,而 getBeanFactory().containsSingleton("ticket") 是 false。这说明登记定义和创建实例是两个独立阶段。前者只写入说明书,后者才制造实物。
registerBean 里传入的那个 lambda,是创建实例时才会被调用的工厂逻辑,不是注册时立刻执行。你可以把它理解成:后厨把"番茄炒蛋盖饭的标准做法"贴在墙上,不等于现在有十盘现成的菜摆在出餐口。菜单写好了,锅还没烧热。
这里还要纠正一个常见的断点误区。Kitchen 接口只声明了 String cook();,它自身没有可执行方法体。如果你把断点打在接口的 cook() 声明行上,运行到相应位置并不会在接口处停下来------真正执行的是实现类里的方法体。接口是一份行为约定,和"说明书"是类似的东西;你想要观察实物,就要到实物的方法里去找。
所以,第 6.1 节的结论可以压缩成一句话:注册BeanDefinition不等于创建实例;实例可能在刷新时预先创建,也可能在后续按需获取时才创建。不要看到 beanDefinitionMap 里有名字,就以为对象已经全部制造完毕。
6.2 lazy 不是启动时把菜全炒好,而是客人点单后才下锅
继续看刚才的实验。在 registerBean 时,第三个 lambda 供应商内部有一个 creations.incrementAndGet()。这个计数器可以帮我们确认实例到底是在何时创建的。
实验里设置了 definition.setLazyInit(true)。然后调用 context.refresh(),再检查 creations 的值:
java
assertThat(creations).hasValue(0);
这说明即使容器刷新完成,懒加载的 Bean 也还没有被创建。接下来第一次调用 context.getBean("ticket") 后,creations 变成 1。第二次调用 getBean("ticket"),得到的是同一个实例,creations 仍然是 1。
这就像餐厅开门营业。厨师不会在开门前把所有菜品全都炒一遍------那样既浪费食材,也会让很多菜变凉。顾客点了"番茄炒蛋盖饭",后厨才开火;第二桌再点同一道菜,得到的是同一份被缓存好的单例菜品吗?这个类比到这里就不能继续硬套了,因为食物做出一份只会被一位顾客吃掉,而 Spring 的单例 Bean 是可以被多个调用方共享的同一个对象。类比的用途是说明"延迟创建"和"首次触发",而不是把 Spring 容器等同于餐厅后厨。
这里必须澄清一个常见误解:懒加载的 Bean 不是"不在启动时创建",而是"把创建推迟到第一次真正获取时才发生"。 Spring 容器本身不会在启动时因为 lazy 就把所有 Bean 都强行创建一遍,也不存在"所有 Bean 都在启动主线程创建"的说法。普通单例 Bean 通常会在 refresh 阶段被预先实例化,但懒加载 Bean 会等到第一次 getBean 或第一次被注入时再创建。实际发生创建的线程,取决于是谁在调用。
再补充一个容易混淆的点:lazy 只影响创建时机,不影响单例语义。第一次 getBean 会创建并放入单例缓存;之后再次 getBean 不会重新创建,而是直接返回缓存中的同一个实例。单例是"每容器、每定义"产生一个实例,不是每次获取都新建一个。这里"每容器"意味着,如果你再新开一个 AnnotationConfigApplicationContext,它有自己的 beanDefinitionMap 和单例缓存,上一个容器里的实例不会自动跑到新容器里来。
所以,第 6.2 节要记住:lazy 改变的是"什么时候创建",不改变"单例只创建一次"。 顾客什么时候点单,厨师什么时候开火;菜单早就写好了,但菜不会在开门前全部炒好。
6.3 一个没有 Spring 的最小场景:Kitchen 与 Counter
在进入 Spring 的机制之前,先看一个完全不用 Spring 的实验------manualInjectionNeedsNoSpring:
java
Counter counter = new Counter(() -> "测试盖饭");
assertThat(counter.order()).isEqualTo("测试盖饭");
这个实验里出现了 Kitchen 接口和 Counter 这个 record:
java
interface Kitchen { String cook(); }
record Counter(Kitchen kitchen) { String order() { return kitchen.cook(); } }
Counter 的构造器接收一个 Kitchen。调用 order() 时,Counter 不自己做饭,而是把请求转交给 kitchen.cook()。这里没有任何注解,没有容器,没有配置文件。它证明了依赖注入的核心思想:对象自己不负责创建它所依赖的对象,而是由外部把依赖交给它。
Counter 的构造器接收 Kitchen,这属于构造器注入。在 Spring 中,构造器注入发生在实例化阶段。所谓实例化阶段,就是通过构造器创建 bean 实例的那一步。此时 Spring 必须先确定 Kitchen 的具体实现,才能完成 Counter 的构造。如果 Kitchen 有多个候选,Spring 需要消歧;如果找不到候选,会报错。这些规则会在 6.6 节展开。
回到生活场景。顾客点了一份"香菇鸡肉饭",他不需要认识负责香菇鸡肉饭的厨师是谁。顾客只关心做出来的食物是否符合要求。具体让哪位厨师负责,由餐厅的排班系统决定。服务员把顾客的需求告诉后厨,后厨安排合适的人做菜,再把菜递给服务员。Spring 的角色更接近背后的排班与调度系统:它根据声明找出合适的对象,完成组合。
Kitchen 接口在这里是一份"角色契约",Counter 声明"我需要一个能做饭的 Kitchen"。这个声明不包含"我自己去招聘一位厨师"的动作。Spring 容器读到 Counter 的构造器,知道创建 Counter 之前必须先找到 Kitchen 的实例,这就是依赖注入在实例化阶段的直接体现。
因此,第 6.3 节的核心结论:构造器注入不是获取阶段的补救动作,而是在实例化阶段就必须完成的组装决策。 没有 Spring,这种组装可以由手写代码完成------但手写代码一旦对象变多,关联变复杂,就会陷入维护陷阱。
6.4 正常实验与反例:注册、懒加载、单例、prototype
现在把前面几个实验放在一起,做一次完整的预测-验证。
正常实验: definitionAndLazyInstanceAreDifferentStages。
先预测:在 registerBean 之后、refresh 之前,容器里只有定义,没有单例实例;refresh 之后因为设置了 lazyInit(true),计数器仍然为 0;第一次 getBean("ticket") 时,计数器变为 1;第二次 getBean("ticket") 返回同一个实例,计数器仍为 1。
然后看实验证据:
java
assertThat(context.containsBeanDefinition("ticket")).isTrue();
assertThat(context.getBeanFactory().containsSingleton("ticket")).isFalse();
context.refresh();
assertThat(creations).hasValue(0);
Object first = context.getBean("ticket");
assertThat(context.getBean("ticket")).isSameAs(first);
assertThat(creations).hasValue(1);
事实完全吻合。这个实验把"注册"和"创建"彻底拆开:registerBean 写说明书,refresh 启动容器,getBean 触发创建并缓存。
破坏条件的反例: prototypeCreatesEachTimeAndIsNotAutomaticallyDestroyed。
预测:如果 Bean 的作用域是 prototype,那么每次 getBean 都会返回新对象;容器关闭时,不会再自动调用这个原型对象的销毁回调。
实验验证:
java
context.registerBean("prototype", DisposableBean.class, () -> destroyed::incrementAndGet,
definition -> definition.setScope("prototype"));
context.refresh();
assertThat(context.getBean("prototype")).isNotSameAs(context.getBean("prototype"));
// finally 中关闭容器
assertThat(destroyed).hasValue(0);
两次 getBean 得到不同实例,destroyed 仍为 0。这说明容器记录了对单例的销毁责任,却不记录对原型 Bean 的销毁责任。原型 Bean 拿到手之后,生命周期就交给使用方了。
下面用一张 ASCII 图梳理从注册到获取的关键路径:
registerBean
│
▼
beanDefinitionMap ← 只放 BeanDefinition(说明书)
│
▼
context.refresh()
│
├── 普通单例:可能在这里预实例化
└── lazy 单例:本例没有提前依赖,不在这里创建
│
▼
getBean(name)
│
▼
单例缓存命中? ──是──→ 返回已有实例
│否
▼
createBean(name, mbd, args)
│
▼
实例化(构造/工厂)
│
▼
属性填充 + 初始化
│
▼
放入单例缓存,返回
这张图的核心是:getBean 不是每次都会走到 createBean。 单例第一次创建后进入缓存,后续获取直接命中缓存。prototype 则绕开缓存,每次都创建。
断点观察表如下:
| 问题 | 断点位置 | 观察变量/逻辑对象 | 预期证据 |
|---|---|---|---|
| 注册阶段是否创建实例 | DefaultListableBeanFactory.registerBeanDefinition 方法内 |
beanDefinitionMap |
定义已加入,但单例缓存无该对象 |
| lazy是否延后创建 | AbstractBeanFactory.doGetBean首次缓存查询后及创建分支 |
sharedInstance与返回对象 | 本例刷新期间不主动获取ticket;首次获取时缓存未命中,第二次命中已有实例 |
| prototype 是否每次创建 | AbstractBeanFactory.doGetBean 中 prototype 分支 |
返回对象 | 两次调用返回不同对象 |
| 关闭时是否销毁原型 | AbstractBeanFactory.registerDisposableBeanIfNecessary 方法内 |
mbd.isPrototype() |
条件为 true 时不注册销毁回调 |
表格里的方法名和分支都是真实存在的位置。局部变量如果无法确定具体名字,就观察方法返回值或分支判断结果,不要自己去猜一个不存在的变量名。
6.5 源码入口:AbstractBeanFactory.doGetBean 的三段路
现在打开 AbstractBeanFactory 的 doGetBean 方法。它由 getBean 最终调起,是理解"注册与创建分离"的骨架入口。下面用教学顺序描述,不是逐行照搬源码:
第一步,规范化名字,并检查单例缓存。doGetBean 先调用 transformedBeanName(name) 得到标准 beanName,然后调用 getSingleton(beanName)。如果缓存命中,就进入 getObjectForBeanInstance 处理 FactoryBean 的特殊情况,然后返回。这一步说明 getBean 不会绕过容器缓存------只要单例已经存在,就不再走完整的创建流程。
第二步,如果缓存未命中,检查 prototype 循环引用。如果当前 Bean 是 prototype 且已经在创建过程中,抛出 BeanCurrentlyInCreationException。接着查找父 BeanFactory。如果当前工厂没有这个定义而父工厂有,就委托给父工厂处理。再之后获取合并后的 RootBeanDefinition,并保证 dependsOn 声明的依赖先被创建。
第三步,按作用域分支。若 mbd.isSingleton() 为真,则进入 getSingleton(beanName, () -> createBean(...))。这里的 createBean 负责实例化、填充属性、执行初始化。若 mbd.isPrototype() 为真,则先 beforePrototypeCreation,再 createBean,最后 afterPrototypeCreation。二者共享 createBean,但单例创建后会被缓存,prototype 不会。
这里要特别强调一个纠错点:单例 Bean 创建后仍要经过完整的初始化生命周期。 不是说第一次 getBean 因为缓存未命中而创建了对象,就可以绕过 InitializingBean 或自定义初始化方法。createBean 内部会统一执行这些回调,之后才返回实例。getBean 只是入口,它不替代生命周期管理。
createBean 内部还会执行构造器实例化。构造器注入发生在实例化阶段,而不是初始化之后。也就是说,容器要先解决构造器参数里的依赖,创建出对象;随后才进行属性自动装配、afterPropertiesSet 等步骤。这个先后顺序在后面对接构造函数循环依赖问题时非常重要。
如果观察者想在调试中验证上述过程,可以把断点放在 doGetBean 中获取缓存后的 else 分支入口。此时你会看到 sharedInstance 为 null,说明这是第一次创建。单步走到 createBean 调用处,能看到传入的 beanName、mbd 和 args。createBean 返回后再观察,对象已进入 getObjectForBeanInstance 流程。
6.6 关键机制:FactoryBean 的 & 区分、原型销毁边界与候选消歧
这一节把剩下几个关键机制放在一起讲,因为它们都发生在"获取"阶段,并且各有各的边界。
FactoryBean 产品与工厂本身的区分。
factoryBeanNameAndAmpersandReferToDifferentObjects 实验注册了一个 KitchenFactory,它实现了 FactoryBean<Kitchen>:
java
static class KitchenFactory implements FactoryBean<Kitchen> {
public Kitchen getObject() { return () -> "工厂米饭"; }
public Class<?> getObjectType() { return Kitchen.class; }
}
断言如下:
java
assertThat(context.getBean("kitchenFactory")).isInstanceOf(Kitchen.class);
assertThat(context.getBean("&kitchenFactory")).isInstanceOf(KitchenFactory.class);
getBean("kitchenFactory") 拿到的是产品------一个 Kitchen;getBean("&kitchenFactory") 拿到的是工厂本身------一个 KitchenFactory。& 前缀让容器不再把工厂当成产品生产器,而是把工厂本身交给调用方。
在 AbstractBeanFactory.getObjectForBeanInstance 中,先检查名字是否带 & 前缀。如果带,且当前对象是 FactoryBean,就直接返回对象本身;如果不带,且对象是 FactoryBean,则调用 getObjectFromFactoryBean 获取产品。这个方法的一个重要责任是:把名字里的 & 语义和对象本身是否为 FactoryBean 结合起来决策 。带 & 时不会去提取产品,不带 & 时不会把工厂露出去。输入是实例、名字和元数据;输出要么是工厂,要么是产品。
原型销毁边界。
6.4 的反例已经展示:prototype 创建后,容器不会自动调用其销毁回调。在 AbstractBeanFactory.registerDisposableBeanIfNecessary 中有这样的分支:如果 mbd.isPrototype() 为真,则不会进入注册销毁回掉的单例分支。容器的职责边界很清晰:单例它负责到底,prototype 只负责创建和初始化,不负责最终销毁。
多个候选未必报错,消歧规则会依次检查。
primaryQualifierCollectionAndOptionalHaveDifferentRoles 实验中同时注册了 rice 和 noodle 两个 Kitchen,但 rice 被设为 primary。Choice 的构造器里,preferred 注入 rice,qualified 通过 @Qualifier("noodle") 注入 noodle,List<Kitchen> 收集两个,Optional<Runnable> 因为不存在而为空。结果四个注入点各得其所,并不报错。
真正报错的是 ambiguityFailsWithoutASelectionRule:两个 Kitchen 都没有 primary,注入点也不是集合或 Optional,同时没有@Qualifier或名称等有效选择规则。Optional只容忍没有候选,不会把仍有歧义的多个候选自动变为空。qualifier先参与候选资格筛选;候选仍有多个时,determineAutowireCandidate检查primary、依赖名称或建议名称、最高priority、唯一默认候选及直接注册依赖等规则,不能把qualifier仅当成primary之后才开始的过滤步骤。如果所有规则都无法消歧,才抛出异常。
所以不是"有多个候选就一定报错",而是"多个候选且没有有效消歧规则才报错"。现有实验覆盖primary、qualifier、集合、缺失时Optional及无消歧失败;并未逐一验证priority、默认候选等所有分支,后续阅读要区分源码规则与测试范围。这里的自动配置只是普通 Bean 定义参与候选选择,真正自动配置的装配策略在第 12 章;配置来源的优先级在第 13 章;代理相关在 21 与 22 章;事务切面的行为在 24 与 25 章。
6.7 生命周期回调的顺序:初始化不是创建的全部
前面讲过,getBean 不会绕过容器缓存或生命周期。现在借助 lifecycleCallbacksSurroundInitializationAndClose 实验,把"创建"这件事拆得更细。该实验注册了一个 Tracked 类,实现 InitializingBean 和 DisposableBean,并在容器里插入一个 BeanPostProcessor。Tracked 的构造器、afterPropertiesSet、destroy 以及后处理器的前后回调都会向同一个列表写入标记。
测试断言如下:
java
assertThat(trace).containsExactly("construct", "before-init", "init", "after-init");
// 关闭后追加 "destroy"
这说明一次完整的单例 Bean 生命周期至少包括:构造实例、postProcessBeforeInitialization、afterPropertiesSet、postProcessAfterInitialization。这些步骤发生在 createBean 内部,而不是在 getBean 入口完成。很多人以为"初始化"等价于"构造器执行",其实构造器属于实例化阶段,afterPropertiesSet 属于初始化阶段。在两者之间,BeanPostProcessor 的 before 回调插入进来;在 afterPropertiesSet 前后分别执行 before 与 after 回调。如果你把断点打在 Kitchen 接口的方法上,不会有任何停顿,因为接口没有方法体,真正的可执行代码在实现类或工厂 lambda 里。
这里有一个重要分支:InitializingBean 是 Spring 提供的一个接口,afterPropertiesSet 会在所有属性注入完成后执行。这意味着如果 Bean 需要依赖其他 Bean 才能进行内部初始化,放在这里比放在构造器里更安全。但如果你希望控制在 XML 或注解中声明初始化方法,Spring 也会支持,只是本章不展开。
DisposableBean.destroy 则在容器关闭时被调用。对于单例,容器会注册销毁回调;对于 prototype,容器不会注册,这部分前面已经说过。生命周期回调的观察点,是理解"容器创建对象"并非瞬间动作的好窗口。
6.8 反例与失败推演:循环依赖怎么把容器卡住
IocLabTest 提供了三个与循环依赖相关的实验,正好用来推演失败边界。
首先,constructorCycleCannotBeSolvedByEarlyReferences 注册了两个互相通过构造器注入的类型 ConstructorA 和 ConstructorB,断言 context.refresh() 抛出 BeansException。原因在于构造器注入发生在实例化阶段:要创建 ConstructorA 必须先有 ConstructorB,而创建 ConstructorB 又必须先有 ConstructorA。此时两个对象都还没有完成实例化,没有"早期引用"可以暴露,Spring 无法打破僵局,只能抛出异常。
其次,plainFrameworkCanExposeEarlyReferencesForSingletonFieldCycle 注册了字段注入的 FieldA 和 FieldB,断言 a.b.a 与 a 是同一对象。字段循环依赖能解决,是因为对象可以先通过默认构造器完成实例化,然后把尚未完全初始化的引用提前放入缓存,供另一个对象注入。这个实验验证了允许循环引用时的单例字段环;类似的实例化后属性或setter注入环也可能利用早期引用,但本例两端都要求先取得对方的构造器环无法这样解决------这正是"构造器注入在实例化阶段"的直接体现。实例化尚未完成,就无法提供引用。
第三,disallowingCircularReferencesRejectsTheSameFieldCycle 在同一个字段循环场景下调用 setAllowCircularReferences(false),预期 refresh() 抛出 BeansException。这个实验证明,早期引用机制是一个可开关的行为。关闭后,字段循环也不再被允许。在大型工程里,循环依赖往往意味着职责划分模糊,关闭这个开关更有利于暴露问题。
此外,还可以做读者自行新增的变式推演:两个 prototype Bean 相互依赖。由于 prototype 不会缓存早期引用,且在创建过程中会设置"prototype currently in creation"标记,第二个 Bean 在创建时发现第一个还在创建中,就会抛出 BeanCurrentlyInCreationException。这没有出现在既定测试里,所以只能说"教学推演",不能宣称已有实验覆盖。
这几个反例的共同点是:容器不会无限递归创建,它要么利用缓存和早期引用打破循环,要么在无法打破时给出明确异常。理解失败点,比单纯知道"能注入"更接近源码阅读的目的。
6.9 断点证据表:从注册到销毁的观察点
下面把本章涉及的观察点集中成一张表。断点位置使用真实存在的方法名,局部变量如果不确定具体名字,就写成"返回对象""候选集合等逻辑观察对象"。
| 问题 | 断点位置 | 观察变量/逻辑对象 | 预期证据 |
|---|---|---|---|
| 定义是否已注册而未创建实例 | DefaultListableBeanFactory.registerBeanDefinition 方法内 |
beanDefinitionMap 与 singletonObjects |
beanDefinitionMap 包含名字,singletonObjects 不含 |
| 单例是否由缓存命中 | AbstractBeanFactory.doGetBean 中 getSingleton 返回后 |
sharedInstance |
首次为 null,后续为同一实例 |
| FactoryBean 是否被解析为产品 | AbstractBeanFactory.getObjectForBeanInstance 方法内 |
beanInstance 的类型与 name 是否含 & |
不含 & 且 beanInstance instanceof FactoryBean 时走产品提取 |
| prototype 是否进入单例缓存 | AbstractBeanFactory.doGetBean 中 prototype 分支 |
返回对象与 beforePrototypeCreation 调用 |
每次返回新对象,且无单例缓存写入 |
| 销毁回调是否注册给 prototype | AbstractBeanFactory.registerDisposableBeanIfNecessary 方法内 |
mbd.isPrototype() |
prototype 为 true 时直接跳过注册 |
| 生命周期回调顺序 | AbstractAutowireCapableBeanFactory.initializeBean 方法内 |
返回对象、applyBeanPostProcessorsBeforeInitialization 前后 |
顺序为 before-init、init、after-init |
| 构造器循环是否因早期引用解决 | ConstructorResolver.autowireConstructor 方法内 |
候选构造器参数集合 | 构造器循环直接在实例化阶段失败 |
| 字段循环是否被允许 | AbstractAutowireCapableBeanFactory.doCreateBean的earlySingletonExposure判断,再跟入DefaultSingletonBeanRegistry.getSingleton |
allowCircularReferences开关与早期引用获取 | 开关参与是否注册早期引用工厂;缓存读取方法的布尔参数是allowEarlyReference,不是同一个字段 |
注意:不要把这些变量名当作官方文档中的精确符号,某些局部变量名是从教学观察角度取的"逻辑观察对象",实际调试时以当前版本源码为准。断点打在接口方法声明上不会停住,验证接口行为要到实现类的方法体。
6.10 工程边界:单例不是全局唯一,原型不是容器不参与
前文提过"单例是每容器每定义"。这里再强调三个工程边界。
第一,单例不是 JVM 全局唯一。 同一个应用启动两个 AnnotationConfigApplicationContext,每个容器都可以有自己的 kitchen 单例。definitionAndLazyInstanceAreDifferentStages 使用的 try-with-resources 每次创建新容器,容器关闭后单例缓存清空。所谓单例,只是"在一个 BeanFactory 中,根据同一个 BeanDefinition 只创建一个实例"。跨容器比较时,两个实例可以同时存在。
第二,prototype 不代表容器完全不参与。 容器负责创建、执行注入、初始化回调、应用后处理器,只是不缓存实例,也不负责销毁。prototypeCreatesEachTimeAndIsNotAutomaticallyDestroyed 中,每次 getBean("prototype") 都经过完整创建流程,容器做了大量工作。把 prototype 理解成"容器丢给你一个对象不管了"是错误的。
第三,Bean 创建时机不固定在启动主线程。 definitionAndLazyInstanceAreDifferentStages 的 creations 计数显示,懒加载 Bean 在第一次 getBean 时创建。如果第一次 getBean 发生在别的线程,创建动作就会在那个线程执行。普通单例虽然通常在 refresh 阶段由容器主线程预实例化,但这不是绝对规则,普通依赖先后顺序本身并不会让非懒加载单例自动延到refresh结束以后;还应区分懒加载、作用域和显式后台初始化等配置。所以不能说"所有 Bean 都在启动主线程创建"。
这三点也解释了源码中 getBean 与 createBean 分离的意义:getBean 是获取统一入口,负责缓存检查、作用域分支、父工厂委托等;createBean 是真正制造实例的地方。容器把"要不要创建"和"怎么创建"分开,才能在不同作用域、不同时机下灵活安排。
6.11 迁移练习与参考答案
练习一:把 ticket 改为 prototype 后,预测 creations 计数和销毁行为。
在 definitionAndLazyInstanceAreDifferentStages 中,如果把 definition.setLazyInit(true) 改为 definition.setScope("prototype"),并保留 lazy 设置,预测:refresh() 后 creations 仍为 0;第一次 getBean("ticket") 后为 1;第二次 getBean("ticket") 后为 2,且两个对象不是同一个;容器关闭后,两实例的销毁回调(如果注册了)不会被自动调用。
答案分析:prototype 不在启动时预实例化,lazy 对 prototype 无影响,因为 prototype 本来就是按需创建。每次获取都会创建新实例,所以计数递增。容器不缓存 prototype,也不会注册销毁回调,这是 registerDisposableBeanIfNecessary 中 mbd.isPrototype() 的直接结果。注意:lambda 创建的是 Object 而非 DisposableBean,所以本来也没有销毁回调;若要验证销毁边界,需要让实例实现 DisposableBean,但这不是本练习核心。
练习二:两个 Kitchen 候选,去掉 primary 和 @Qualifier,Choice 构造器只注入一个 Kitchen。预测 refresh 会发生什么,并解释为何 List<Kitchen> 注入不会报错。
预测:refresh因构造器依赖无法消歧而失败,外层通常表现为UnsatisfiedDependencyException,原因链包含NoUniqueBeanDefinitionException;测试应检查正确的异常层次。因为 Choice 有一个单值 Kitchen 注入点,而候选有两个,且没有 primary、@Qualifier、@Priority 或名称匹配。Spring 在 determineAutowireCandidate 中依次尝试这些规则,全部失败后调用 resolveNotUnique 抛出异常。但 List<Kitchen> 注入点会被特殊处理:resolveMultipleBeans 识别集合类型,返回全部候选,不会报错。答案为:单值注入需要唯一候选,集合注入允许收集多个候选。
练习三:如果注册 KitchenFactory 两次,一次用名字 kitchenFactory,一次用名字 kitchenFactory2,然后分别 getBean("&kitchenFactory2")。预测 getObject 是否会被调用,单例缓存中会有什么。
预测:getBean("&kitchenFactory2") 返回工厂本身,不会调用 getObject。因为名字带有 & 前缀,getObjectForBeanInstance 直接返回 FactoryBean 实例,跳过了产品提取。单例缓存中会存在工厂实例,但产品对象只有在 getBean("kitchenFactory2") 第一次被调用时才会创建并缓存。如果两个 FactoryBean 定义都指向同一个类,它们是不同的 BeanDefinition,各自创建自己的工厂实例,产品也分别缓存。
以上练习均基于 IocLabTest 中已有类型和方法,变式行为通过推理得出,未在配套测试中单独覆盖的,读者可自行新增测试验证。
6.12 本章收束
回到开篇的问题:Spring 管理的是对象,还是对象的说明书?
答案是:Spring 容器同时管理着两者,但它们的生命周期完全不同。BeanDefinition 是说明书,登记在 beanDefinitionMap;Bean 实例是菜品,来自 createBean。注册定义不等于创建对象,拿到对象不等于绕过生命周期,创建对象也不等于拥有销毁责任。容器的作用是在适当的时机,根据说明书制造出合适的对象,并按照作用域决定缓存、共享或抛弃。
在这个过程中,getBean 是统一入口,它检查缓存、处理 FactoryBean 的 & 语义、按单例或 prototype 分支;createBean 和 initializeBean 完成实例化、注入和初始化;销毁回调只对单例等可控作用域生效。多个候选时,容器通过 primary、@Qualifier、@Priority 等规则消歧,而不是盲目报错。循环依赖能解决与否,取决于注入发生在实例化阶段还是属性填充阶段,以及是否允许暴露早期引用。
这些机制并非为了把简单问题复杂化,而是因为我们面对的真实系统里,对象之间的关系常常比"直接 new"复杂得多。容器用说明书隔离"需要什么"和"具体是什么",用作用域隔离"共享"和"每次新建",用回调隔离"创建"和"使用中"。理解这一层,再看后面的自动配置(第 12 章)、配置来源(第 13 章)、代理(第 21、22 章)和事务(第 24、25 章)时,就能始终站在"对象如何被制造和管理"的基础上,而不是漂浮在注解之上。
顾客最终吃到的是菜,但餐厅能稳定营业,靠的是那套从菜单到后厨再到出餐口的流程。Spring 容器就像餐厅的运营体系:你可以只关心面前那份菜品,但想看懂它为什么好吃、为什么上菜这么快,就必须走进后厨,看一眼墙上的说明书。