Spring循环依赖与三级缓存:从源码到实战彻底破解Bean创建之谜
2026/9/16 15:17:59 网站建设 项目流程

我前几天深夜在技术群里看到这么一个问题:“Spring到底是怎么解决循环依赖的?为什么面试官总喜欢问三级缓存?”当时群里十几个人给出了七八种答案,有的说二级缓存就够用了,有的说必须三级,还有个兄弟直接贴了源码却讲不明白为什么。我盯着屏幕想了想,这个问题的确值得认认真真写一篇长文来拆清楚——不是因为它是面试题,而是因为循环依赖本身就是Spring容器里一个非常精巧的设计节点,理解了它,《Spring Bean生命周期》《Spring三级缓存》《代理对象生成时机》这些概念都会一起被打通。

这篇文章我不打算照搬官方文档,也不打算只给你结论。我会从一个实际报错场景开始,一步步带你走完Spring创建Bean的整个流程,把三个缓存容器分别存什么、什么时候存、什么时候取、为什么一定需要“三级”这些问题全部说透。不管你是在准备Spring面试,还是已经被各种“Could not obtain transaction-synchronized Session”和“BeanCurrentlyInCreationException”折磨过,这篇文章都值得读完。

1. 循环依赖到底是个什么问题

1.1 先从一段真实的报错说起

如果你在一个Spring Boot项目里不小心写了类似下面的代码:

@Service public class UserService { @Autowired private OrderService orderService; } @Service public class OrderService { @Autowired private UserService userService; }

启动项目时,Spring容器大概率会直接甩给你这么一段异常:

Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. Bean 'userService' is currently in creation: Unsatisfied dependency expressed through field 'orderService'

早期版本的Spring会提示“BeanCurrentlyInCreationException”,新版本Spring Boot 2.6之后默认直接禁止循环依赖了,只有在application.yml里显式设置spring.main.allow-circular-references=true才会放行。很多初学者遇到这个报错第一反应是懵的:我不是加了@Autowired吗?依赖怎么会注入不进去?

实际上问题不在@Autowired,而在于两个Bean互相持有对方引用时,容器“先有鸡还是先有蛋”的经典困境——如果要创建UserService,就必须先拿到OrderService;如果要创建OrderService,又必须先拿到UserService。如果容器只会按照“完整创建完一个Bean再创建下一个”的朴素思路走,那它俩会无限等待彼此,最终把线程栈堵死。

1.2 循环依赖的两种形态

在深入解法之前,先明确一个关键区分:不是所有循环依赖都能被Spring自动解开,也不是所有循环依赖都“需要”被解开。按注入方式划分主要有两种:

  • 构造器循环依赖:A的构造函数需要B,B的构造函数需要A。这种情况下Spring直接宣告失败,因为构造器必须在Bean实例化的时候就执行,这时候Bean连个半成品状态都不存在,没有任何缓存兜底。
  • 属性注入(setter/字段)循环依赖:A的字段里需要B,B的字段里需要A。Bean可以先把自身这个“不完整对象”创建出来,后续再慢慢填充属性。这才是Spring三级缓存发挥作用的舞台。

绝大多数业务代码里用的是字段注入或setter注入,所以Spring才能用一套巧妙的缓存机制把循环依赖消化掉。那些声称“Spring不能解决循环依赖”的文章,其实是把条件说漏了——解决不了的是构造器场景,属性注入场景它不仅能解决,还解决得非常优雅。

1.3 为什么你要理解它而不只是背结论

说实话,很多面向面试的文章都会把“三级缓存”当作一个八股列表去背:一级缓存是singletonObjects,二级是earlySingletonObjects,三级是singletonFactories,讲完就完事。但你真要在项目里排查一个BeanCurrentlyInCreationException,光知道三个Map的名称根本不够用。

