可以,很強,68行程式碼實現Bean的非同步初始化,粘過去就能用。

2023-06-05 15:00:53

你好呀,我是歪歪。

前兩天在看 SOFABoot 的時候,看到一個讓我眼前一亮的東西,來給大家盤一下。

SOFABoot,你可能不眼熟,但是沒關係,本文也不是給你講這個東西的,你就認為它是 SpringBoot 的變種就行了。

因為有螞蟻金服背書,所以主要是一些金融類的公司在使用這個框架:

官方介紹是這樣的:

SOFABoot 是螞蟻金服開源的基於 Spring Boot 的研發框架,它在 Spring Boot 的基礎上,提供了諸如 Readiness Check,類隔離,紀錄檔空間隔離等能力。在增強了 Spring Boot 的同時,SOFABoot 提供了讓使用者可以在 Spring Boot 中非常方便地使用 SOFA 中介軟體的能力。

上面這些功能都很強大,但是我主要是分享一下它的這個小功能:

https://help.aliyun.com/document_detail/133162.html

這個功能可以讓 Bean 的初始化方法在非同步執行緒裡面執行,從而加快 Spring 上下文載入過程,提高應用啟動速度。

為什麼看到功能的時候,我眼前一亮呢,因為我很久之前寫過這篇文章《我是真沒想到,這個面試題居然從11年前就開始討論了,而官方今年才表態。》

裡面提到的面試題是這樣的:

Spring 在啟動期間會做類掃描,以單例模式放入 ioc。但是 spring 只是一個個類進行處理,如果為了加速,我們取消 spring 自帶的類掃描功能,用寫程式碼的多執行緒方式並行進行處理,這種方案可行嗎?為什麼?

當時通過 issue 找到了官方對於這個問題回覆總結起來就是:應該是先找到啟動慢的根本原因,而不是把問題甩鍋給 Spring。這部分對於 Spring 來說,能不動,就別動。

僅從「啟動加速-非同步初始化方法」這個標題上來看,Spring 官方不支援的東西 SOFABoot 支援了。所以這玩意讓我眼前一亮,我倒要看看你是怎麼搞得。

先說結論:SOFABoot 的方案能從一定程度上解決問題,但是它依賴於我們編碼的時候指定哪些 Bean 是可以非同步初始化的,這樣帶來的好處是不必考慮迴圈依賴、依賴注入等等各種複雜的情況了,壞處就是需要程式設計師自己去識別哪些類是可以非同步初始化的。

我倒是覺得,程式設計師本來就應該具備「識別自己的專案中哪些類是可以非同步初始化」的能力。

但是,一旦要求程式設計師來主動去識別了,就已經「輸了」,已經不夠驚豔了,在實現難度上就不是一個級別的事情了。人家 Spring 想的可是框架給你全部搞定,頂多給你留一個開關,你開箱即用,啥都不用管。

但是總的來說,作為一次思路演變為原始碼的學習案例來說,還是很不錯的。

我們主要是看實現方案和具體邏輯程式碼,以 SOFABoot 為抓手,針對其「非同步初始化方法」聚焦下鑽,把原始碼當做紐帶,協同 Spring,打出一套「我看到了->我會用了->我拿過來->我看懂了->是我的了->寫進簡歷」的組合拳。

Demo

先搞個 Demo 出來,演示一波效果,先讓你直觀的看到這是個啥玩意。

這個 Demo 非常之簡單,幾行程式碼就搞定。

先搞兩個 java 類,裡面有一個 init 方法:

然後把他們作為 Bean 交給 Spring 管理,Demo 就搭建好了:

直接啟動專案,啟動時間只需要 1.152s,非常絲滑:

然後,注意,我要稍微的變一下形。

在注入 Bean 的時候觸發一下初始化方法,模擬實際專案中在 Bean 的初始化階段,既在 Spring 專案啟動過程中,做一些資料準備、設定拉取等相關操作:

再次重啟一下專案,因為需要執行兩個 Bean 的初始化動作,各需要 5s 時間,而且是序列執行,所以啟動時間直接來到了 11.188s:

那麼接下來,就是見證奇蹟的時刻了。

我加上 @SofaAsyncInit 這樣的一個註解:

你先別管這個註解是哪裡來的,從這個註解的名稱你也知道它是幹啥的:非同步執行初始化。

這個時候我再啟動專案:

從紀錄檔中可以看到:

  1. whyBean 和 maxBean 的 init 方法是由兩個不同的執行緒並行執行的。
  2. 啟動時間縮短到了 6.049s。

