循环依赖与扩展失控:Spring 提前引用为何会暴露错误对象
循环依赖最危险的结果不是启动失败,而是启动成功后系统里同时存在两个身份:某个 Bean 提前拿到原始对象,其他 Bean 最终拿到代理对象,事务、鉴权或缓存只对后一部分调用生效。另一些事故来自扩展点本身:BeanPostProcessor 为了初始化自己,提前拉起一串普通 Bean;BeanFactoryPostProcessor 在定义阶段调用 getBean();一个“修复循环”的 lazy 注入把错误推迟到第一笔请求。
理解这些失败不能只背诵“三级缓存能解决循环依赖”。三级缓存是单例创建算法中的引用协调结构,它只在特定依赖形态下提供提前引用,并不保证对象已经完成属性填充、初始化和全部后处理。更不应该把它当成允许任意环形架构的设计能力。
官方 依赖注入与循环依赖说明 明确构造器循环会形成不可解析场景;setter 场景即使能取得提前引用,也仍要验证成熟度与最终代理身份。
单例创建的三个容器保存不同成熟度的引用
DefaultSingletonBeanRegistry 的核心结构可概括为:singletonObjects 保存完全创建并对外暴露的单例;earlySingletonObjects 保存已经实际取用的提前引用;singletonFactories 保存能够按需生成提前引用的工厂。还有“当前正在创建”集合和依赖关系表,用来检测状态、协调销毁顺序。
工厂不是为了“缓存第三份对象”,而是延迟决定提前暴露什么引用。若自动代理创建器参与,它可以通过 getEarlyBeanReference 提前返回与最终代理一致的入口;只有真的有依赖在 A 创建期间请求 A,工厂才会被调用并把结果转入 early cache。创建完成后,最终引用进入一级缓存,早期结构被清理。
提前引用的对象尚处在创建窗口:属性可能未填充,Aware、初始化方法和大多数后处理尚未完成。B 在构造器或 init 中调用它,可能读取默认值、空集合或未启动资源。算法解决的是“引用闭环”,不是“行为已就绪”。因此即使容器允许 setter 循环,业务仍可能依赖初始化先后而不稳定。
构造器循环没有可暴露实例,应该直接失败
A 的构造器需要 B,B 的构造器又需要 A 时,容器在任一对象完成实例化前就需要另一个对象,无法注册一个指向真实 A 的 singleton factory。除非依赖本身是延迟句柄或代理,否则不存在可注入的早期实例。BeanCurrentlyInCreationException 在这里是在保护不可能满足的构造契约。
examples/backend-development/spring-framework/cycles-extension-failures/CircularDependencyDemo.java 用创建栈表达这一点:
javac --release 17 -Xlint:all -Werror examples/backend-development/spring-framework/cycles-extension-failures/CircularDependencyDemo.java examples/backend-development/spring-framework/cycles-extension-failures/EarlyReferenceMismatchDemo.java
java -cp examples/backend-development/spring-framework/cycles-extension-failures CircularDependencyDemocreationStack=[B, A] constructorCycleRejected=true exception=BeanCurrentlyInCreation正确修复通常是找出方向错误:抽取 A、B 共同依赖的第三个职责;把反向调用改成返回结果、领域事件或上层编排;把读取接口与写入接口分离;将真正可延迟的依赖改为 ObjectProvider/工厂。把一个构造器改成 setter 只是改变失败时机,并没有证明对象图合理。
延迟提供者也不是免费解环。它把缺失候选、歧义、创建异常和性能成本推迟到调用时;若每次调用都获取 prototype 或 scoped Bean,还需要明确其销毁和上下文。对关键必需依赖,启动失败往往比首个请求失败更安全。
prototype 循环通常不能靠单例缓存解决,因为容器不会以同样方式维护可复用的 prototype 提前引用。request/session 等 scope 是否支持循环取决于 scope 实现与代理,但代理只延迟目标查找;真正调用时环仍可能出现。分析时必须先写出每个节点的 scope,不能把所有 Bean 当成 singleton。
代理必须保证提前引用与最终暴露引用身份一致
假设 A 需要事务代理,B 在 A 创建期间需要 A。如果 B 得到原始 A,而 A 初始化后被包装成代理 P,容器中就存在两条路径:B → raw A 绕过事务,其他调用者 → P → A 执行事务。更隐蔽的是两者业务状态共享,看起来“多数时候正常”,只有经 B 的调用缺少增强。
自动代理创建器会记住提前代理状态,避免初始化后再创建第二个代理。自定义 BeanPostProcessor 如果只在 postProcessAfterInitialization 包装对象,却不参与 early-reference 协议,遇到循环就可能制造 raw/代理不一致。多个自动代理创建器、scoped proxy 与自定义包装器叠加时,身份和顺序更复杂,必须测试最终代理链而不是只测试类型可赋值。
EarlyReferenceMismatchDemo.java 把风险输出为两个 identity:
java -cp examples/backend-development/spring-framework/cycles-extension-failures EarlyReferenceMismatchDemoearlyProxy=false finalProxy=true sameReference=false rawInjectionRisk=true真实 Spring 在检测到某 Bean 已被原始形式注入其他 Bean、最终又被包装时,可能拒绝完成创建并报告 raw version 注入问题。关闭检查或允许循环,只会让不一致进入运行期。看到此类异常,应收集依赖路径、提前引用是否创建、参与包装的处理器和最终对象 identity,而不是全局打开容忍开关。
BeanFactory 后处理阶段只能操作定义,不应抢跑普通 Bean
BeanDefinitionRegistryPostProcessor 和 BeanFactoryPostProcessor 工作在普通单例实例化之前,适合注册、修改和验证 BeanDefinition。它们可以读取属性、调整 scope、增加 definition 或注册基础设施;若在这里调用普通 getBean(),会让目标在全部定义后处理器执行完之前提前实例化,后续属性占位、自动代理或元数据修改可能来不及生效。
非 static @Bean 方法返回 BeanFactoryPostProcessor 时,容器为了取得处理器可能提前实例化其配置类;这也是“not eligible for auto-proxying”一类警告的来源之一。基础设施工厂方法应避免依赖普通业务 Bean,必要时使用 static 工厂让容器无需先构造配置实例。处理器自身依赖图要小、稳定且只依赖更早阶段的基础设施。
定义处理器还要尊重注册与修改时限。容器冻结配置后再偷偷增加 definition,会让按类型缓存、候选集合和 AOT 分析不一致。动态模块需要明确的 context 生命周期或子容器,而不是在并发请求中修改活跃 BeanFactory。
对 definition 做类加载和反射也要谨慎。扫描阶段可能只需要类名与元数据,过早加载会触发静态初始化、缺失可选依赖或错误 ClassLoader。缓存键应包括资源、ClassLoader 与配置版本;插件卸载时清理强引用,避免类加载器泄漏。
BeanPostProcessor 不应通过自身依赖触发半套处理链
BeanPostProcessor 必须先于普通 Bean 注册,普通 Bean 才能经过完整链。若处理器 P 的构造器需要业务 Bean X,容器为了创建 P 提前创建 X,此时其他处理器可能还未注册,X 就无法获得完整代理或注入处理。日志中的“not eligible for getting processed by all BeanPostProcessors”不是可以常规忽略的提示,它说明对象创建时序已经越过基础设施边界。
修复方法是让处理器依赖元数据和轻量基础设施,而非被处理对象;延迟获取只能在处理器完整注册后使用,并需防止处理回调中递归 getBean()。处理器在每个 Bean 上运行,任何网络 I/O、粗锁或未缓存反射都会乘以 Bean 数,扩大启动时间。应记录扫描数、命中数、包装数和分位耗时。
处理器返回替代对象时必须维护契约:可赋给注入点类型;同一 Bean 不重复包装;equals/hashCode/toString 不意外触发业务;目标异常保留;销毁能到达真实资源;提前引用与最终引用一致。若两个处理器都包装代理,还要确认引介接口、Advisor 顺序和 target source 没有丢失。
InstantiationAware 后处理器能在实例化前短路默认创建、改变属性填充,能力更强也更危险。返回自建对象意味着标准构造、注入、初始化和销毁可能不再自动发生;扩展必须明确自己接管了哪些阶段。MergedBeanDefinition 后处理器缓存注入元数据时,还要在 definition 重置或类重载时清理。
循环并不只发生在字段箭头上
对象图之外还有多种运行期环:事件监听器发布同类型事件形成递归;AOP 拦截器调用被同一切点再次拦截的服务;FactoryBean 产品创建反向获取工厂依赖;@DependsOn 人为添加相反顺序;销毁回调重新从正在关闭的 context 取 Bean;配置属性解析依赖一个尚未完成的 converter。
这些环不一定由三级缓存检测。应使用有向图思维记录“创建依赖”“运行调用”“生命周期顺序”三类边,不能混成一张图。@DependsOn 只影响初始化/销毁顺序,不注入对象,也不能修复构造器环;@Lazy 只在边上放代理或延迟解析,不保证第一次求值时无环。
事件递归要有事件类型与因果 id 的深度限制;切面递归要排除基础设施包或使用重入保护;FactoryBean 诊断要区分 name 产品与 &name 工厂;关闭阶段禁止创建新单例。每一种环都有不同断点,统一打开 allowCircularReferences 无法治理它们。
从异常类型还原创建阶段,而不是试错注解
可把失败按阶段分成五类:
定义失败:重复名称、类无法加载、条件或配置解析错误。此时尚无业务实例。实例化失败:构造器选择、参数循环、工厂方法或构造器异常。属性填充失败:候选缺失/歧义、setter 异常、提前引用不一致。
初始化与后处理失败:init、代理创建、处理器顺序或类型不兼容。销毁失败:回调顺序、线程未停、资源已关闭或关闭期重新取 Bean。
异常链的最外层常是 BeanCreationException,真正线索在最深 cause、bean name、resource description 和创建栈。不要只搜索第一行消息。调试时为指定 Bean 开启创建日志,导出 definition 来源、scope、dependsOn、注入点与依赖/被依赖关系;比较最终暴露类、target class 和 identity hash。JFR 或启动剖析可定位处理器和类加载热点。
若修改一次注解后异常从 A 转到 B,不代表接近解决,可能只是把创建路径推到下一节点。先画出最小强连通分量,再决定哪条边在业务语义上应该单向。构造器注入有意让环尽早失败,这种失败反馈值得保留。
扩展点必须有预算、边界和回归样本
每个自定义 RegistryPostProcessor、FactoryPostProcessor、BeanPostProcessor、Scope、FactoryBean 与自动代理创建器都应登记负责人、适用 Bean 范围、执行阶段、顺序、缓存、线程模型和失败策略。全包扫描或“对所有 Bean 都尝试反射”应有启动预算;扩展异常默认让 context 启动失败还是降级,也要按能力重要性明确。
回归样本至少包括:普通单例、构造器循环、setter 循环、需要代理的循环、prototype、FactoryBean、父子 context、初始化异常和关闭重启。断言的不只是 context 能启动,还包括对象 identity、代理层数、Advisor 列表、init/destroy 次数、处理器命中数以及失败后线程和资源回到基线。
生产治理要统计 early reference 创建次数、循环依赖路径、提前实例化警告、代理不一致拒绝、各处理器耗时和 Bean 创建深度。正常架构中 early reference 应极少;数量在升级后增长,通常意味着扫描边界、自动配置或依赖方向改变。将其作为启动资产差异审查,比等到事务偶发失效更便宜。
三级缓存可以解释容器如何在有限条件下闭合引用,却不能证明这个环值得存在。真正稳健的设计是让必需依赖在构造时明确、让扩展点只做所属阶段的工作、让提前与最终引用保持同一身份,并把无法满足的对象图在启动期拒绝。失败越早、证据越完整,系统留给运行期的随机性就越少。
