同一个类注册进 Spring 后,程序还能通过 new 创建它。两次 getBean() 也未必得到同一个对象。决定对象身份的是容器中注册的定义及其 scope,而不是类名上出现过哪个注解。

本篇的问题是:定义、实例和依赖引用分别存在于哪里,怎样证明一个服务持有的对象就是预期的容器对象?实验沿用第 00 篇工程,固定 Framework 6.2.11、JDK 21,通过 == 和构造次数检查身份。它讨论普通 Bean,FactoryBean 产品和代理替换将在后文单独展开。

从类关系到实例引用

OrderService 的构造器声明一个 OrderRepository 和一个 PricePolicy 参数,表达的是类层面的协作要求。一次具体构造才建立实例关系:service.repository 指向哪一个仓储取决于实际传入值。同类仓储可以同时存在,类图无法表达它们属于哪个上下文、已保存哪些订单。

容器中的对象图至少涉及三个层次:定义记录创建信息,实例承载状态,实例字段保存依赖引用。BeanDefinition 存在时可以没有对应实例;实例创建后也不会变成定义。把两者混在一起,会把“类已扫描到”误判为“对象已完成初始化”。容器与配置元数据

定义、单例缓存与实际引用

配置文件或注解提供定义输入;工厂解析定义并创建对象;服务调用沿 Java 引用继续执行。IoC 描述装配控制权的转移,依赖注入是完成引用连接的一种方式。程序也能通过手动组合根注入构造参数,因此 DI 并不要求先使用 Spring。依赖注入

注册发生时实例可以还不存在

为了观察状态,实验使用没有构造器依赖的 Counted:

1
2
3
4
public static class Counted {
static int created;
public Counted() { created++; }
}

计数器用于单线程实验,不是并发指标。在 GenericApplicationContext 中注册一个单例和一个 prototype:

1
2
3
context.registerBean("singleton", Counted.class);
context.registerBean("prototype", Counted.class,
definition -> definition.setScope("prototype"));

registerBean() 的名字容易造成“已取得 Bean”的印象。固定实现中它创建 ClassDerivedBeanDefinition,应用 customizer,随后把定义交给 registerBeanDefinition();这一条注册路径没有调用 Counted 构造器。固定版本注册实现

因此注册后检查应区分 containsBeanDefinition("singleton") 与 containsSingleton("singleton")。前者查询定义是否存在,后者查询单例注册表是否有该名称的实例。不能只看一个 definition 数量,就宣称初始化全部成功。

refresh() 创建普通非延迟单例,使 created 从 0 变为 1;prototype 不在这一步按普通单例方式预实例化。定义中的 scope 决定后续获取分支,第二篇继续比较不同配置来源如何生成同一类元数据。固定版本启动阶段

这里的先后关系属于当前示例条件。工厂后置处理器若提前调用 getBean(),注册与常规启动之间也可能出现实例;定义使用 Supplier 时,其执行时点同样由创建路径决定。注册不等于创建,并不意味着 refresh 前在所有 API 使用方式下都绝对不存在实例。

singleton 的身份条件

刷新后取两次名为 singleton 的 Bean,并与一个手动对象比较:

1
2
3
4
5
var managed = context.getBean("singleton");
check(managed == context.getBean("singleton"),
"01 singleton lookup identity");
check(managed != new Counted(),
"01 new object is outside container");

== 比较两个引用是否指向同一个对象,适合这个实验;equals() 可能被业务类重写,两个值相等的对象仍可以有不同身份。identityHashCode() 可以辅助日志定位,但可能发生碰撞,不能代替引用断言。

Spring singleton 的数量约束针对一个容器中的一个 Bean 定义。它不限制整个 JVM 只能存在一个该类对象,也不拦截任意 new。两个定义即使使用同一个 class,只要 bean name 不同,也可以得到两个普通单例;两个相互独立的上下文具有各自的单例缓存。scope 契约

手动创建的 Counted 没有通过工厂创建过程,也不会因为同类已注册而自动被并入缓存。对普通构造器直接使用 new,获得的就是本次 Java 构造产生的实例。至于增强配置类中调用 @Bean 方法是否被截获,需要额外的配置类条件,第 04 篇给出 full 与 lite 对照。

名称与类型也不能混为一个标识。按类型获取对象首先需要选择候选;同一个 class 注册两次会产生类型歧义,即使按两个名称分别查找都能成功。alias 是名称映射,也不是增加一份定义和一份实例;这些区别会在定义与候选篇验证。

prototype 的获取分支

同一份 Counted 类型的定义改成 prototype 后,两次获取产生不同实例:

1
2
check(context.getBean("prototype") != context.getBean("prototype"),
"01 prototype lookup creates distinct objects");

两次表达式各调用一次 getBean(),本次正常运行各构造一个 Counted。连同一个容器单例和一个手动对象,原身份实验一共创建四个实例:

1
2
3
4
5
CHECK PASS 01 singleton lookup identity
CHECK PASS 01 new object is outside container
CHECK PASS 01 prototype lookup creates distinct objects
CHECK PASS 01 one singleton + manual + two prototype instances
CHAPTER 01 PASS

这段是原始身份实验的输出节选,完整运行另包含定义状态及作用域边界断言。断言计数只覆盖这组独立上下文,不能把整个程序的静态计数器当成容器统计接口。