所以 @SofaAsyncInit 這個註解實現了「指定 Bean 的初始化方法實現非同步化」。

你想想,如果你有 10 個 Bean,每個 Bean 都需要 1s 的時間做初始化,總計 10s。

但是這些 Bean 之間其實不需要序列初始化,那麼用這個註解,並行只需要 1s,搞定。

到這裡,你算是看到了這樣的東西存在,屬於「我看到了」。

接下來,我們進入到「我會用了」這個環節。

怎麼來的。

在解讀原理之前,我還得告訴你這個註解到底是怎麼來的。

它屬於 SOFABoot 框架裡面的註解,首先你得把你的 SpringBoot 修改為 SOFABoot。

這一步參照官方檔案中的「快速開始」部分,非常的簡單:

https://www.sofastack.tech/projects/sofa-boot/quick-start/

第一步就是把專案中 pom.xml 中的:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>${spring.boot.version}</version>
    <relativePath/> 
</parent>

替換為:

<parent>
    <groupId>com.alipay.sofa</groupId>
    <artifactId>sofaboot-dependencies</artifactId>
    <version>${sofa.boot.version}</version>
</parent>

這裡的 ${sofa.boot.version} 指定具體的 SOFABoot 版本,我這裡使用的是最新的 3.18.0 版本。

然後我們要使用 @SofaAsyncInit 註解,所以需要引入以下 maven:

<dependency>
    <groupId>com.alipay.sofa</groupId>
    <artifactId>runtime-sofa-boot-starter</artifactId>
</dependency>

對於 pom.xml 檔案的變化,就只有這麼一點:

最後,在工程的 application.properties 檔案下新增 SOFABoot 工程一個必須的引數設定,spring.application.name,用於標示當前應用的名稱

# Application Name
spring.application.name=SOFABoot Demo

就搞定了,我就完成了一個從 SpringBoot 切換為 SOFABoot 這個大動作。

當然了,我這個是一個 Demo 專案,結構和 pom 依賴都非常簡單,所以切換起來也非常容易。如果你的專案比較大的話,可能會遇到一些相容性的問題。

但是,注意我要說但是了。

你是在學習摸索階段,Demo 一定要簡單,越小越好,越純淨越好。所以這個切換的動作對你搭建的一個全新的 Demo 專案來說沒啥難度,不會遇到任何問題。

這個時候,你就可以使用 @SofaAsyncInit 註解了:

到這裡,恭喜你,會用了。

拿來吧你

不知道你看到這裡是什麼感受。

反正對於我來說,如果僅僅是為了讓我能使用這個註解,達到非同步初始化的目的,要讓我從熟悉的 SpringBoot 修改為聽都沒聽過的 SOFABoot,即使這個框架背後有阿里給它背書,我肯定也是不會這麼幹的。

所以,對於這一類「人有我無」的東西,我都是採取「拿來吧你」策略。

你想,最開始的我就說了,SOFABoot 是 SpringBoot 的變種,它的底層還是 SpringBoot。

而 SOFABoot 又是開源的,整個專案的原始碼我都有了:

https://github.com/sofastack/sofa-boot/blob/master/README_ZH.md

從其中剝離出一個基於 SpringBoot 做的小功能,融入到我自己的 SpringBoot 專案中,還玩意難道不是手到擒來的事情?

不過就是稍微高階一點的 cv 罷了。

首先,你得把 SOFABoot 的原始碼下載下來,或者在另外的一個專案中參照它,把自己的專案恢復為一個 SpringBoot 專案。

我這邊是直接把 SOFABoot 原始碼搞下來了,先把原始碼裡面的 @SofaAsyncInit 註解粘到專案裡面來,然後從 @SofaAsyncInit 註解入手,發現除了測試類只有一個 AsyncInitBeanFactoryPostProcessor 類在對其進行使用:

所以把這個類也搬運過來。

搬運過來之後你會發現有一些類找不到導致報錯:

針對這部分類,你可以採取無腦搬運的方式,也可以稍加思考替換一些。

比如我就分為了兩種型別:

標號為 ① 的部分,我是直接貼上到自己的專案中,然後使用專案中的類。

標號為 ② 的部分,比如 BeanLoadCostBeanFactory 和 SofaBootConstants,他們的目的是為了獲取一個 moduleName 變數:

我也不知道這個 moduleName 是啥,所以我採取的策略是自己指定一個:

至於 ErrorCode 和 SofaLogger,紀錄檔相關的,就用自己專案裡面的紀錄檔就行了。

就是這個意思:

這樣處理完成之後,AsyncInitBeanFactoryPostProcessor 類不報錯了,接著看這個類在哪裡使用到了。

