深入 Spring 09:循环依赖与早期代理一致性
能取得一个引用,不等于对象已经初始化
订单服务 A 需要库存协调器 B,B 又保存 A 的引用。构造器注入和 setter 注入表达的是同一个依赖环,却可能得到不同启动结果。差别在于请求回到 A 时,A 的构造器是否已经返回,以及容器能否提供一个与最终公开引用一致的早期对象。
本文固定 Spring Framework 6.2.11、JDK 21,源码提交 4c134254642d88e058aa004bdaf44168e1be7bb2。实验位于 examples/spring-framework-lab/src/main/java/blog/spring/Chapter09.java,使用单线程原生 GenericApplicationContext。它覆盖构造器环、setter 环、自动代理环、关闭循环许可四组,并额外构造只在初始化后包装的错误扩展。
A 实现 Order 接口,ready 初始为 false,afterPropertiesSet 才把它改成 true。B.setA 保存引用时立即读取 ready。这个读取使实验能区分“引用注入成功”与“被注入对象已完成初始化”,避免只证明容器没有报错。
构造器循环在实例出现之前回到起点
构造器依赖可以写成 new A(new B(A))。容器必须先解析 A 的构造参数 B,创建 B 又要求先取得 A。此时 A 的构造器尚未执行完成,没有实例可供保存,也没有可从目标创建代理的对象。
主路径是 AbstractBeanFactory.doGetBean 进入 singleton 创建,再由 AbstractAutowireCapableBeanFactory.createBean、doCreateBean、createBeanInstance 和 ConstructorResolver 解析构造参数。当依赖查找回到 a,成品缓存和早期引用都没有可用值,再次开始同名 singleton 创建会触发 currently-in-creation 检查。创建路径源码
Chapter09 的第一组显式使用 AUTOWIRE_CONSTRUCTOR,断言 Framework 工厂默认允许 circular references,随后仍然观察到 UnsatisfiedDependencyException。最深层原因是 BeanCurrentlyInCreationException,refresh 失败后 context 不再 active,a 和 b 都没有作为成品 singleton 保留。
因此 allowCircularReferences 的含义是允许尝试使用特定早期引用路径,不是承诺解决任何有向环。仅把属性开关设为 true 无法使一个尚不存在的构造结果提前出现。
setter 循环把回边推迟到构造之后
第二组先调用 A 的无参构造器。doCreateBean 得到目标实例后,在 populateBean 之前检查三个条件:定义是 singleton、工厂允许循环引用、当前 Bean 正在创建。条件满足时调用 addSingletonFactory,登记一个能够计算早期引用的 ObjectFactory。
属性填充 A.b 触发 B 的创建。B 的构造器也先返回,然后填充 B.a 时回到 A。这次 A 已有早期工厂,查找可以取得引用,于是 B 的属性填充及初始化完成,控制流再回到 A 的属性填充和初始化。最终 a.b == b 且 b.a == a。
B.setA 在 A.afterPropertiesSet 之前执行,故 observedEarly 为 true;整个 refresh 完成后 A.ready 为 true。这里没有线程竞争,半初始化状态来自单线程递归创建次序。即使所有缓存操作都是线程安全的,也不会改变这个先后关系。
若 B 在 setA 中直接调用 A 的真实下单方法,而该方法要求 A.b 已经存在,那么取得引用之后仍可能空指针或读取错误配置。容器允许引用环只解决引用可达性,业务可调用性需要自己的不变式。示例只读取布尔状态,不把未初始化对象用于外部副作用。
三个缓存保存的是不同阶段的状态
DefaultSingletonBeanRegistry 的 singletonObjects 保存已发布实例;earlySingletonObjects 保存已经求值的早期引用;singletonFactories 保存延迟产生早期引用的工厂。常见的“三级缓存”称呼很容易遗漏最后一个集合保存的是工厂而非第三份 Bean。
getSingleton(beanName, allowEarlyReference) 先查成品;若不存在且 Bean 正在创建,再查已经形成的早期引用。在允许创建早期引用的分支中,源码取得 singleton 锁并重复检查,然后调用工厂,把结果放入 earlySingletonObjects 并移除相应 singletonFactory。早期查找源码
这个转换保证同一次正在创建的 Bean 后续早期查找复用已经形成的引用,而不是每查一次重新造一个代理。工厂的延迟调用也意味着没有实际回边请求时,不必提前创建代理;普通 after-initialization 路径仍可在正常时点进行包装。
6.2.11 的这段代码使用 ReentrantLock,并考虑原始创建线程及后台初始化协作。不能从旧版本文章复制“始终 synchronized 某个 Map”的实现描述。这里的实验没有启用后台初始化,不据此给出跨线程启动性能或死锁覆盖结论。
成品发布时 addSingleton 写入 singletonObjects,并移除残留的早期工厂和早期引用。三个结构不是永久同时持有三个对象;它们描述同名 singleton 创建期间的阶段性存储,最终查询应收敛到正式发布的那个引用。
自动代理器怎样保持同一个 A
第三组给名称 a 注册 BeanNameAutoProxyCreator,并指定一个计数 MethodInterceptor。A 实现 Order,因而公开引用是 JDK 动态代理。B 依赖 Order 接口,避免把接口代理强制转换成具体 A 类型。
早期工厂调用的 getEarlyBeanReference() 会遍历 SmartInstantiationAwareBeanPostProcessor。AbstractAutoProxyCreator 的实现先把原始 target 放入自己的 earlyBeanReferences,再调用 wrapIfNecessary 创建代理。这个内部 Map 记录的是处理记录,不应与 DefaultSingletonBeanRegistry 中真正缓存早期代理的 earlySingletonObjects 混淆。自动代理器源码
A 完成初始化后,自动代理器的 postProcessAfterInitialization 发现同一个 target 已参与早期代理,返回 target 本身,避免再包装一层。doCreateBean 随后读取已有早期引用;当 exposedObject 仍等于原始 bean 时,将 exposedObject 替换为该早期代理。最终发布的代理因此正是此前交给 B 的那个对象。
实验用身份断言检查 b.a == context.getBean(“a”),并确认 AopUtils.isJdkDynamicProxy 为 true。B.setA 读取 ready 时 advice 已经执行一次,但 target.ready 仍为 false;refresh 后通过公开引用调用,advice 变为两次,业务状态变为 true。代理可用和目标已就绪在这里被明确分开。
若只断言“容器返回了代理”,会漏掉 B 持有原对象、外部持有代理的裂分对象图。涉及事务、缓存或安全拦截时,这种裂分意味着不同调用方获得不同语义,因此引用身份是这个实验必须检查的条件。
只在末尾包装会破坏一致性
额外反例使用普通 BeanPostProcessor,只在 after-initialization 返回新代理,没有实现早期引用协议。B 在填充期间得到的是 raw A,而最终 exposedObject 变为代理。doCreateBean 发现已有实际依赖方且 allowRawInjectionDespiteWrapping 默认关闭,抛出 BeanCurrentlyInCreationException,消息包含 raw version。
这组将 a、b 设为 lazy,refresh 后显式 getBean(“a”),以单独观察业务请求触发的创建失败。它不会把“容器 active”当成实例创建成功;refresh 已经完成,懒加载对象的失败发生在后续查询。实验检查异常文本,并检查 a、b 都未残留在成品缓存中。
失败时点需要精确到入口。6.2.11 的 eager singleton 预实例化入口对某些直接抛出的 BeanCurrentlyInCreationException 有跳过处理分支,不能把不同创建入口的异常传播当成同一保证。本例通过显式查询固定触发点,验证的是 doCreateBean 的引用一致性保护,不声称每一种启动配置都会在 refresh 同一行失败。
打开 allowRawInjectionDespiteWrapping 会改变这项保护,却不会把 B 手中的原始引用自动替换成最终代理。关闭保护并非修复对象图,可能只是让不一致状态更难观察。自定义包装处理器若必须支持早期暴露,应理解完整协议及对象身份要求,而不是缓存一个随手创建的第二代理。
关闭许可与 Boot 的默认策略分开看
第四组保持 setter 对象图不变,仅调用 setAllowCircularReferences(false)。A 构造完成后不会登记早期工厂,B 回查 A 仍无可用引用,refresh 失败,根因是 BeanCurrentlyInCreationException。与第一组相比,构造器已经返回,但政策禁止提供这条早期路径。
这里直接设置的是原生 Framework 工厂属性。Spring Boot 的应用配置是另一个层次:Boot 2.6 发布说明将循环引用改为默认禁止,并说明设 spring.main.allow-circular-references=true 可以恢复此前的尝试行为。Boot 3.5 配置文档仍列出该属性默认 false。Boot 2.6 变更、Boot 3.5 属性
本工程没有启动 Boot,也没有把 Boot 2.6 和 Framework 6.2.11 拼成依赖组合运行。Framework 默认值和 true/false 两个运行分支有本地证据,Boot 历史及 3.5 默认值来自官方文档核验。把二者分开,才能解释同一对 setter Bean 在裸容器和 Boot 应用中表现不同,而不误判为底层框架能力消失。
拆环时必须保留业务约束
若 A 和 B 的方法互相承担对方业务的一部分,可以把共同决策抽出为第三个协调对象,使 A、B 不再互相要求对方完成初始化。若只是调用时才需要依赖,ObjectProvider 或延迟代理可以推迟查找,但不能在构造器里立刻调用 getObject 又把回边恢复到原时点。
延迟查找也改变失败时机:缺失依赖或创建异常可能从启动转到首个业务请求。这个变化必须被接口契约接受。不能为了启动成功,把原本要求启动即验证的关键资源悄悄推迟到线上调用。
基于事件拆分还会改变同步返回、异常传播和可能的事务边界,不能仅用“没有循环了”作为业务等价证明。对循环依赖的修复应检查初始化约束、方法返回结果和失败传播,缓存名词只解释容器内部机制,不能替代这些判断。
反例题与改动练习
反例题:B.setA 能调用早期代理的 ready,是否说明 A 已完成初始化?不能。advice 已经执行,目标 ready 仍为 false;引用可调用与初始化完成是不同状态,实验分别断言它们。
在 Chapter09 副本的晚包装、lazy 场景中,于 refresh 前设置工厂 setAllowRawInjectionDespiteWrapping(true),保留只在 after-initialization 包装的处理器。将原来的异常捕获分支改成取得公开 Order 与 B,并断言 b.a != exposed,同时检查 b.a 不为代理、exposed 为代理。重跑本章命令,预测显式查询可以返回,但两个调用方仍持有不同引用。这个改动仅关闭一致性保护,不能将它作为支持早期代理的修复方案;完成后恢复基线。
可复现的验收入口
在实验工程中使用 JDK 21 执行:
1 | |
四组主实验加晚包装反例都必须执行,最后输出 PASS Chapter09 resources closed 并以零退出。原始日志位于 examples/spring-framework-lab/evidence/09/,预期失败由类型、根因和缓存状态断言捕获,不是把 Maven 失败当作通过。
上游 DefaultListableBeanFactoryTests 的 extensiveCircularReference 检查大量属性回边的对象身份,circularReferenceThroughAutowiring 检查构造路径的失败。这里阅读了这些测试,实际执行的是本工程的五个场景;后台初始化、prototype 环和整个上游测试套件没有作为本章已运行结果。上游测试
判断一个环能否创建,应在每条回边标出请求时点:目标是否已存在、早期暴露是否允许、早期引用是否与最终引用一致、调用是否要求尚未建立的状态。生命周期顺序见 深入 Spring 07:生命周期回调与最终代理引用,扩展注册时机见 深入 Spring 08:扩展顺序与过早创建。

