關於我對Spring迴圈依賴的思考

阿紫發表於2022-05-29

前言

在今天,依然有許多人對迴圈依賴有著爭論,也有許多面試官愛問迴圈依賴的問題,更甚至是在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物件是完備的,不需要去獲取鎖。

但以上的設計也很顯然無法達到該要求。

二級快取

以上問題其實可以簡化成如何將完備物件和不完備的物件區分開來?因為只要我們知道這個是完備物件,那麼直接返回,如果是不完備的物件,那麼就需要獲取鎖。

我們可以這樣,再加一級快取,第一級快取存放完備物件,第二級快取存放不完備的物件,由於此類物件是在Bean剛建立時放入快取中的,所以我們這裡把它稱作早期物件

此時,當我們需要獲取A物件時,我們只需判斷第一級快取有沒有A物件,如果有,說明A物件是完備的,可直接返回使用,如果沒有,說明A物件可能還沒建立或者是建立中,就繼續加鎖-->從二級快取獲取物件-->建立物件的邏輯

此時流程如下:

1、getBean('foo')

2、從一級快取中獲取foo,未獲取到

3、加鎖

4、從二級快取中獲取foo,未獲取到

5、建立foo物件

6、將foo物件放入二級快取

7、填充屬性

8、將foo物件放入一級快取,此時foo物件已經是個完備物件了

9、刪除二級快取中的foo物件

10、解鎖返回

基於現有流程,我們再來模擬一下循壞依賴時的情況

現在,既能解決物件的完備性問題,又能滿足我們的效能要求。perfect!

代理物件

要知道,Java裡不僅有普通物件,還有代理物件,那麼建立代理物件發生迴圈依賴時是否能夠滿足要求呢?

我們先來了解一下代理物件是什麼時候建立的?

在Spring中,建立代理物件邏輯是在最後一步,也就是我們常常說的【初始化後】

現在,我們嘗試把這部分邏輯加入到之前的流程中

顯而易見,最後的foo物件實際已經是個代理物件了,但bar依賴的物件依舊是個普通的foo物件!

所以,當出現代理物件迴圈依賴時,之前的流程並不能滿足要求!

那麼這個問題又應當如何解決呢?

思路

問題出現的原因就在於bar物件去獲取foo物件時,從二級快取中得到的foo物件是個普通的物件。

那麼有沒有辦法在這裡新增一些判斷,比如說判斷foo物件是不是要進行代理,如果是的話就去建立foo的代理物件,然後將代理物件proxy_foo返回。

我們先假設這個方案是可行的,再來看有沒有其他的問題

根據流程圖我們可以發現出一個問題:建立了兩次proxy_foo!

1、getBean('foo')流程中,填充屬性之後建立了一次proxy_foo

2、getBean('bar')的填充屬性時,從快取中獲取foo時,也建立了一次proxy_foo

而這兩個proxy_foo是不相同的!雖然proxy_foo中引用的foo物件是相同的,但這也是不可接受的。

這個問題又當如何解決?

三級快取

我們知道這兩次建立的proxy_foo是不相同的,那麼程式應當如何知道呢?也就是說,我們如果可以加一個標識,標識這個foo物件已經被代理過了,讓程式直接使用這個代理的就可以了,不要再去建立代理了。是不是就解決這個問題了呢?

這個標識可不是什麼flag=ture or false之類的,因為就算程式知道foo已經被代理過了,那程式還是得把proxy_foo拿到才行,也就是說,我們還得找個地方把proxy_foo存起來。

這個時候我們就需要再加一級快取。

邏輯如下:

1、當從快取中獲取foo時,且foo被代理了之後,就將proxy_foo放入這一級快取中。

2、在getBean('foo')流程中,建立代理物件時,先在快取中檢視是否有代理物件,如果有則使用該代理物件

這裡你可能會有疑問:不是說先判斷三級快取有沒有,沒有再去建立proxy_foo嘛?怎麼不管有沒有都去建立?

是的,這裡不管如何都去建立了proxy_foo,只是最後判斷三級快取有沒有,有的話就使用三級快取裡的,之前建立的proxy_foo就不要了。

原因是這樣的,我們知道建立代理物件的邏輯是在Bean【初始化後】這一流程當中的某個後置處理器當中完成的,而後置處理器是可以由使用者自定義實現的,那麼反過來說就表示Spring是無法控制這一部分邏輯的。

我們可以這樣假設,我們自己也實現了一個後置處理器,這個處理器的作用不是建立代理物件proxy_foo,而是把foo替換成dog, 如果按之前的想法(只判斷是否為代理物件)你就會發現這樣的問題:getBean('foo')返回的是dog,但是bar物件依賴的是foo。

但是如果我們將【建立代理物件】這一邏輯看成只是眾多後置處理器中的一個實現。

1、在從快取中取foo時,呼叫一系列的後置處理器,然後將後置處理器返回的最終結果放入三級快取。

2、在getBean('foo')時,同樣呼叫一系列的後置處理器,然後從三級快取獲取foo對應的物件,得到了就使用它,否則使用後置處理器返回結果。