就這樣順藤摸瓜,最後搬運完成之後,就是這些類移過來了:

除了這些類之外,你還會把這個 spring.factories 搬運過來,在專案啟動時把這幾個相關的類載入進去:

然後再次啟動這個和 SOFABoot 沒有一點關係的專案:

你會發現,你的專案也具備非同步初始化 Bean 的功能了。

你要再進一步,把它直接封裝為一個 spring-boot-starter-asyncinitbean,釋出到你們公司的私服裡面。

其他團隊也能開箱即用的使用這個功能了。

別問,問就是你自己獨立開發出來的,掌握全部原始碼,技術風險可控:

啃原理

在開始啃原理之前,我先多比比兩句。

我寫文章的時候,為什麼要把「拿來吧你」這一小節放在「啃原理」之前,我是有考慮的。

當我們把「非同步初始化」這個功能點剝離出來之後,你會發現,要實現這個功能,一共也沒涉及到幾個類。

聚焦點從一整個專案變成了幾個類而已,至少從感官上不會覺得那麼的難,對閱讀其原始碼產生太大的抗拒心理。

而我之前很多關於原始碼閱讀的文章,都強調過這一點:帶著疑問去偵錯原始碼,要抓住主幹,謹防走偏。

前面這一小節,不過是把這一句話具化了而已。即使沒有把這些類剝離出來,你直接基於 SOFABoot 來偵錯這個功能。在你搞清楚「非同步初始化」這個功能的實現原理之前,理論上你的關注點和注意力不應該被上面這些類之外的任何一個類給吸引走。

接下來,我們就帶你啃一下原理。

關於原理部分,我們的突破口肯定是看 @SofaAsyncInit 這個註解的在哪個地方被解析的。

你仔細看這個註解裡面有一個 value 屬性,預設為 true,上面的註解說:用來標註是否應該對 init 方法進行非同步呼叫。

而使用到這個 value 值的地方,就只有下面這一個地方:

com.alipay.sofa.runtime.spring.AsyncInitBeanFactoryPostProcessor#registerAsyncInitBean

判斷為 true 的時候,執行了一個 registerAsyncInitBean 方法。

從方法名稱也知道,它是把可以非同步執行的 init 方法的 Bean 收集起來。

所以看原始碼可以看出,這裡面是用 Map 來進行的儲存,提供了一個 register 和 get 方法:

那麼這個 Map 裡面到底放的是啥呢?

我也不知道,打個斷點瞅一眼,不就行了:

通過斷點偵錯,我們知道這個裡面把專案中哪些 Bean 可以非同步執行 init 方法通過 Map 存放了起來。

那麼問題就來了:它怎麼知道哪些 Bean 可以非同步執行 init 呢?

很簡單啊,因為我在對應的 Bean 上打上了 @SofaAsyncInit 註解。所以可以通過掃描註解的方式找到這些 Bean。

所以你說 AsyncInitBeanFactoryPostProcessor 這個類是在幹啥?

肯定核心邏輯就是在解析標註了 @SofaAsyncInit 註解的地方嘛。

到這裡,我們通過註解的 value 屬性,找到了 AsyncInitBeanHolder 這個關鍵類。

知道了這個類裡面有一個 Map,裡面維護的是所有可以非同步執行 init 方法的 Bean 和其對應的 init 方法。

好,你思考一下,接下來應該幹啥?

接下來肯定是看哪個地方在從這個 Map 裡面獲取資料出來,獲取資料的時候,就說明是要非同步執行這個 Bean 的 init 方法的時候。

不然它把資料放到 Map 裡面幹啥?玩嗎?

呼叫 getAsyncInitMethodName 方法的地方,也在 AsyncProxyBeanPostProcessor 類裡面:

com.alipay.sofa.runtime.spring.AsyncProxyBeanPostProcessor#postProcessBeforeInitialization

AsyncProxyBeanPostProcessor 類實現了 BeanPostProcessor 介面,並重新了其 postProcessBeforeInitialization 方法。

在這個 postProcessBeforeInitialization 方法裡面,執行了從 Map 裡面拿物件的動作。

如果獲取到了則通過 AOP 程式設計,編織進一個 AsyncInitializeBeanMethodInvoker 方法。

把 bean, beanName, methodName 都傳遞了進去:

所以關鍵點,就在 AsyncInitializeBeanMethodInvoker 裡面,因為這個裡面有真正判斷是否要進行非同步初始化的邏輯,主要解讀一下這個類。