你得知道Bean创建到哪个阶段才会把自身放进三级缓存,你得知道为什么AOP代理对象在这个流程里会迟到,你得知道什么时候三级缓存会退化、什么时候它升级成二级缓存,你还得知道哪些操作会让Spring的整个缓存补救机制彻底失效。把这些细节全部串起来看,这套机制才真正变成你脑子里的东西,而不是背诵的答案。下面我把整个Bean创建链路重新走一遍。

2. 三种缓存各存什么:一张图说清全貌

2.1 三个Map的定位

Spring解决循环依赖的核心就是DefaultSingletonBeanRegistry这个类,里面定义了三层ConcurrentHashMap。我先给你一个总体视角,后续再逐步展开。

/** 一级缓存:存放已经完全创建好的单例Bean */ private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256); /** 二级缓存:存放早期的半成品Bean,也就是已经实例化但未完成属性填充的对象 */ private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16); /** 三级缓存:存放单例Bean工厂,用于生成这个Bean的早期引用 */ private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);

三个缓存的职责可以简单概括成:

  • 一级缓存:最终形态。Bean已经完整走完实例化、属性填充、初始化、AOP代理生成等所有步骤,可以交给任何调用者使用。
  • 二级缓存:过渡形态。保存着“实例化已完成,但属性还没填充完、初始化还没执行”的半成品,通常是因为循环依赖被提前暴露出来的对象。
  • 三级缓存:提前暴露的工厂。存的不是对象本身,而是一个ObjectFactory,它能在需要的时候返回一个“早期引用”,并且在调用时有机会对对象做代理包装。

不少人读到这里会问:三级缓存里存的既然是工厂,为什么不直接在二级缓存里存对象?这正是整篇的重点,我会在第3章专门花篇幅讲。先把这个基础框架扎根,我们继续看Bean在创建过程中是怎么跟这三个Map打交道的。

2.2 一次正常Bean创建会经历什么

要理解三级缓存的妙处,得先知道一个Bean从无到有会经过哪几个主要阶段。这里拿一个没有任何循环依赖的普通单例Bean举例:

  1. Spring根据BeanDefinition判断是单例还是原型,单例才会进入三级缓存机制。
  2. 通过反射调用构造函数创建原始对象,这一刻对象已经存在于内存中,但它内部依赖的属性全是null。
  3. 实例化后置处理SmartInstantiationAwareBeanPostProcessordetermineCandidateConstructorsgetEarlyBeanReference等回调会在这个阶段尝试介入。
  4. 把原始对象封装成ObjectFactory放进三级缓存,允许后续被其他Bean提前引用。
  5. 执行属性填充(populateBean),通过AutowiredAnnotationBeanPostProcessor把依赖的其他Bean注入进来。
  6. 如果注入的对象还没创建完,会触发创建该对象的逻辑,形成递归;这时三级缓存里的早期暴露工厂就能发挥作用。
  7. 执行BeanPostProcessorpostProcessBeforeInitializationafterPropertiesSet(InitializingBean)、自定义init-method。
  8. 执行BeanPostProcessorpostProcessAfterInitialization,AOP代理通常在这一步生成。
  9. 把最终对象放进一级缓存,同时把二、三级缓存中的对应条目清理掉。

在这个流程里,第4步是Spring解决循环依赖的关键动作——它在对象还不完整的时候就把一个“取引用入口”暴露了出去。正常创建时这个动作看起来没什么收益,可一旦出现循环引用,这个入口就是救命稻草。

2.3 为什么早期引用里藏着一个“陷阱”

第6步和第8步放在一起看,会发现一个潜在的矛盾:如果A在属性填充阶段被B依赖,B从三级缓存拿到的是A的早期引用;但A真正完成初始化、生成AOP代理之后,最终放进一级缓存的对象跟早期引用可能是两个不同的对象

那B拿到的引用是不是就失效了?这问题要是没处理干净,Spring的整个缓存机制就会出现对象不一致的严重Bug。解决方式就是我在后续第4章会讲到的getEarlyBeanReference设计——Spring允许在三级缓存暴露期间就提前把代理对象准备好,让早期引用和最终对象是同一个。现在先留着这个悬念,我们先把“为什么需要三级缓存”这个核心问题推导一遍。

