深入 Spring(01):IoC、对象图与容器身份
同一个类注册进 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 | |
计数器用于单线程实验,不是并发指标。在 GenericApplicationContext 中注册一个单例和一个 prototype:
1 | |
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 | |
== 比较两个引用是否指向同一个对象,适合这个实验;equals() 可能被业务类重写,两个值相等的对象仍可以有不同身份。identityHashCode() 可以辅助日志定位,但可能发生碰撞,不能代替引用断言。
Spring singleton 的数量约束针对一个容器中的一个 Bean 定义。它不限制整个 JVM 只能存在一个该类对象,也不拦截任意 new。两个定义即使使用同一个 class,只要 bean name 不同,也可以得到两个普通单例;两个相互独立的上下文具有各自的单例缓存。scope 契约
手动创建的 Counted 没有通过工厂创建过程,也不会因为同类已注册而自动被并入缓存。对普通构造器直接使用 new,获得的就是本次 Java 构造产生的实例。至于增强配置类中调用 @Bean 方法是否被截获,需要额外的配置类条件,第 04 篇给出 full 与 lite 对照。
名称与类型也不能混为一个标识。按类型获取对象首先需要选择候选;同一个 class 注册两次会产生类型歧义,即使按两个名称分别查找都能成功。alias 是名称映射,也不是增加一份定义和一份实例;这些区别会在定义与候选篇验证。
prototype 的获取分支
同一份 Counted 类型的定义改成 prototype 后,两次获取产生不同实例:
1 | |
两次表达式各调用一次 getBean(),本次正常运行各构造一个 Counted。连同一个容器单例和一个手动对象,原身份实验一共创建四个实例:
1 | |
这段是原始身份实验的输出节选,完整运行另包含定义状态及作用域边界断言。断言计数只覆盖这组独立上下文,不能把整个程序的静态计数器当成容器统计接口。
prototype 意味着向工厂请求时创建实例;它不意味着注入 prototype 的 singleton 在每次业务调用前都自动重新获取依赖。构造器注入只在创建服务时解析一次参数,之后服务字段保存该次结果。若业务需要反复请求新实例,须引入显式的获取边界,例如第 05、06 篇的 ObjectProvider,或者专门的 scope 机制。singleton 对 prototype 的依赖
销毁同样有边界。prototype 在工厂创建时可以经历初始化处理,但容器不会像普通受管理 singleton 一样保存所有产物等待关闭时销毁。使用者需要明确释放资源的责任。第 00 篇的仓储销毁结果不能直接套在任意 prototype 上。
沿 getBean 追踪状态变化
对普通名称调用 getBean(),入口进入 AbstractBeanFactory.doGetBean()。固定版本先用 transformedBeanName() 归一化名称,再查询单例缓存。缓存存在且没有显式创建参数时,使用已存在的实例;否则读取合并定义,处理依赖关系并按 scope 分支。固定版本获取路径
1 | |
这段调用关系省略了父工厂委托、循环创建检测和异常回滚,供阅读普通身份实验使用。完整源码在返回前还会处理 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 | |
本地实验已通过,原始输出在工程 evidence/01/local-20261002/。本篇没有验证并发安全、代理目标身份、父子容器与自定义 scope。继续阅读02:BeanDefinition 如何描述对象创建,从已注册的元数据追踪具体创建指令。