首先,關注一下它的這三個引數:

  • initCountDownLatch:是 CountDownLatch 物件,其中 count 初始化為 1
  • isAsyncCalling:表示是否正在非同步執行 init 方法。
  • isAsyncCalled:表示是否已經非同步執行過 init 方法。

通過這三個欄位,就可以感知到一個 Bean 是否已經或者正在非同步執行其 init 方法。

這個類的核心邏輯就是把可以非同步執行、但是還沒有執行 init 方法的 bean ,把它的 init 方法扔到執行緒池裡面去執行:

看一下在上面的 invoke 方法中的 if 方法:

if (!isAsyncCalled && methodName.equals(asyncMethodName))

isAsyncCalled,首先判斷是否已經非同步執行過這個 bean 的 init 方法了。

然後看看 methodName.equals(asyncMethodName),要反射呼叫的方法是否是之前在 map 中維護的 init 方法。

如果都滿足,就扔到執行緒池裡面去執行,這樣就算是完成了非同步 init。

如果不滿足呢?

首先,你想想不滿足的時候說明什麼情況?

是不是說明一個 Bean 的 init 方法在專案啟動過程中不只被呼叫一次。

就像是這樣:

雖然,我不知道為什麼一個 Bean 要執行兩次 init 方法,大概率是程式碼寫的有問題。

但是我不說,我也不給你丟擲異常,我反正就是給你相容了。

所以,這段程式碼就是在處理這個情況:

如果發現有多次呼叫,那麼只要第一次非同步初始化完成了,即 isAsyncCalling 為 false ,你可以繼續執行反射呼叫初始化方法的動作。

這個 invoke 方法的邏輯就是這樣,主要是有一個執行緒池在裡面。

那麼這個執行緒池是哪裡來的呢?

com.alipay.sofa.runtime.spring.async.AsyncTaskExecutor

在第一次 submit 任務的時候,框架會幫我們初始化一個執行緒池出來。

然後通過這個執行緒池幫我們完成非同步初始化的目標。

所以你想想,整個過程是非常清晰的。首先找出來哪些 Bean 上標註了 @SofaAsyncInit 註解,找個 Map 維護起來,接著搞個 AOP 切面,看看哪些 Bean 能在 Map 裡面找到,線上程池裡面通過動態代理,呼叫其 init 方法。

就完了。

對不對?

好,那麼問題就來了?

為什麼我不直接在 init 方法裡面搞個執行緒池呢,就像是這樣。

先注入一個自定義執行緒池,同時註釋掉 @SofaAsyncInit 註解:

在指定 Bean 的 init 方法中使用該執行緒池:

這也不也是能達到「非同步初始化」的目的嗎?

你說對不對?

不對啊,對個錘子對。

你看啟動紀錄檔:

服務已經啟動完成了,但是 4s 之後,Bean 的 init 方法才執行完畢。

在這期間,如果有請求要使用對應的 Bean 怎麼辦?

拿著一個還未執行完成 init 方法的 Bean 框框一頓用,這畫面想想就很美。

所以怎麼辦?

我也不知道,看一下 SOFABoot 裡面是怎麼解決這個問題的。

在我們前面提到的執行緒池裡面,有這樣的一個方法:

com.example.asyncthreadpool.spring.AsyncTaskExecutor#ensureAsyncTasksFinish

在這個方法裡面,呼叫了 future 的 get 方法進行阻塞等待。當所有的 future 執行完成之後,會關閉執行緒池。

這個 FUTURES 是什麼玩意,怎麼來的?

它就是執行 submitTask 方法時,維護進行去的,裡面裝的就是一個個非同步執行的 init 方法:

所以它通過這個方法可以確保能感知到所有的通過這個執行緒池執行的 init 方法都執行完畢。

現在,方法有了,你先思考一下,我們什麼時候觸發這個方法的呼叫呢?

是不是應該在 Spring 容器告訴你:小老弟,我這邊所有的 Bean 都搞定了,你這邊啥情況了?

這個時候你就需要呼叫一下這個方法。

而 Spring 容器載入完成之後,會發布這樣的一個事件。也就是它:

所以,SOFABoot 的做法就是監聽這個事件:

com.example.asyncthreadpool.spring.AsyncTaskExecutionListener

這樣,即可確保在非同步執行緒中執行的 init 方法的 Bean 執行完成之後,容器才算啟動成功,對外提供服務。

到這裡,原理部分我算是講完了。

但是寫到這裡的時候,我突然冒出了一個寫之前沒有過的想法:在整個實現的過程中,最關鍵的有兩個東西:

  1. 一個 Map:裡面維護的是所有可以非同步執行 init 方法的 Bean 和其對應的 init 方法。
  2. 一個執行緒池:非同步執行 init 方法。