3. 三级缓存的核心推导:为什么不是两级

3.1 二级缓存到底缺了什么

很多讲循环依赖的文章都会说“二级缓存就够了”,甚至有人直接写“三级缓存是多余的”,这两种说法都不准确。要搞清楚这个问题,你得先问自己:如果只有一级和二级缓存,Spring还能破解属性循环依赖吗?

答案是可以,但有一个前提:创建Bean的时候不允许任何AOP介入,也就是全程不准生成代理对象。在这个理想化条件下,A实例化完就放进二级缓存,B填充属性时直接从二级缓存取A的原始引用,等B创建完,再回头把A的属性填充完。整个过程没有对象替换问题,两个Bean引用的始终是同一个内存对象。

现实情况恰恰是:Spring应用里几乎处处都有AOP。事务管理是AOP,异步注解是AOP,自定义切面也是AOP。如果某个Bean的最终形态必须是一个代理对象,那么这个代理对象通常是在初始化阶段的后置处理器里生成的,也就是上面第8步。一旦代理是在最后一步才生成,而B在早期阶段拿到的又是原始对象,那么B内部持有的A就和一级缓存中的最终对象不是同一个,轻则导致事务切面失效,重则引发各种难以排查的诡异问题。

3.2 三级缓存真正解决的问题是“代理对象的提前暴露”

于是Spring设计了一个非常聪明的机制:允许Bean在实例化完成之后、属性填充之前,通过三级缓存里的ObjectFactory提前生成一个“经过后置处理”的早期引用。这个早期引用不是普通原始对象,而是调用了getEarlyBeanReference之后的结果。

getEarlyBeanReference这名字很容易让人忽略,但它才是三级缓存存在的根本原因。AbstractAutoProxyCreator会重写这个方法,在里面执行:

@Override public Object getEarlyBeanReference(Object bean, String beanName) { Object cacheKey = getCacheKey(bean.getClass(), beanName); if (!this.earlyProxyReferences.contains(cacheKey)) { this.earlyProxyReferences.add(cacheKey); } return wrapIfNecessary(bean, beanName, cacheKey); }

重点是最后那行wrapIfNecessary——它会在早期阶段判断当前Bean是否需要代理,需要就直接创建代理对象。这样当B从三级缓存拿到A的早期引用时,得到的已经是代理对象了;等A本身走到初始化后置处理阶段,postProcessAfterInitialization因为发现earlyProxyReferences里已有记录,就不会重复包装,直接返回同一个代理对象。

兜了这么大一圈,结论很清晰:三级缓存存在的价值,是让代理对象有机会在循环依赖发生点被提前创建,从而保证所有持有方拿到的引用和最终一致。如果没有这个机制,只靠二级缓存,每次循环依赖发生后必然出现“同一Bean两个版本”的错乱。这不是小概率Bug,而是切面场景下必然复现的大坑。

3.3 那么“两级缓存”什么时候真的够用

为公平起见,我也得说说两级缓存确实够用的场景。如果你的项目里完全没有AOP,所有Bean都是原始对象直接暴露,那二级缓存完全可以扛住循环依赖。我见过一些老项目把Spring的AOP剪裁掉,或者只用@Configuration做Bean装配从不用切面,这种情况下确实不会出现代理错乱。

但问题是,你不能因为你的项目没有AOP就要求框架不处理AOP。Spring是通用框架,它必须保证“任何一个Bean可能在任意阶段被包装成代理对象”这个前提成立,所以它选择三级缓存来兼容最严苛的情况。你可以理解为:三级缓存不是为“普通对象循环依赖”设计的,而是为“代理对象循环依赖”设计的,普通场景只是顺带被覆盖了。

这个逻辑如果你能在面试里讲出来,基本上就能碾压只会背Map名字的应聘者了。

4. 代码走读:从Bean创建到缓存升级的完整链路

4.1 getSingleton与提前暴露

