《Spring Boot 高级》1.3 依赖解析与循环依赖

走完 getBean() 到 doCreateBean 的解析链:用字节码调用次序说明三级缓存 singletonObjects / earlySingletonObjects / singletonFactories 为何能解掉 setter 循环依赖、构造器循环依赖为何必然失败,并对比 @Lazy 与 ObjectProvider 破环的代价。

本节目标:把 getBean() 的解析链走完,用三级缓存的字段与调用次序解释「setter 循环依赖能解、构造器循环依赖必炸」的根本原因,并给出 @Lazy 与 ObjectProvider 两种破环手段的适用边界与代价。
适用版本:Spring Boot 4.1.x(Java 21)

1.3 依赖解析与循环依赖

1.2 把定义准备好了,本节看容器怎么按定义造对象、接依赖。循环依赖是面试常客,但多数资料停在「三级缓存」四个字上,说不清每一级缓存各自防的是什么。本节用本机 jar 的字节码调用次序把这个问题钉死。

延续前两节的对象:LoanService 与 NotificationService 互相依赖——LoanService 借书后要通知读者,NotificationService 通知时要查借阅状态。先把它们写成 setter 注入,再看它为什么能启动。

1.3.1 getBean() 的解析流程

getBean() 入口在 AbstractBeanFactory,主流程收在 doGetBean(String, Class<T>, Object[], boolean)。它做四件事:

步骤动作说明
1transformedBeanName(name)去掉 & 前缀、把别名归一成真实 bean 名
2getSingleton(beanName)先查单例缓存,命中直接返回
3getMergedLocalBeanDefinition(beanName)取合并后的 RootBeanDefinition
4getObjectForBeanInstance(...)若是 FactoryBean,取 getObject() 结果

若缓存未命中且是单例,会走 createBean → AbstractAutowireCapableBeanFactory.doCreateBean(String, RootBeanDefinition, Object[])。doCreateBean 内部的三步顺序是理解循环依赖的关键,本机 spring-beans-7.0.9.jar 的字节码里顺序明确:

createBeanInstance(...)             // 先反射造出「空壳」实例
addSingletonFactory(beanName, ...)  // 再把「怎么拿到早期引用」注册进三级缓存
populateBean(...)                   // 最后才做属性/依赖注入

也就是说,对象在依赖注入之前,就已经可以被别人拿到了。这正是 setter 循环依赖能被解开的前提。注意 getSingleton 有多个重载:无参版本只查一级缓存,带 boolean allowEarlyReference 的版本才会去查二、三级缓存。

doGetBean 对非单例分支的处理不同:prototype 每次调用都新建实例,不进单例缓存;request / session 等作用域交给对应的 Scope 实现。本节讨论的循环依赖全部限定在单例范围内,这也是三级缓存只覆盖单例的原因。

1.3.2 依赖注入的解析:resolveDependency

注入动作发生在 populateBean 阶段,最终落到 DefaultListableBeanFactory.resolveDependency(DependencyDescriptor, String, Set<String>, TypeConverter),再进入 doResolveDependency(...)。它:

  1. 由 findAutowireCandidates(beanName, type, descriptor) 收集所有类型匹配的候选;
  2. 由 determineAutowireCandidate(candidates, descriptor) 选出唯一一个(依赖 @Primary / @Qualifier / matchesBeanName,见 1.2.7);
  3. 对选中的候选调用 descriptor.resolveCandidate(name, type, beanFactory),其内部就是 beanFactory.getBean(name)。

注意第 3 步:解析依赖本质上是一次嵌套的 getBean。所以当 A 注入 B 时,容器会递归去创建 B;如果 B 又要注入 A,就构成了环——循环依赖问题就在这一层暴露。

1.3.3 三级缓存的数据结构与查找顺序

三级缓存不是「三个 Map」这么简单,它们在 org.springframework.beans.factory.support.DefaultSingletonBeanRegistry 里各有明确类型(已核实):

级别字段类型存什么
一级singletonObjectsMap<String, Object>完全初始化好的单例
二级earlySingletonObjectsMap<String, Object>提前暴露的早期引用(可能已是代理)
三级singletonFactoriesMap<String, ObjectFactory<?>>能产出早期引用的工厂

查找由 getSingleton(String, boolean allowEarlyReference) 完成,顺序固定:先 singletonObjects,未命中且该 bean 正在创建(isSingletonCurrentlyInCreation)时查 earlySingletonObjects,再未命中且允许早期引用时查 singletonFactories,命中则调用 getObject() 并把结果升级到 earlySingletonObjects(同时从三级移除)。

