七年老码农聊 Spring 循环依赖:从踩坑到装 X 的自救指南

七年老码农聊 Spring 循环依赖:从踩坑到装 X 的自救指南

各位老铁好,我是那个在 Spring 坑里摸爬滚打了七年的老油条。还记得刚接手祖传项目时,启动服务器突然报BeanCurrentlyInCreationException,百度半小时才知道这玩意儿叫 “循环依赖”。当时老员工丢来一句:“Spring 默认能解决 setter 循环依赖,但构造器的救不了”,听得我云里雾里。今天就把这七年踩过的坑、啃过的源码、装过的 X 全抖出来,咱用人话聊技术,顺便穿插点当年被 PM 催上线的辛酸史。

一、先搞懂啥是循环依赖:对象创建像玩拼图,卡住了!

1. 三种循环依赖场景:像三个人互相借橡皮

假设我们有三个类:A依赖B,B依赖C,C又依赖A,这就是三级循环依赖。不过日常最多遇到的是二级循环,比如UserService依赖UserDao,UserDao又依赖UserService。Spring 里循环依赖分三种类型,就像三种借橡皮的方式:

  • 构造器循环依赖:A的构造器需要B,B的构造器需要A。这就像两个人搬砖,A 说 “你先给我铲子我才搬”,B 说 “你先给我推车我才给铲子”,谁也动不了,直接卡死(Spring 解决不了这种,后面细说)。
  • setter 循环依赖:A通过 setter 方法注入B,B通过 setter 注入A。好比 A 和 B 互相留纸条 “等你准备好了告诉我”,Spring 能当和事佬,先给半成品,后续再补全。
  • 字段注入循环依赖:和 setter 类似,只是用@Autowired直接注字段,本质上也是 setter 方式,Spring 同样能解决。

我第一次遇到的是 setter 循环依赖,当时在两个工具类里互相注入,启动时没报错,跑了半小时突然空指针 —— 原来是其中一个对象还没完全初始化就被用了,吓得我连夜买了杯奶茶压惊。

二、Spring 如何解决 setter 循环依赖?三级缓存像快递中转站

1. 三级缓存揭秘:老码农带你扒源码

Spring 有三个 “快递柜”(缓存)来存对象的不同状态,存在DefaultSingletonBeanRegistry类里:

  • 一级缓存(singletonObjects):存完全初始化好的对象,相当于 “成品快递”,直接能用。
  • 二级缓存(earlySingletonObjects):存刚通过构造器创建,还没执行 setter 和初始化方法的 “半成品对象”,相当于 “拆开但没组装的零件”。
  • 三级缓存(singletonFactories):存一个工厂对象,能生成半成品对象的代理(比如 AOP 代理),相当于 “零件图纸”,需要时现造。

流程大概是这样的(以A依赖B,B依赖A为例):

  1. 创建A,先调用构造器生成一个空壳对象(没注入B),放进三级缓存。
  1. 发现A需要B,去创建B,同样生成空壳对象,放进三级缓存。
  1. B需要A,这时候从三级缓存拿到A的半成品(空壳),注入到B里,B就有了A的引用(虽然是半成品,但能用)。
  1. B初始化完,放进一级缓存,回到A的流程,把B的成品注入到A里,A也初始化完,放进一级缓存。

这就像 A 和 B 互相借工具:先给对方一个 “临时工具”(半成品对象),等对方完全准备好了,再把 “正式工具” 换上。Spring 通过三级缓存,让对象在没完全初始化时就能被引用,避免了死锁。

2. 为什么构造器循环依赖救不了?

因为构造器注入必须在对象创建时就拿到完整的依赖,而 Spring 无法在构造器阶段提供一个还没创建好的对象。就像你去奶茶店点单,店员说 “先付钱才能做奶茶”,你说 “等奶茶做好我才有钱”,两边僵住了。这时候只能改代码,比如用 setter 注入,或者加@Lazy懒加载(后面会讲)。

三、实战场景:当 PM 突然让你改祖传代码

1. 场景一:老项目里的互相注入,启动报错

假设你遇到这样的代码:

// UserService.java
@Service
public class UserService {
    private final UserDao userDao; // 构造器注入,致命!
    