Spring创建单例Bean时,入口在AbstractBeanFactory.doGetBean,内部会调用DefaultSingletonBeanRegistry.getSingleton。这个方法有一段逻辑非常关键:

protected Object getSingleton(String beanName, boolean allowEarlyReference) { Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) { singletonObject = this.earlySingletonObjects.get(beanName); if (singletonObject == null && allowEarlyReference) { synchronized (this.singletonObjects) { singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null) { singletonObject = this.earlySingletonObjects.get(beanName); if (singletonObject == null) { ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName); if (singletonFactory != null) { singletonObject = singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } } } return singletonObject; }

我建议你把这短短几十行当作阅读重点。它做的事就是:创建A时发现依赖B,创建B时又回头依赖A,于是在某个瞬间B来找A,一级缓存没有A(A还在创建中),二级缓存也没有A,但三级缓存里有A的工厂。于是B从工厂里取出A的早期引用,同时把这个引用升级到二级缓存并移除三级缓存条目。下一次再有人来要A,直接从二级缓存取,不用再经过工厂二次加工。

这里有个经常被忽略的细节:三级缓存条目不是一直存在的,一旦有人因为循环依赖调用了工厂,三级缓存就退化成二级缓存。所以整个三级缓存机制只在循环依赖首次发生的那一瞬间起作用,过后容器里的状态和普通场景没有区别。

4.2 用两个Bean的时序把过程演一遍

为了让整个过程更具体,我用UserService和OrderService把时序完整推演一遍。假设创建顺序是先UserService后OrderService:

  1. Spring开始创建UserService,调用构造函数生成原始对象userServiceEarly。
  2. UserService完成实例化,被包装成ObjectFactory放进三级缓存,key是“userService”。
  3. 继续执行UserService的属性填充,发现需要OrderService。
  4. Spring开始创建OrderService,同样先生成原始对象orderServiceEarly,并放进三级缓存。
  5. OrderService执行属性填充时发现需要UserService,于是调用getSingleton("userService", true)
  6. 一级和二级缓存都没有UserService,但从三级缓存拿到了UserService的工厂。
  7. 工厂执行getEarlyBeanReference,如果UserService命中AOP切点,这里生成代理对象;如果不需要代理,返回原始对象。
  8. OrderService拿到了UserService的早期引用(可能是代理),完成属性填充,继续初始化,最后放进一级缓存。
  9. 回过头来,UserService依然在执行属性填充,此时从一级缓存里拿到完整的OrderService,注入进去。
  10. UserService继续执行初始化、后置处理。因为earlyProxyReferences里有记录,不再重复生成代理,最终UserService对象(就是Step7里那个引用)放进一级缓存。

整个过程里,OrderService一开始拿到的是“UserService的最终形态”,后面不会发生变化,因为代理在Step7已经被提前创建了。这就是同一个对象、好几个人持有而不会错乱的原因。

4.3 synchronized锁的意义

上面getSingleton代码块里还藏着一个细节:对singletonObjects加了synchronized。为什么需要一个类级别的锁?因为三级缓存到二级缓存的升级过程包含“取出工厂→调用工厂→放入二级缓存→移除三级缓存”多个动作,如果并发环境里两个线程同时在处理不同Bean的循环依赖,都很可能从三级缓存拿到同一个工厂,各自生成一份早期引用,造成对象分叉。加锁之后,同一时间只有一个线程能执行升级动作,保证了一个Bean的早期引用在容器里只有一份。

这部分如果你去翻旧版本Spring,能看到实现方式有所变化,但加锁的目的始终一致。我自己排查并发启动冲突时遇到过类似问题,印象很深刻:多个线程同时触发getSingleton且Bean依赖关系复杂时,如果没有锁,后果不只是重复代理,还可能出现两个二级缓存条目对应同一个Bean的极端情况。所以这段锁不是性能瓶颈,而是正确性保障。

5. 为什么构造器循环依赖就是解不了

5.1 构造器注入没有“提前暴露”的时间窗口

