前言
在今天,依然有许多人对循环依赖有着争论,也有许多面试官爱问循环依赖的问题,更甚至是在Spring中只问循环依赖,在国内,这彷佛成了Spring的必学知识点,一大特色,也被众多人津津乐道。而我认为,这称得上Spring框架里众多优秀设计中的一点污渍,一个为不良设计而妥协的实现,要知道,Spring整个项目里也没有出现循环依赖的地方,这是因为Spring项目太简单了吗?恰恰相反,Spring比绝大多数项目要复杂的多。同样,在Spring-Boot 2.6.0 Realease Note中也说明不再默认支持循环依赖,如要支持需手动开启(以前是默认开启),但强烈建议通过修改项目来打破循环依赖。
本篇文章我想来分享一下关于我对循环依赖的思考,当然,在这之前,我会先带大家温故一些关于循环依赖的知识。
依赖注入
由于循环依赖是在依赖注入的过程中发生的,我们先简单回顾一下依赖注入的过程。
案例:
@Component public class Bar { }
@Component public class Foo { @Autowired private Bar bar; }
@ComponentScan(basePackages = "com.my.demo") public class Main { public static void main(String[] args) { AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Main.class); context.getBean("foo"); } }
以上为一个非常简单的Spring入门案例,其中Foo
注入了Bar
, 该注入过程发生于context.getBean("foo")
中。
过程如下:
1、通过传入的"foo", 查找对应的BeanDefinition, 如果你不知道什么是BeanDefinition,那你可以把它理解成封装了bean对应Class信息的对象,通过它Spring可以得到beanClass以及beanClass标识的一些注解。
2、使用BeanDefinition中的beanClass,通过反射的方式进行实例化,得到我们所谓的bean(foo)。
3、解析beanClass信息,得到标识了Autowired
注解的属性(bar)
4、使用属性名称(bar),再次调用context.getBean('bar')
,重复以上步骤
5、将得到的bean(bar)设值到foo的属性(bar)中
以上为简单的流程描述
什么是循环依赖
循环依赖其实就是A依赖B, B也依赖A,从而构成了循环,从以上例子来讲,如果bar里面也依赖了foo,那么就产生了循环依赖。
image-20220528105342065
Spring是如何解决循环依赖的
getBean这个过程可以说是一个递归函数,既然是递归函数,那必然要有一个递归终止的条件,在getBean中,很显然这个终止条件就是在填充属性过程中有所返回。那如果是现有的流程出现Foo依赖Bar,Bar依赖Foo的情况会发生什么呢?
1、创建Foo对象
2、填充属性时发现Foo对象依赖Bar
3、创建Bar对象
4、填充属性时发现Bar对象依赖Foo
5、创建Foo对象
6、填充属性时发现Foo对象依赖Bar....
foo_bar
很显然,此时递归成为了死循环,该如何解决这样的问题呢?
添加缓存
我们可以给该过程添加一层缓存,在实例化foo对象后将对象放入到缓存中,每次getBean时先从缓存中取,取不到再进行创建对象。
缓存是一个Map,key为beanName, value为Bean,添加缓存后的过程如下:
1、getBean('foo')
2、从缓存中获取foo,未找到,创建foo
3、创建完毕,将foo放入缓存
4、填充属性时发现Foo对象依赖Bar
5、getBean('bar')
6、从缓存中获取bar,未找到,创建bar
7、创建完毕,将bar放入缓存
8、填充属性时发现Bar对象依赖Foo
9、getBean('foo')
10、从缓存中获取foo,获取到foo, 返回
11、将foo设值到bar属性中,返回bar对象
12、将bar设置到foo属性中,返回
以上流程在添加一层缓存之后我们发现确实可以解决循环依赖的问题。
多线程出现空指针
你可能注意到了, 当出现多线程情况时,这一设计就出现了问题。
我们假设有两个线程正在getBean('foo')
1、线程一正在运行的代码为填充属性,也就是刚刚将foo放入缓存之后
2、线程二稍微慢一些,正在运行的代码是:从缓存中获取foo
此时,我们假设线程一挂起,线程二正在运行,那么它将执行从缓存中获取foo这一逻辑,这时你就会发现,线程二得到了foo,因为线程一刚刚将foo放入了缓存,而且此时foo还没有被填充属性!
如果说线程二得到这个还没有设值(bar)的foo对象去使用,并且刚好用了foo对象里面的bar属性,那么就会得到空指针异常,这是不能为允许的!
那么我们又当如何解决这个新的问题呢?
加锁
解决多线程问题最简单的方式便是加锁。
我们可以在【从缓存获取】前加锁,在【填充属性】后解锁。
如此,线程二就必须等待线程一完成整个getBean流程之后才在缓存中获取foo对象。
我们知道加锁可以解决多线程的问题,但同样也知道加锁会引起性能问题。
试想,加锁是为了保证缓存里的对象是一个完备的对象,但如果当缓存里的所有对象都是完备的了呢?或者说有部分对象已经是完备了的呢?
假设我们有A、B、C三个对象
1、A对象已经创建完毕,缓存中的A对象是完备的
2、B对象还在创建中,缓存中的B对象有些属性还没填充完毕
3、C对象还未创建
此时我们想要getBean('A'), 那我们应该期望什么?我们是否期望直接从缓存中获取到A对象返回?或者还是等待获取锁之后才能得到A对象?
很显然我们更加期望直接获取到A对象返回就可以了,因为我们知道A对象是完备的,不需要去获取锁。
但以上的设计也很显然无法达到该要求。