而這個 Map 是怎麼來的?

不是通過掃描 @SofaAsyncInit 註解得到的嗎?

那麼掃描出來的 @SofaAsyncInit 怎麼來的?

不就是我寫程式碼的時候主動標註上去的嗎?

所以,我們是不是可以完全不用 Map ,直接使用非同步執行緒池:

剩去中間環節,直接一步到位,只需要留下兩個類即可:

我這裡把這個兩個類貼出來。

AsyncTaskExecutionListener:

public class AsyncTaskExecutionListener implements PriorityOrdered,
                                       ApplicationListener<ContextRefreshedEvent>,
                                       ApplicationContextAware {
    private ApplicationContext applicationContext;

    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        if (applicationContext.equals(event.getApplicationContext())) {
            AsyncTaskExecutor.ensureAsyncTasksFinish();
        }
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE + 1;
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }
}

AsyncTaskExecutor:

@Slf4j
public class AsyncTaskExecutor {
    protected static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
    protected static final AtomicReference<ThreadPoolExecutor> THREAD_POOL_REF = new AtomicReference<ThreadPoolExecutor>();

    protected static final List<Future> FUTURES = new ArrayList<>();

    public static Future submitTask(Runnable runnable) {
        if (THREAD_POOL_REF.get() == null) {
            ThreadPoolExecutor threadPoolExecutor = createThreadPoolExecutor();
            boolean success = THREAD_POOL_REF.compareAndSet(null, threadPoolExecutor);
            if (!success) {
                threadPoolExecutor.shutdown();
            }
        }
        Future future = THREAD_POOL_REF.get().submit(runnable);
        FUTURES.add(future);
        return future;
    }

    private static ThreadPoolExecutor createThreadPoolExecutor() {
        int threadPoolCoreSize = CPU_COUNT + 1;
        int threadPoolMaxSize = CPU_COUNT + 1;
        log.info(String.format(
                "create why-async-init-bean thread pool, corePoolSize: %d, maxPoolSize: %d.",
                threadPoolCoreSize, threadPoolMaxSize));
        return new ThreadPoolExecutor(threadPoolCoreSize, threadPoolMaxSize, 30,
                TimeUnit.SECONDS, new LinkedBlockingQueue<>(), new ThreadPoolExecutor.CallerRunsPolicy());
    }

    public static void ensureAsyncTasksFinish() {
        for (Future future : FUTURES) {
            try {
                future.get();
            } catch (Throwable e) {
                throw new RuntimeException(e);
            }
        }

        FUTURES.clear();
        if (THREAD_POOL_REF.get() != null) {
            THREAD_POOL_REF.get().shutdown();
            THREAD_POOL_REF.set(null);
        }
    }
}

你只需要把這兩個類,一共 68 行程式碼,粘到你的專案中,然後把 AsyncTaskExecutionListener 以 @Bean 的方式注入:

@Bean
public AsyncTaskExecutionListener asyncTaskExecutionListener() {
    return new AsyncTaskExecutionListener();
}

恭喜你,你專案中的 Bean 也可以非同步執行 init 方法了,使用方法就像這樣式兒的:

但是,如果你要對比這兩種寫的法的話:

肯定是選註解嘛,優雅的一比。

所以,我現在問你一個問題:清理聊聊非同步初始化 Bean 的思路。

然後在追問你一個問題:如果通過自定義註解的方式實現?需要用到 Spring 的那些擴充套件點?

還思考個毛啊,不就是這個過程嗎?

回想一下前面的內容,是不是品出點味道了,是不是有點感覺了,是不是覺得自己又行了?

其實說真的,這個方案,當需要人來主動標識哪些 Bean 是可以非同步初始化的時候,就已經「輸了」,已經不夠驚豔了。

但是,你想想本文只是想教你「非同步初始化」這個點嗎?

不是的,只是以「非同步初始化」為抓手,試圖教你一種原始碼解讀的方法,找到撕開 Spring 框架的又一個口子,這才是重要的。

最後,前兩天阿里開發者公眾號也釋出了一篇叫《Bean非同步初始化,讓你的應用啟動飛起來》的文章,想要達成的目的一樣,但是最終的落地方案可以說差距很大。這篇文章沒有具體的原始碼,但是也可以對比著看一下,取長補短,融會貫通。

行了,我就帶你走到這了,我只是給你指個路,剩下的路就要你自己走了。

天黑路滑,燈火昏暗,抓住主幹,及時回頭。