很多读者到了这里会问:属性注入能通过三级缓存解循环,为什么构造器循环依赖不行?

答案要从对象生命周期的源头找。构造器循环依赖发生在Bean创建的最开始阶段——为了调用A的构造函数,Spring必须先把B的实例准备好;为了调用B的构造函数,又必须先把A的实例准备好。但问题是,A的构造函数还没执行完,A在内存里连个最简单的原始对象都不存在,你拿什么暴露给B?

三级缓存暴露的前提是”对象已经实例化,至少有一个不是null的引用存在“。而构造器注入恰恰卡在“实例化”这一步,这个前提还没建立起来,缓存机制没有任何东西可以放进去。你可以把构造器理解成办理入住登记:还没给你分配房间号,你却要求另一个旅客从你房间里拿行李,这当然做不到。属性注入就不一样,房间可以先开好(实例化完成),行李再慢慢往里搬(属性填充),中途有人来取东西也来得及。

5.2 一个典型的失败过程

假设有两个类ClassAClassB,各自通过构造函数互相引用:

@Component public class ClassA { private final ClassB b; public ClassA(ClassB b) { this.b = b; } } @Component public class ClassB { private final ClassA a; public ClassB(ClassA a) { this.a = a; } }

Spring尝试创建ClassA,发现构造函数需要ClassB,于是转而去创建ClassB;ClassB构造函数需要ClassA,又转回去创建ClassA。两边在“一个完整对象都没有”的状态下互相等待,最终触发BeanCurrentlyInCreationExceptionUnsatisfiedDependencyException,启动直接失败。

Spring也不是没尝试过给构造器循环依赖提供解决方案。容器的三级缓存里其实有一个早期暴露分支,但由于构造器调用发生在暴露之前,这个分支永远够不到。实际上,现代Spring Boot默认也会直接拒绝循环依赖启动,只有在显式修改配置后才放行属性注入场景,构造器场景你是开了开关也照样起不来。

5.3 如果项目里就是有构造器循环依赖怎么办

遇到构造器循环依赖,我的建议是:

  • 重构依赖关系:最常见的方式是拆出中间层。A和B之所以互相依赖,说明它们的职责边界可能没划清楚。把互相调用的逻辑抽到一个服务C里,让A和B都依赖C,循环自然消失。
  • 改用@Lazy注解:在构造函数参数上加@Lazy,让Spring先给一个代理占位符,等真正调用时再解析。这个方案能用,但代理的引入会让调试变复杂,业务不复杂的场景可以接受。
  • 改属性注入:如果代码里确实改动成本很高,可以把final字段和构造函数注入改成字段注入,让三级缓存机制接管。但注意Spring官方并不推荐把属性注入当成解决循环依赖的常规方法,这更像下策。

我自己处理过一个大单体项目,里面两个Service互相调用的频率极高,重构成本巨大。后来我选择增加一个@Lazy,短期内解决了问题,但长期看代码还是需要逐步解耦。别把循环依赖当成无所谓的味道,能拆就拆才是正路。

6. 生产环境中的循环依赖排查与规避清单

6.1 我怎么定位是哪个Bean在循环

当Spring启动报BeanCurrentlyInCreationException时,异常堆栈里通常会明确标注当前的Bean名称和它正在依赖的Bean。比如:

The dependencies of some of the beans in the application context form a cycle: ┌─────┐ | userService ↑ ↓ | orderService └─────┘

Spring已经把这个环的拓扑结构打印出来了,如果你看到类似图形,直接顺着箭头去找对应的类就好。稍微复杂的情况是环里面有3个以上的Bean,或者某个Bean通过@DependsOn形成跨模块依赖。这种时候建议先在启动类顺序加载阶段把所有Bean名称打出来,用依赖关系画一张简单的关系图,再定位成环的地方。