    @Autowired
    public UserService(UserDao userDao) {
        this.userDao = userDao;
    }
}
// UserDao.java
@Service
public class UserDao {
    private final UserService userService; // 构造器注入,循环了!
    
    @Autowired
    public UserDao(UserService userService) {
        this.userService = userService;
    }
}

启动直接报错Requested bean is currently in creation,这时候有三种解法:

  • 解法一:改成 setter 注入(推荐)
@Service
public class UserService {
    private UserDao userDao;
    @Autowired
    public void setUserDao(UserDao userDao) {
        this.userDao = userDao;
    }
}

就像把 “必须当场给工具” 改成 “等会儿给也行”,Spring 就能介入处理。

  • 解法二:加@Lazy懒加载在注入的地方加@Lazy,让 Spring 先注入一个代理对象,真正用的时候再创建真实对象。适合非必须立即使用的场景,比如:
@Autowired
@Lazy
public UserService(UserDao userDao) {
    this.userDao = userDao;
}

但小心!如果过早调用代理对象的方法,还是会出问题,我曾因为在初始化方法里调了代理对象,导致半夜被叫起来改 bug。

  • 解法三:拆分成中间层比如加一个UserRepository类,让UserService和UserDao都依赖它,打破循环。适合代码重构时用,虽然麻烦,但一劳永逸。

2. 场景二:AOP 导致的循环依赖,半成品对象有坑!

如果你的类有 AOP 代理(比如加了@Transactional),三级缓存会生成代理对象。假设A依赖B的代理,B依赖A的代理,Spring 会在二级缓存里存代理对象,而不是原始对象。我曾遇到过这种情况:调用对象的方法时,发现是代理类的方法,而不是原始类的,debug 了两小时才发现是循环依赖导致的代理提前生成。这时候记住:三级缓存主要是为了处理 AOP 代理,正常情况下不用关心,除非你在玩自定义 BeanPostProcessor

四、避坑指南:这 3 个坑别踩,我替你们交过学费了

1. 构造器注入别滥用!除非你想体验启动爆炸

刚学 Spring 时,我迷信 “构造器注入更安全”,结果在两个 Service 里互相用构造器注入,启动直接 GG。记住:构造器注入适合依赖必须存在的场景,而 setter 注入适合可选依赖或可能循环的场景。就像买电脑,电源是构造器注入(必须有),键盘是 setter 注入(可有可无)。

2. 别在循环依赖里玩复杂初始化

有一次我在@PostConstruct方法里调用了依赖对象的方法,结果因为对象还是半成品,导致数据没加载完就被用了。记住:循环依赖中对象是分阶段初始化的,先保证能创建出来,再考虑初始化逻辑,别在半成品上动太多手脚。

3. 怀疑循环依赖?先查这两步

  • 第一步:看启动日志,搜BeanCurrentlyInCreationException,定位到具体的类。
  • 第二步:用spring-boot-starter-actuator的端点/beans,查看 Bean 的依赖关系,我曾用这招揪出深藏在三层调用里的循环依赖,省时省力。

五、总结:循环依赖就像职场甩锅,学会化解才是本事

搞懂循环依赖后,我现在看代码就像老中医把脉:看到两个类互相@Autowired,先判断是构造器还是 setter 注入,再想 Spring 能不能处理。总结几个核心点:

  • 能不用循环依赖就不用:大部分循环依赖都是设计问题,比如 Service 层和 Dao 层互相调,很可能是职责划分不清,拆分成工具类或中间层更香。
  • 相信 Spring 但别依赖它:Spring 能解决 setter 循环依赖,但不是让你故意写循环代码,代码整洁度永远优先。
  • 源码是最好的老师:当年我对着getEarlyBeanReference方法啃了三天,才搞懂三级缓存的作用,现在遇到问题先看源码,比百度快多了。

最后送大家一句打油诗:循环依赖别慌张,先看注入啥模样,构造器错 Setter 扛,三级缓存来帮忙,设计问题早解决,代码整洁才是王!

如果你在项目里遇到诡异的 Bean 创建问题,不妨想想是不是循环依赖在搞鬼。有啥问题欢迎留言,咱们一起聊聊那些年被 Spring 坑过的日子~

你可能感兴趣的:(spring,java,后端)