写缓存的入口是 addSingletonFactory(String, ObjectFactory<?>);创建跟踪靠 singletonsCurrentlyInCreation 集合,由 beforeSingletonCreation / afterSingletonCreation 维护。三级缓存的作用不是「存三个副本」,而是把「何时能拿到早期引用」这件事延迟成一个工厂——只有真的发生循环依赖时才会调用工厂去生成早期引用,没有环时这一级完全不被触碰。

1.3.4 setter 循环依赖为什么能解

看 LoanService 与 NotificationService 的 setter 版本:

@Service
public class LoanService {
    private NotificationService notificationService;

    @Autowired
    public void setNotificationService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }
}
@Service
public class NotificationService {
    private LoanService loanService;

    @Autowired
    public void setLoanService(LoanService loanService) {
        this.loanService = loanService;
    }
}

创建 loanService 时的时间线:

getBean("loanService")
  └─ doCreateBean("loanService")
       1. createBeanInstance  → 造出 LoanService 空壳
       2. addSingletonFactory → 三级缓存登记「如何产出早期引用」
       3. populateBean       → 注入 notificationService
            └─ getBean("notificationService")
                 ├─ createBeanInstance  → 造出 NotificationService 空壳
                 ├─ addSingletonFactory → 三级缓存登记
                 └─ populateBean       → 注入 loanService
                      └─ getSingleton("loanService", true)
                           → 命中三级缓存,升级到二级,返回早期引用
  └─ initializeBean → 加入一级缓存 singletonObjects

关键在第 2 步:addSingletonFactory 用的工厂实际是 () -> getEarlyBeanReference(beanName, mbd, bean)。这个 getEarlyBeanReference 会调用 SmartInstantiationAwareBeanPostProcessor.getEarlyBeanReference,在循环依赖里也保证 AOP 代理只生成一次——这是三级缓存真正的设计意图,比「解环」本身更重要。

换个说法:如果只有「解环」需求,二级缓存(一个 earlySingletonObjects)就够了。多出来的三级缓存专门用来「把代理的生成时机推迟到确实需要时」,并保证无论环在哪里被打开,拿到的都是同一个代理对象。

时间线里「升级到二级」这一步值得单独说:一旦 getSingleton(..., true) 命中三级工厂并产出早期引用,容器会把它从 singletonFactories 移除、放进 earlySingletonObjects。这样同一个早期引用不会被工厂重复生成——后续再有别的 bean 注入它,直接命中二级即可,从而保证「一个 bean 只有一个早期引用」。

1.3.5 构造器循环依赖为什么必然失败

把注入改成构造器:

@Service
public class LoanService {
    private final NotificationService notificationService;

    public LoanService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }
}
@Service
public class NotificationService {
    private final LoanService loanService;

    public NotificationService(LoanService loanService) {
        this.loanService = loanService;
    }
}

启动直接抛 org.springframework.beans.factory.BeanCurrentlyInCreationException(已核实)。原因在顺序上:构造器参数必须在 createBeanInstance 之前解析完,而 addSingletonFactory 在 createBeanInstance 之后。要造 LoanService,得先拿到 NotificationService;要造 NotificationService,又得先拿到 LoanService——两个都还没造出空壳,没有任何早期引用可暴露。beforeSingletonCreation 会把名字加入 singletonsCurrentlyInCreation,第二次进来时检测到「已在创建中」且无早期引用,直接失败。

这不是缺陷而是取舍:构造器注入能保证依赖不可变与完整,代价是失去解环能力。官方因此推荐构造器注入 + 无环设计;真出现环,说明两个类职责耦合过紧,更该做的是重构而不是绕过去。

判断一个环会不会炸,最简办法是看注入点:字段/方法注入的环可解,构造器注入的环必炸;混合场景(一端构造器、一端 setter)同样必炸,因为构造器那一端在 createBeanInstance 之前就必须拿到完整对象。

1.3.6 @Lazy:代理破环

在注入点加 @Lazy 可以解构造器环:

@Service
public class LoanService {
    private final NotificationService notificationService;

    public LoanService(@Lazy NotificationService notificationService) {
        this.notificationService = notificationService;
    }
}

机制在 ContextAnnotationAutowireCandidateResolver.getLazyResolutionProxyIfNecessary(DependencyDescriptor, String)(已核实):当注入点标了 @Lazy,它不再返回真实 bean,而是返回一个通过 buildLazyResolutionProxy 生成的代理。代理在第一次调用方法时才去 getBean,此时目标早已创建完毕。

代价是:注入点拿到的是代理对象,getClass() 不是真实类型;代理只对方法调用生效,直接读字段拿不到真值;每次方法调用多一层代理开销;代理方法异常栈会多出代理帧,排障时容易迷惑。

1.3.7 ObjectProvider:延迟到用时

更「诚实」的做法是注入 ObjectProvider<T>:

@Service
public class LoanService {
    private final ObjectProvider<NotificationService> notificationProvider;

    public LoanService(ObjectProvider<NotificationService> notificationProvider) {
        this.notificationProvider = notificationProvider;
    }

    public void notifyReader(String memberId) {
        notificationProvider.getObject().send(memberId);
    }
}

resolveDependency 检测到注入类型是 ObjectProvider 时,返回的是 DefaultListableBeanFactory.DependencyObjectProvider(本机核实为包内嵌套类)。它只在 getObject() 被调用时才真正解析依赖,因此构造器阶段不需要目标存在,环自然断开。

ObjectProvider 还顺带解决「依赖可能不存在」:getIfAvailable() / getIfUnique() / stream() 提供了可选注入与多实例遍历,比 @Autowired(required=false) 更明确。

它与 @Lazy 的关键差别在于「谁承担延迟」:@Lazy 由容器生成代理,调用方无感,但类型与字段访问都会失真;ObjectProvider 把「何时取、取不到怎么办」的决定权交回调用方代码,行为更可预测,测试里也更容易替换成桩对象。因此需要破环时,优先选 ObjectProvider。

1.3.8 三种手段的代价对比

手段能否解构造器环拿到的是什么主要代价
三级缓存(默认)否真实对象仅支持单例 + setter/字段注入
@Lazy是代理对象类型失真、字段读不到值、多一层代理
ObjectProvider是容器句柄调用方需显式 getObject(),语义更啰嗦

选择原则:能重构消环就重构;必须保留环时,优先 ObjectProvider(语义清晰、无代理失真),只有在「不能改调用方代码」时才用 @Lazy。三级缓存是兜底而非鼓励——它能让老代码跑起来,但也让隐性的紧耦合长期存在。

还有一个容易被忽略的坑:prototype 作用域的 bean 之间形成环,三级缓存也救不了,因为原型 bean 不进单例缓存。此时必须用 @Lazy 或 ObjectProvider。

1.3.9 与代理的交互

循环依赖里最容易出问题的是「环 + AOP」。当 LoanService 同时被 @Transactional 或 @Async 增强时,早期引用需要的是代理而不是原始对象,这正是 getEarlyBeanReference 的职责所在:

  • 若没有三级缓存,populateBean 阶段注入的会是原始对象,而后续初始化时又生成了代理——同一个 bean 出现「被注入的是 A、最终注册的是代理 B」两个实例,破坏单例语义。
  • 有三级缓存时,getEarlyBeanReference 在环里就触发了 AbstractAutoProxyCreator 的代理生成,并把该代理缓存进二级,最终注入与最终注册的是同一个代理。

所以排查「注入的对象不是代理、导致 @Transactional 不生效」这类问题时,要确认该 bean 是否处在环里、以及它是否被 SmartInstantiationAwareBeanPostProcessor 正确接管。

1.3.10 知道之后能做什么

观测三级缓存。 在 getSingleton(String, boolean) 与 addSingletonFactory 上下断点,构造一个 setter 环,观察第 5 步命中 singletonFactories 的瞬间;再把注入改成构造器,观察 BeanCurrentlyInCreationException 抛在 beforeSingletonCreation。

验证代理只生成一次。 给两个互相依赖的 bean 都配上 @Transactional(触发 AOP),断点 getEarlyBeanReference,会看到它在环里只被调用一次,最终注入的早期引用就是最终代理。

排查真实报错。 生产里遇到 BeanCurrentlyInCreationException,先确认是构造器环还是字段环;构造器环只能用 @Lazy / ObjectProvider,字段环则要检查是不是 @Async、@Transactional 引入的代理导致 singletonsCurrentlyInCreation 状态异常。

小结

  • doCreateBean 的顺序是「实例化 → 注册早期引用工厂 → 注入」,对象先可被引用、后接依赖,这是解环的前提。
  • 三级缓存各司其职:一级放成品、二级放已升级的早期引用、三级放早期引用工厂;只在真发生环时才触碰三级。
  • 三级缓存真正的设计意图是「让代理只生成一次」,而不只是「解环」。
  • setter 环能解,构造器环必炸,根因是早期引用注册晚于构造器参数解析;prototype 环三级缓存也救不了。
  • @Lazy 用代理延迟解析、ObjectProvider 用句柄延迟解析,都能破构造器环,但各有类型失真或调用啰嗦的代价。

第 1 章到此结束。下一章进入自动配置:这些 @Conditional 到底怎么决定「这个定义要不要注册」,以及自动配置之间的加载顺序由什么决定。

阅读导航:上一节:1.2 Bean 定义注册与合并 · 下一节:2.1 自动配置类的加载顺序 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计