还有一个非常隐蔽的场景:自定义BeanPostProcessor里又去容器里取其他Bean,人肉制造了一个运行时循环引用。这种问题异常信息经常不直接,只在某个后处理器的调用栈上能看出端倪。我遇到过一次,排查了很久才反应过来是某个全局的BeanPostProcessorpostProcessAfterInitialization里又调用了getBean,导致Spring在缓存未就绪时再次触发创建,最后只能通过重构后处理器的操作方式解决。

6.2 Spring Boot 2.6之后为什么默认禁止

Spring Boot 2.6发布的时候做了一件很有争议的事情:把spring.main.allow-circular-references默认值改成了false。很多老项目升级后一启动就报循环依赖错误,社区里哀嚎一片。其实Spring团队的行为逻辑很清晰——循环依赖本质上是设计缺陷,一旦项目规模变大,Bean之间形成网状依赖,容错率会很低,而且循环依赖会让整体架构变得不可维护。

如果你正面临这种升级问题,优先考虑拆解依赖而不是在配置里开开关。开开关只是把错误延后,项目里每个循环引用都是一个潜在的技术债。除非你有明确的、短期无法重构的原因,否则不要通过改配置强行绕过。

如果你已经决定要临时开启,可以这样配置:

spring: main: allow-circular-references: true

但请记住,这只是一个临时脚手架,长期你必须找时间清理。就像家里电路短路了,你可以先用胶布缠一下应急,但终究要找出短路点重新接线。

6.3 推荐的设计模式与实战建议

日常开发中想避免循环依赖,有几个我验证过非常有效的办法:

  • 用中间层打破双向直接依赖:把A依赖B、B依赖A的关系,改写成A依赖C、B依赖C,或者引入事件机制让A和B通过事件通信。事件的好处是结构立刻解耦,坏处是调用链变得隐式,不要滥用。
  • 把公共逻辑下沉:我的经验是,互相依赖的两个类里通常都有对方真正需要的一块逻辑。把这块逻辑提取到独立的Service或Helper里,两边的依赖自然变成单向。
  • 注意内部方法调用导致的假象:有时候你觉得类A和类B没有循环,但因为方法里互相调用了对方的服务,Spring记录它们处于创建中也还是会报错。这种情况其实就是逻辑层级的循环引用,同样需要拆。
  • 不要靠@Autowired(required = false)掩盖问题:它只会在注入失败时给个null,运行时才炸,比启动时直接报错更坑。

最后送你一个我常用的排查思路:启动报循环依赖时,先把异常里出现的类名逐个看一遍,凡是在构造函数里注入其他Bean的类重点标红。然后针对每个成环路径,问自己三个问题——这个依赖能不能去掉?能不能转成方法参数?能不能拆到第三方类?如果三个答案都是否,再考虑用@Lazy兜底。

7. 手写一个简化版三级缓存,能跑通才是真懂

7.1 一个最小模型的设计

聊了这么久的Spring内部机制,我建议你再往前走一步:自己动手写一个简化版本的三级缓存。这个练习不需要完整实现Spring的全部功能,只需要复刻核心流程,能证明你理解“提前暴露+代理不重复创建”的精髓。

我设计的最小模型有三个类:SimpleBeanFactory(核心容器)、AB,其中A和B互相通过字段依赖。容器里需要三个Map,与Spring结构一一对应。然后模拟创建A→填充B→B填充A依赖→B完成→A完成的流程。

这个模型的价值在于,你可以直观地看到缓存升级发生的时间点,并且用断点调试的方式观察三个Map的变化。

7.2 核心代码示例

我先直接给你一个可以运行的版本,你在本地跑一遍,再尝试改造成自己的版本:

import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; public class SimpleCircularDependencyDemo { static class SimpleBeanFactory { // 一级缓存:完整Bean private final Map<String, Object> singletonObjects = new HashMap<>(); // 二级缓存:早期Bean private final Map<String, Object> earlySingletonObjects = new HashMap<>(); // 三级缓存:Bean工厂 private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(); // 记录哪些Bean正在创建中 private final Map<String, Boolean> creating = new HashMap<>(); public Object getBean(String beanName) throws Exception { // 先查完整缓存 if (singletonObjects.containsKey(beanName)) { return singletonObjects.get(beanName); } // 这里为了演示简单,统一走“创建Bean”流程 return createBean(beanName); } private Object createBean(String beanName) throws Exception { creating.put(beanName, true); // Step 1: 实例化(模拟无参构造) Class<?> clazz = Class.forName(beanName); Object earlyObject = clazz.getDeclaredConstructor().newInstance(); // Step 2: 提前放入三级缓存 singletonFactories.put(beanName, () -> { // 这里是getEarlyBeanReference的简化版 return wrapIfNecessary(earlyObject); }); // Step 3: 属性填充 populateBean(beanName, earlyObject); // Step 4: 初始化完毕放入一级缓存 singletonObjects.put(beanName, earlyObject); singletonFactories.remove(beanName); earlySingletonObjects.remove(beanName); creating.remove(beanName); return earlyObject; } private void populateBean(String beanName, Object bean) throws Exception { Field[] fields = bean.getClass().getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); if (field.get(bean) != null) { continue; } Class<?> fieldType = field.getType(); String dependencyName = fieldType.getName(); if (creating.containsKey(dependencyName)) { // 发现循环依赖,从缓存里找早期引用 Object dependency = getEarlyReference(dependencyName); field.set(bean, dependency); } else { Object dependency = getBean(dependencyName); field.set(bean, dependency); } } } private Object getEarlyReference(String beanName) { if (earlySingletonObjects.containsKey(beanName)) { return earlySingletonObjects.get(beanName); } ObjectFactory<?> factory = singletonFactories.get(beanName); if (factory != null) { Object earlyObject = factory.getObject(); earlySingletonObjects.put(beanName, earlyObject); singletonFactories.remove(beanName); return earlyObject; } return null; } private Object wrapIfNecessary(Object bean) { // 简化版AOP代理:默认不包装,可自行扩展 return bean; } } static class A { public B b; } static class B { public A a; } public static void main(String[] args) throws Exception { SimpleBeanFactory factory = new SimpleBeanFactory(); A a = (A) factory.getBean("SimpleCircularDependencyDemo$A"); B b = (B) factory.getBean("SimpleCircularDependencyDemo$B"); System.out.println("A中的B是否等于容器中的B: " + (a.b == b)); System.out.println("B中的A是否等于容器中的A: " + (b.a == a)); } }

这段代码跑通后,你会在控制台看到两个true——说明A和B互相引用的是同一个内存对象。如果你把三级缓存去掉,直接改成二级缓存,在“不需要代理”的简化场景里它依然能跑通。但如果你在wrapIfNecessary里加入“给包装对象新增一个代理”,就会发现二级缓存方案下B拿到的是普通对象,容器里最终放的是代理对象,两个引用不再相等。

这就是手写一遍的意义:你不再需要背结论,而是亲手复现了“为什么需要三级缓存”的工程场景。

7.3 带AOP扩展的验证实验

我给你留个思考题,也是我会让身边人做的实验:在wrapIfNecessary里,如果判断条件拦到某个类需要代理,就返回一个Proxy.newProxyInstance生成的代类;然后看看A的b字段和一级缓存里的B是否还能保持同一对象。

我发现亲手跑过这个实验的人,基本都不会再把spring三级缓存当八股背了。因为他亲眼看到了“代理对象被提前制造并暴露”这个步骤对整个容器一致性的保护。我个人也是很建议你在本地开一个Spring Boot项目,写一个含事务注解的Service,再配一个循环依赖,把allow-circular-references打开,用断点盯着三级缓存看它什么是什么时候升级的,比看十篇文章都管用。

8. 易混淆知识点对照:三级缓存、生命周期与AOP

8.1 一张速查表理清关系

结合读者反馈,我发现很多人看完循环依赖后,会把getEarlyBeanReferencepostProcessAfterInitialization搞混,也会把“实例化”说成“初始化”。这里我做了一个简易对照:

概念发生时机与三级缓存的关系常见误区
实例化创建Bean第一步实例化完成后才有资格进入三级缓存误以为构造器执行完就叫初始化
属性填充实例化之后触发循环依赖的主要阶段误以为填充发生在初始化之后
初始化属性填充之后正常Bean在这里执行各类后置处理器误以为AOP代理在初始化前生成
getEarlyBeanReference早期暴露阶段三级缓存工厂内部的核心方法误以为它一定生成代理,其实是条件生成
postProcessAfterInitialization初始化完成之后无循环依赖时AOP生成的常规位置误以为它优先于早期暴露执行

这张表浓缩了全文的关键节点,如果你能不看表就把这些对应关系说出来,说明理解已经比较扎实了。

8.2 BeanPostProcessor与三级缓存的配合

BeanPostProcessor这个接口在循环依赖机制里扮演的角色经常被低估。Spring容器里有一堆内置的后处理器,AutowiredAnnotationBeanPostProcessor负责解析@AutowiredAbstractAutoProxyCreator负责AOP代理。在三级缓存的流程里,getEarlyBeanReference正是SmartInstantiationAwareBeanPostProcessor提供的能力之一。

如果没有这套后处理器机制,三级缓存的工厂里就真的只能放裸对象,代理错乱问题又回来了。所以可以这么理解:三级缓存提供了“提前暴露”的时机,BeanPostProcessor提供了“提前包装”的能力,两者合起来才是完整的循环依赖解决方案。

我自己在排查一些诡异问题时,习惯在getEarlyBeanReferencepostProcessAfterInitialization两处打条件断点,看同一个Bean是否被方法连续命中。如果连续命中且对象不相同,多半是后处理器的重复包装逻辑出了岔子;如果只有早期方法命中而最终方法没执行,说明这个Bean是通过循环依赖路径提前生成的,这两条观测经验在实战里还挺有用的。

8.3 与Spring AI、Spring Boot版本无关的稳定设计

有不少读者搜索时会把Spring循环依赖和Spring AI、Spring Cloud、Spring Boot版本问题混在一起搜,其实三级缓存是Spring框架容器层的稳定设计,已经持续了十多年。不管你是用Spring Boot 2.x还是3.x,甚至Spring Boot 4.0还在与Jackson兼容性问题作斗争的时候,三级缓存的底层语义都不会轻易变化。官方默认禁止循环依赖的策略可能因版本而异,但底层的DefaultSingletonBeanRegistry始终保留着这一套能力。

从学习路径上,我的建议是:先把Spring核心容器搞明白,再去看Spring AI里Agent编排、Spring Cloud里的服务治理,这样无论上层加了多少花活,底层依然是同一套Bean管理逻辑。循环依赖这个主题刚好是打通底层认知的一个优质入口。

9. 写在最后的几点实操体会

聊了这么多,最后分享点个人经验。很多年以前我拿到一个老项目,启动时刷屏式的循环依赖警告,开发者直接在配置里把allow-circular-references设成了true,眼不见心不烦。后来项目做了微服务拆分,几个Service参与了分布式事务,AOP代理一变多,那些靠循环依赖硬撑的类开始出现事务不生效、切面重复执行的问题,排查了一遍又一遍才发现全是历史债务。

我现在的原则很简单:新代码里绝对不主动写循环依赖,老代码里遇到循环依赖先拆再改,拆不了才考虑@Lazy临时兜底。你可以把循环依赖理解成代码里的循环import——语法允许,编译器不报错,但维护的人想骂人。理解Spring如何解决它,是为了遇见问题能兜底,更是为了尽量避免制造问题。

最后再送一个小技巧:本地调试循环依赖时,在DefaultSingletonBeanRegistry#getSingleton(String, boolean)方法里下一个条件断点,条件写成beanName.contains("你的类名"),然后观察isSingletonCurrentlyInCreation的状态变化。你会发现,原本看起来抽象的“提前暴露”,在断点里就是三级缓存里多出来又消失的那一行记录。亲眼看到它,你就真的懂了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询