你就會發現,隨便你怎麼折騰,getBean('foo')返回的物件與bar物件依賴的foo永遠是同一個物件。

以上即為Spring對於迴圈依賴的解決方案

我對Spring這部分設計的思考

先總體回顧一下Spring的設計,Spring中採用了三級快取

1、第一級快取存放完備的bean物件

2、第二級快取存放的是匿名函式

3、第三級快取存放的是從第二級快取中匿名函式返回的物件

是的,Spring將我們說的[從二級快取中獲取foo, 呼叫後置處理器]這兩個步驟直接做成了一個匿名函式

它的結構如下:

private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);
@FunctionalInterface
public interface ObjectFactory<T> {

    T getObject() throws BeansException;

}

函式內容即為呼叫一系列後置處理器

protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
  Object exposedObject = bean;
  if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {
    for (BeanPostProcessor bp : getBeanPostProcessors()) {
      if (bp instanceof SmartInstantiationAwareBeanPostProcessor) {
        SmartInstantiationAwareBeanPostProcessor ibp = (SmartInstantiationAwareBeanPostProcessor) bp;
        exposedObject = ibp.getEarlyBeanReference(exposedObject, beanName);
      }
    }
  }
  return exposedObject;
}

對於這部分設計,一直存在著一些爭議:Spring中到底使用幾級快取可以解決迴圈依賴?

觀點一

普通物件發生迴圈依賴時二級快取即可以解決,但代理物件發生迴圈依賴時需要三級快取才可以

這也算是一個普遍的觀點

這個觀點的角度是用二級快取時,發生迴圈依賴會不會出bug,認為是普通物件不會,代理物件會。

換句話說:在發生多迴圈依賴時,多次從快取中獲取物件,每次得到的物件是否相同?

舉例來說,A物件依賴B物件,B物件依賴A物件和C物件,C物件依賴A物件。

getBean('A')流程如下

在該流程中,A物件從快取中獲取了兩次。

現在,我們結合從快取中獲取物件的過程來思考一下。

當只有二級快取時的邏輯:

1、呼叫二級快取中的匿名函式獲取物件

2、返回物件

假設匿名函式中返回原物件,沒有建立代理邏輯——這裡嚴格來說是沒有後置處理器的邏輯

那麼每次【呼叫二級快取中的匿名函式獲取物件】時返回的A物件都是同一個。

所以得出普通物件在只有二級快取時沒有問題。

假設匿名函式中會觸發建立代理的邏輯,匿名函式返回的是代理物件。

那麼每次【呼叫二級快取中的匿名函式獲取物件】是都會建立代理物件。

每次建立的代理物件都是個新物件,故每次返回的A物件都不是同一個。

所以得出代理物件在只有二級快取時會出現問題。

那麼為什麼三級快取可以呢?

三級快取時的邏輯:

1、先嚐試從三級快取中獲取,未獲取到

2、呼叫二級快取中的匿名函式獲取物件

3、將物件放入三級快取

4、刪除二級快取中的匿名函式

5、返回物件

所以在第一次從快取獲取時會呼叫匿名函式建立代理物件,往後每次獲取時都是直接從第三級快取取出返回。

綜上所述,該觀點是佔得住腳的。

但我更希望這個觀點換個更嚴謹說法:當每次匿名函式返回的物件是一致時,二級快取足以;當每次匿名函式返回的物件不一致時,需要有第三級快取

觀點二

該觀點也是我自己的觀點:從設計的角度出發,只有三級快取才能保證框架的擴充套件性和健壯性。

當我們回顧觀點一的結論,你就會發現一個十分矛盾的地方:Spring如何才能得知匿名函式返回的物件是一致的?

匿名函式中的邏輯是呼叫一系列的後置處理器,而後置處理器是可自定義的。

意思就是匿名函式返回了什麼,這件事本身就不受Spring所控制。

這時我們再借用三級快取看這個問題,就會發現:無論匿名函式返回的物件是否一致,三級快取都能有效的解決迴圈依賴的問題。

從設計來看,三級快取的設計是可以包含二級快取所達到的需求的。

所以我們可以得出:使用三級快取的設計將比二級快取的設計有更好的擴充套件性和健壯性。

如果用觀點一的看法去設計Spring框架,那得加一大堆邏輯判斷,如果用觀點二,那隻需加一層快取。

小結

本篇文章的初衷是想寫我對Spring迴圈依賴的思考,但為了能夠說清楚這件事,還是詳細的描述了Spring解決迴圈依賴的設計。

以至於最後我想表達自己的思考時,只有寥寥幾句,因為大部分思考我已寫在了【Spring是如何解決迴圈依賴的】章節。

最後,希望大家有所收穫,如果有疑問可找我詢問,或者在評論區留下你的思考。


如果我的文章對你有所幫助,還請幫忙點贊、關注、轉發一下,你的支援就是我更新的動力,非常感謝!
個人部落格空間:https://zijiancode.cn

相關文章