prototype 意味着向工厂请求时创建实例;它不意味着注入 prototype 的 singleton 在每次业务调用前都自动重新获取依赖。构造器注入只在创建服务时解析一次参数,之后服务字段保存该次结果。若业务需要反复请求新实例,须引入显式的获取边界,例如第 05、06 篇的 ObjectProvider,或者专门的 scope 机制。singleton 对 prototype 的依赖

销毁同样有边界。prototype 在工厂创建时可以经历初始化处理,但容器不会像普通受管理 singleton 一样保存所有产物等待关闭时销毁。使用者需要明确释放资源的责任。第 00 篇的仓储销毁结果不能直接套在任意 prototype 上。

沿 getBean 追踪状态变化

对普通名称调用 getBean(),入口进入 AbstractBeanFactory.doGetBean()。固定版本先用 transformedBeanName() 归一化名称,再查询单例缓存。缓存存在且没有显式创建参数时,使用已存在的实例;否则读取合并定义,处理依赖关系并按 scope 分支。固定版本获取路径

1
2
3
4
5
6
7
8
9
getBean(name)
→ 归一化名称
→ 查询单例缓存
命中且无显式创建参数:取得已有对象
未命中:读取合并定义
singleton:在受控创建流程中创建并缓存
prototype:为本次获取创建对象
自定义scope:委托对应Scope
→ 处理FactoryBean语义与要求的返回类型

这段调用关系省略了父工厂委托、循环创建检测和异常回滚,供阅读普通身份实验使用。完整源码在返回前还会处理 FactoryBean;第 06 篇解释为什么缓存里的工厂对象不总等于最终返回的产品。

单例创建由 DefaultSingletonBeanRegistry.getSingleton(beanName, ObjectFactory) 组织,成功对象进入按 bean name 索引的 singletonObjects。这个 Map 归属于当前工厂实例,因而另一个工厂拥有另一份缓存。固定版本单例注册表

prototype 路径调用 beforePrototypeCreation()、createBean() 和 afterPrototypeCreation(),没有把结果按普通单例缓存供下次复用。状态变化是新实例进入调用者的引用;决策分支是 mbd.isPrototype();外部后果是连续两次获取的引用不同。这样的链条比“prototype 多例”更便于用断点确认。

创建失败还包含清理路径:singleton 创建回调捕获 BeansException 时会调用 destroySingleton(beanName),避免残留早期引用和相关依赖对象。这里没有展开循环依赖缓存细节,因为只看几个 Map 不能解释何时允许早期引用;相关问题需结合生命周期与代理再检验。

上游测试怎样约束结论

固定版本 DefaultListableBeanFactoryTests.prototype() 先注册默认 scope,验证两次取得同一对象;随后改用 prototype,验证不同对象;最后显式 singleton 再验证同一对象。它使用同类对象与引用身份断言,排除了“类型一样所以结果应该一样”的推理。对应上游测试

本批读取该测试并运行独立 Chapter01,没有执行上游 JUnit 任务。上游测试证明维护者对边界的可执行描述;本地输出证明所下载二进制在给定环境中的行为。两项证据不能互相替代,也不构成所有 scope 或所有版本都已验证的声明。

模式提炼:元数据与对象、作用域与缓存

定义与实例分离,使配置处理器能够在业务对象创建前修改 scope、构造参数和初始化声明。元数据是可处理的输入,实例是执行后的结果。代价是存在多个启动状态:读取定义成功、后处理成功和实例可用,需要分别检查。适用场景是配置来源多、需要统一生命周期的应用;简单手动程序可以直接表达对象图。

作用域把实例复用策略放在工厂获取边界。singleton 借助缓存复用,prototype 按次创建,自定义 scope 委托另一个策略。它解决的是获取时的对象数量与生命周期问题,线程安全还由对象本身的状态与同步决定。订单仓储的 ArrayList 没有因此获得锁,多请求实验仍未执行。

模式 机制 适用条件 代价与边界
元数据与对象分离 definition注册后再解释创建 多配置来源与创建前扩展 启动状态更多,须区分注册和实例化
作用域策略 获取时选择缓存、创建或Scope委托 组件具有不同复用和生命周期需求 注入后的普通引用不会自动重新获取;不保证线程安全

练习与检查方式

两个互不关联的 GenericApplicationContext 分别注册名为 singleton 的 Counted,取得的实例会相同吗?不会。每个工厂各有缓存;实验补充了跨上下文身份断言。这个条件排除了父子容器查找委托,后者需要另外分析。

同一个上下文把 Counted 注册为 left 和 right,两个实例是否相同?普通 singleton 定义分别创建各自实例;按名称获取应不同。若改成两个 Supplier 都返回同一个外部对象,那是显式共享输入,不能用来否定普通创建策略。

把服务持有的 prototype 仓储字段反复读取,能否获得新仓储?不能。字段读取沿已有引用进行;只有再次请求工厂,或者其他获取机制介入,才触发 prototype 创建。可在构造器记录仓储身份,与 Provider 获取结果作对照。

复现与继续阅读

实验工程与完整环境说明见00:从手动组装到可验证的容器实验。本篇入口为:

1
./mvnw -q compile exec:java -Dexec.mainClass=blog.spring.Chapter01

本地实验已通过,原始输出在工程 evidence/01/local-20261002/。本篇没有验证并发安全、代理目标身份、父子容器与自定义 scope。继续阅读02:BeanDefinition 如何描述对象创建,从已注册的元数据追踪具体创建指令。