👉 这是一个或许对你有用的社群
🐱 一对一交流/面试小册/简历优化/求职解惑,欢迎加入「芋道快速开发平台」知识星球。下面是星球提供的部分资料:
《项目实战(视频)》:从书中学,往事中“练”
《互联网高频面试题》:面朝简历学习,春暖花开
《架构 x 系统设计》:摧枯拉朽,掌控面试高频场景题
《精进 Java 学习指南》:系统学习,互联网主流技术栈
《必读 Java 源码专栏》:知其然,知其所以然
👉这是一个或许对你有用的开源项目
国产 Star 破 10w+ 的开源项目,前端包括管理后台 + 微信小程序,后端支持单体和微服务架构。
功能涵盖 RBAC 权限、SaaS 多租户、数据权限、商城、支付、工作流、大屏报表、微信公众号等等功能:
Boot 地址:https://gitee.com/zhijiantianya/ruoyi-vue-pro
Cloud 地址:https://gitee.com/zhijiantianya/yudao-cloud
视频教程:https://doc.iocoder.cn
来源:juejin.cn/post/
7266969577408888892
这两天,星球群里有人问到:Spring如何解决循环依赖。
这其实是一个老生常谈的问题,原来是有人在面试看机会,没刷面试题,跑群里灵魂拷问星主来了。既然如此,我就给大家安排安排!
![d95dad5516b01ff12808dc4137b5fa38.jpeg](https://i-blog.csdnimg.cn/blog_migrate/a69feb410b010939c534a783daaeae24.jpeg)
看到网上的各种文章的发布时间,这个题目应该是老面试题了,可能比我的码龄长。有很多结合源码来进行解读的文章,但是大多数,是在描述Spring如何解决循环依赖,但是比较少会讲解为什么这么设计。今天自己写了一下简易的代码,结合这个简易的代码来说说我的理解。
什么是循环依赖
首先说明一下,本文讨论的循环依赖仅针对scope为singleton
,且非构造函数注入的bean。如果是prototype
的bean,或者使用的是构造函数注入,Spring会直接抛出BeanCurrentlyInCreationException
异常,并不会去通过什么手段解决这些循环依赖。
我们在使用Spring的过程中,会有很多bean依赖注入的场景。因为没有严格的规范约束,我们在使用的过程中,比较容易就会产生beanA依赖beanB,而beanB又依赖beanA的情况。
![b185f7d188e0bdf0d703a1c13354e49d.jpeg](https://i-blog.csdnimg.cn/blog_migrate/da6731537eb73efc64439933116ea84f.jpeg)
这时,我们在创建beanA的输入,发现要注入beanB,就去尝试创建beanB,又发现要注入beanA,又要去创建beanA。至此,我们发现创建beanA依赖创建beanA,形成了死循环。
![23648437a884083090ea960b3a9f90f0.jpeg](https://i-blog.csdnimg.cn/blog_migrate/5a0c514a870fd491d7294c483a3f229b.jpeg)
基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/ruoyi-vue-pro
视频教程:https://doc.iocoder.cn/video/
解决思路
其实要打破上述这个循环的链条,关键点在于,将bean实例化和bean属性注入这2步分开,且允许在属性注入的时候,注入一个已经实例化但还未进行属性注入的bean。即让一个已经实例化的bean,提前暴露出来,可以被其他bean拿到引用进行属性注入,而这个提前暴露的bean的属性输入可以在后续过程中再完成,因为我们的目标bean在进行属性注入的时候,只要拿到这个提前暴露bean的引用即可。
这个思路也跟上面说的不支持构造方法输入的bean循环依赖是呼应的,因为实例化这一步就使用到了构造方法,如果是构造方法注入,这个bean都无法实例化出来,就没有可能进行提前暴露了。顺着这个思路,我们很自然可以想到使用一个map来保存那些实例化之后的bean,这个bean可能仅仅是实例化,还未进行属性注入,其他bean如果依赖它,就可以从这map中获取到并进行注入。
![1290290b4615b8cfcd5f64d79b95424f.jpeg](https://i-blog.csdnimg.cn/blog_migrate/8cf775a78ed3e454e49be6706ebacb9d.jpeg)
基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/yudao-cloud
视频教程:https://doc.iocoder.cn/video/
示例代码
根据上面的思路,我们尝试使用代码进行实现。核心是为了说明如何解决循环依赖,我们对其他部分做了一定的简化:定义2个类,BeanA和BeanB,BeanA中有个BeanB类型的属性b,BeanB中有个BeanA类型的属性a;我们的目标是使用上面解决循环依赖的思路,构造出2个的对象,且对象互相持有对方的引用。
我们先定义2个bean类,各自持有对方类型的一个属性:
@Data
public class BeanA {
private BeanB b;
}
@Data
public class BeanB {
private BeanA a;
}
定义一个CycleDependency类,在main方法中模拟bean加载的过程,构造出2个对象:
public class CycleDependency {
private static final Map<Class<?>, Object> map = new HashMap<>(2);
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
// 模拟获取到需要加载的bean
Class<?>[] classes = new Class[]{BeanA.class, BeanB.class};
// 遍历列表加载bean
for (Class<?> item: classes) {
getBean(item);
}
// 断言校验记载到bean的属性
assert Objects.requireNonNull(getBean(BeanA.class)).getB() == getBean(BeanB.class);
assert Objects.requireNonNull(getBean(BeanB.class)).getA() == getBean(BeanA.class);
}
private static <T> T getBean(Class<T> clazz) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
// 查看缓存中是否存在
if (map.containsKey(clazz)) {
if (clazz.isInstance(map.get(clazz))) {
return clazz.cast(map.get(clazz));
}
return null;
}
// 通过构造方法实例化bean
Object object = clazz.getDeclaredConstructor().newInstance();
// 将构造出来的bean放入缓存,提前暴露引用;注意,这里的bean还没有做属性注入
map.put(clazz, object);
// 模拟属性注入
for (Field field : clazz.getDeclaredFields()) {
field.setAccessible(true);
Class<?> fieldType = field.getType();
field.set(object, map.getOrDefault(fieldType, getBean(fieldType)));
}
// 返回构造出来的bean
if (clazz.isInstance(object)) {
return clazz.cast(object);
}
return null;
}
}
代码解读
主要解读一下CycleDependency
这个类,总共代码行数也不到50,我们直接从上往下解读,为了方便,我进行了截图和分块。
![1fb4850ffe7cd48358d29994a6c40b7b.jpeg](https://i-blog.csdnimg.cn/blog_migrate/d9131b80a43dfec7addb3ffda8b0eb67.jpeg)
最上面,定义了一个map,为了方便说明,我这里key使用的是Class(Spring的三级缓存都是使用String,bean的名称),value就是bean的实例对象。
在main方法中,我们分成了3块:
模拟获取到需要加载的bean,这里就直接是BeanA和BeanB的class数组
遍历步骤1的列表,调用
getBean()
方法去加载bean对于获取到的bean,校验是否相互持有对方的引用
另外,对于第3点assert,需要在idea开启vm参数-ea才会生效;如果实在不生效,可以直接打印出是否相等的结果在控制台进行查看。
核心的代码其实是这个getBean()
方法,我们接下来看下这个方法
![bbfe9e7a964d4a0f88d2e33e9042a37f.jpeg](https://i-blog.csdnimg.cn/blog_migrate/8355019970d8230e1076407242966f9e.jpeg)
查看在map缓存中是否已经存在了,如果存在了就直接返回
调用构造方法实例化bean
将构造出来的对象放到缓存map中进行提前暴露,注意,这里的bean还没有进行属性注入
利用反射获取bean的属性,利用
field.set
模拟属性注入;因为我们一直知道属性只能是BeanA或者BeanB,这里也尝试先从缓存获取,如果获取不到就调用getBean()
方法递归获取返回构造好的对象,这个对象已经进行了属性注入了
进一步思考
上面我们利用一个map做缓存,模拟了一下最简易的处理循环依赖的情况。可以看到,我们只有一级缓存map,就解决了循环依赖,那么Spring为什么要使用三级缓存来处理循环依赖呢?
为什么有第2级
细心的你一定已经发现了,上面的缓存map存在一个问题,就是存放到这个map中的bean,并不保证已经完全可用了,我们在实例化之后,属性注入之前,就为了提前暴露,把bean对象存放到这个map中,而Spring肯定需要另外一级缓存,只存在已经完全可用的bean。所以,我们可以对上面代码做一下改造,新定义一个map变量singletonObjects,存放已经完全可用的bean,我们原始代码中的map,作为第2级缓存使用。
简单修改上述代码,增加1级缓存,现在我们使用了2级缓存来解决存换依赖,同时还保证了在1级缓存singletonObjects中的bean都是属性注入后的bean。
public class CycleDependency {
// 一级缓存,存放完全可用的bean
private static final Map<Class<?>, Object> singletonObjects = new HashMap<>(4);
// 二级缓存,存放的bean可能还未进行属性注入
private static final Map<Class<?>, Object> map = new HashMap<>(4);
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
// 模拟获取到需要加载的bean
Class<?>[] classes = new Class[]{BeanA.class, BeanB.class};
// 遍历列表加载bean
for (Class<?> item: classes) {
getBean(item);
}
// 断言校验记载到bean的属性
assert Objects.requireNonNull(getBean(BeanA.class)).getB() == getBean(BeanB.class);
assert Objects.requireNonNull(getBean(BeanB.class)).getA() == getBean(BeanA.class);
}
private static <T> T getBean(Class<T> clazz) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
// 查看一级缓存中是否存在
if (singletonObjects.containsKey(clazz)) {
if (clazz.isInstance(singletonObjects.get(clazz))) {
return clazz.cast(singletonObjects.get(clazz));
}
return null;
}
// 查看二级缓存中是否存在
if (map.containsKey(clazz)) {
if (clazz.isInstance(map.get(clazz))) {
return clazz.cast(map.get(clazz));
}
return null;
}
// 通过构造方法实例化bean
Object object = clazz.getDeclaredConstructor().newInstance();
// 将构造出来的bean放入缓存,提前暴露引用;注意,这里的bean还没有做属性注入
map.put(clazz, object);
// 模拟属性注入
for (Field field : clazz.getDeclaredFields()) {
field.setAccessible(true);
Class<?> fieldType = field.getType();
field.set(object, map.getOrDefault(fieldType, getBean(fieldType)));
}
// 属性注入后,放入一级缓存
singletonObjects.put(clazz, object);
// 返回构造出来的bean
if (clazz.isInstance(object)) {
return clazz.cast(object);
}
return null;
}
}
为什么有第3级
Spring中bean的创建过程比我们上述的代码示例会复杂很多,还有一个重要的步骤是,生成代理对象。我们上面构造出来的beanA和beanB都是原始类型BeanA和BeanB的对象,但是Spring中还有一个重要的功能点是aop,而aop就是通过动态代理,生成原始类型的代理类型,进而把原始对象包装成代理对象来实现的。我们上述的2级缓存,只处理的了原始对象,但是还未涉及到代理对象。
那这里又会有新的疑问,即使需要代理对象,我们可以进一步改造上述代码,在调用构造方法后,直接去生成代理对象,再放入二级缓存,这样,后面在属性注入步骤完成后,在一级缓存中放入的也是代理对象,这样不也是可以使用二级缓存来解决循环依赖吗?
这个问题我也思考了很久,看了网上很多资料,目前我思考的结论是:这是为了满足Spring的1种设计的思想:尽量延迟去生成代理对象
。如果在2级缓存中,提前暴露的就是代理对象,只从解决循环依赖这个问题的角度,应该是可行的,但是,这样让这个代理对象提前暴露,可能会带来额外的一些安全风险,不满足尽量延迟去生成代理对象
这一指导思想。这一点,如果大家有别的见解,我们一起在评论区讨论。
最后
我们在文章开头提到过:本文讨论的循环依赖仅针对scope为singleton
,且非构造函数注入的bean。为什么“非构造函数注入”,应该已经解释过了,因为如果是构造函数注入,无法进行实例化这一步,更不用说提前暴露了。但是还未没有说明为什么“scope为singleton
”。与Spring中默认的scope=singleton
对象的,还有1种scope=prototype
,从上面的解决存换依赖的思路可知,我们使用了一个map来缓存提前暴露的对象,所以,我们在目标bean属性注入的时候,从map中拿到的是同一个beanA的对象,如果这个scope=prototype
,意味着,我们这里需要新建一个bean,不能使用缓存中的bean,所以上面的思路是无法解决的多例bean的循环依赖的。
欢迎加入我的知识星球,全面提升技术能力。
👉 加入方式,“长按”或“扫描”下方二维码噢:
星球的内容包括:项目实战、面试招聘、源码解析、学习路线。
文章有帮助的话,在看,转发吧。
谢谢支持哟 (*^__^*)