你好呀,我是why。
我之前寫過一些關於執行緒池的文章,然後有同學去翻了一圈,發現我沒有寫過一篇關於 @Async
註解的文章,於是他來問我:
是的,我攤牌了。
我不喜歡這個註解的原因,是因為我壓根就沒用過。
我習慣用自定義執行緒池的方式去做一些非同步的邏輯,且這麼多年一直都是這樣用的。
所以如果是我主導的專案,你在專案裡面肯定是看不到 @Async
註解的。
那我之前見過 @Async
註解嗎?
肯定是見過啊,有的朋友就喜歡用這個註解。
一個註解就搞定非同步開發,多爽啊。
我不知道用這個註解的人知不知道其原理,反正我是不知道的。
最近開發的時候引入了一個元件,發現呼叫的方法裡面,有的地方用到了這個註解。
既然這次用到了,那就研究一下吧。
首先需要說明的是,本文並不會寫執行緒池相關的知識點。
僅描述我是通過什麼方式,去了解這個我之前一無所知的註解的。
搞個 Demo
不知道大家如果碰到這種情況會去怎麼下手啊。
但是我認為不論是從什麼角度去下手的,最後一定是會落到原始碼裡面的。
所以,我一般是先搞個 Demo。
Demo 非常簡單啊,就三個類。
首先是啟動類,這沒啥說的:
然後搞個 service:
這個 service 裡面的 syncSay 方法被打上了 @Async
註解。
最後,搞個 Controller 來呼叫它,完事:
Demo 就搭建好了,你也動手去搞一個,耗時超過 5 分鐘,算我輸。
然後,把專案啟動起來,呼叫介面,檢視日誌:
我去,從執行緒名稱來看,這也沒非同步呀?
怎麼還是 tomcat 的執行緒呢?
於是,我就遇到了研究路上的第一個問題:@Async
註解沒有生效。
為啥不生效?
為什麼不生效呢?
我也是懵逼的,我說了之前對這個註解一無所知,那我怎麼知道呢?
那遇到這個問題的時候會怎麼辦?
當然是面向瀏覽器程式設計啦!
這個地方,如果我自己從原始碼裡面去分析為啥沒生效,一定也能查出原因。
但是,如果我面向瀏覽器程式設計,只需要 30 秒,我就能查到這兩個資訊:
失效原因:
1. @SpringBootApplication
啟動類當中沒有新增@EnableAsync
註解。2.沒有走 Spring 的代理類。因為 @Transactional
和@Async
註解的實現都是基於 Spring 的 AOP,而 AOP 的實現是基於動態代理模式實現的。那麼註解失效的原因就很明顯了,有可能因為呼叫方法的是物件本身而不是代理物件,因為沒有經過 Spring 容器管理。
很顯然,我這個情況符合第一種情況,沒有新增 @EnableAsync
註解。
另外一個原因,我也很感興趣,但是現在我的首要任務是把 Demo 搭建好,所以不能被其他資訊給誘惑了。
很多同學帶著問題去查詢的時候,本來查的問題是@Async
註解為什麼沒有生效,結果慢慢的就走偏了,十五分鐘後問題就逐漸演變為了 SpringBoot 的啟動流程。
再過半小時,網頁上就顯示的是一些面試必背八股文之類的東西...
我說這個意思就是,查問題就好好查問題。查問題的過程中肯定會由這個問題引發的自己更加感興趣的問題。但是,記錄下來,先不要讓問題發散。
這個道理,就和帶著問題去看原始碼一樣,看著看著,可能連自己的問題是什麼都不知道了。
好了,說回來。
我在啟動類上加上該註解:
再次發起呼叫:
可以看到執行緒名字變了,說明真的就好了。
現在我的 Demo 已經搭好了,可以開始找角度去捲了。
從上面的日誌我也能知道,在預設情況下有一個執行緒字首為 task-
的執行緒池在幫我執行任務。
說到執行緒池,我就得知道這個執行緒池的相關配置才放心。
那麼我怎麼才能知道呢?
先壓一壓
其實正常人的思路這個時候就應該是去翻原始碼,找對應的注入執行緒池的地方。
而我,就有點不正常了,我懶得去原始碼裡面找,我想讓它自己暴露到我的面前。
怎麼讓它暴露出來呢?
仗著我對執行緒池的瞭解,我的第一個思路是先壓一壓這個執行緒池。
壓爆它,壓的它處理不過來任務,讓它走到拒絕邏輯裡面去,正常來說是會丟擲異常的吧?
於是,我把程式稍微改造了一下:
想的是直接來一波大力出奇跡:
結果...
它竟然...
照單全收了,沒有異常?
日誌一秒打幾行,打的很歡樂:
雖然沒有出現我預想的拒絕異常,但是我從日誌裡面還是看出了一點點端倪。
比如我就發現這個 taks 最多就到 8:
朋友們,你說這是啥意思?
是不是就是說這個我正在尋找的執行緒池的核心執行緒數的配置是 8 ?
什麼,你問我為什麼不能是最大執行緒數?
有可能嗎?
當然有可能。但是我 10000 個任務發過來,沒有觸發執行緒池拒絕策略,剛好把最大執行緒池給用完了?
也就是說這個執行緒池的配置是佇列長度 9992,最大執行緒數 8 ?
這也太巧合了且不合理了吧?
所以我覺得核心執行緒數配置是 8 ,佇列長度應該是 Integer.MAX_VALUE
。
為了證實我的猜想,我把請求改成了這樣:
num=一千萬。
通過 jconsole 觀察堆記憶體使用情況:
那叫一個飆升啊,點選【執行GC】按鈕也沒有任何緩解。
也從側面證明了:任務有可能都進佇列裡面排隊了,導致記憶體飆升。
雖然,我現在還不知道它的配置是什麼,但是經過剛剛的黑盒測試,我有正當的理由懷疑:
預設的執行緒池有導致記憶體溢位的風險。
但是,同時也意味著我想從讓它丟擲異常,從而自己暴露在我面前的騷想法落空。
懟原始碼
前面的思路走不通,老老實實的開始懟原始碼吧。
我是從這個註解開始懟的:
點進這個註解之後,幾段英文,不長,我從裡面獲取到了一個關鍵資訊:
主要關注我畫線的地方。
In terms of target method signatures, any parameter types are supported.
在目標方法的簽名中,入參是任何型別都支援的。
多說一句:這裡說到目標方法,說到 target,大家腦海裡面應該是要立刻出現一個代理物件的概念的。
上面這句話好理解,甚至感覺是一句廢話。
但是,它緊跟了一個 However:
However, the return type is constrained to either void or Future.
constrained,受限制,被約束的意思。
這句話是說:返回型別被限制為 void 或者 Future。
啥意思呢?
那我偏要返回一個 String 呢?
WTF,列印出來的居然是 null !?
那這裡如果我返回一個物件,豈不是很容易爆出空指標異常?
看完註解上的註釋之後,我發現了第二個隱藏的坑:
如果被
@Async
註解修飾的方法,返回值只能是 void 或者 Future。
void 就不說了,說說這個 Future。
看我劃線的另外一句:
it will have to return a temporary {@code Future} handle that just passes a value through: e.g. Spring's {@link AsyncResult}
上有一個 temporary,是四級詞彙啊,應該認識的,就是短暫的、暫時的意思。
temporary worker,臨時工,明白吧。
所以意思就是如果你要返回值,你就用 AsyncResult 物件來包一下,這個 AsyncResult 就是 temporary worker。
就像這樣:
接著我們把目光放到註解的 value 屬性上:
這個註解,看註釋上面的意思,就是說這個應該填一個執行緒池的 bean 名稱,相當於指定執行緒池的意思。
也不知道理解的對不對,等會寫個方法驗證一下就知道了。
好了,到現在,我把資訊整理彙總一下。
我之前完全不懂這個註解,現在我有一個 Demo 了,搭建 Demo 的時候我發現除了 @Async
註解之外,還需要加上@EnableAsync
註解,比如加在啟動類上。然後把這個預設的執行緒池當做黑盒測試了一把,我懷疑它的核心執行緒數預設是 8,佇列長度無線長。有記憶體溢位的風險。 通過閱讀 @Async
上的註解,我發現返回值只能是 void 或者 Future 型別,否則即使返回了其他值,不會報錯,但是返回的值是 null,有空指標風險。@Async
註解中有一個 value 屬性,看註釋應該是可以指定自定義執行緒池的。
接下來我把要去探索的問題排個序,只聚焦到 @Async
的相關問題上:
1.預設執行緒池的具體配置是什麼? 2.原始碼是怎麼做到只支援 void 和 Future 的? 3.value 屬性是幹什麼用的?
具體配置是啥?
我找到具體配置其實是一個很快的過程。
因為這個類的 value 引數簡直太友好了:
五處呼叫的地方,其中四處都是註釋。
有效的呼叫就這一個地方,直接先打上斷點再說:
org.springframework.scheduling.annotation.AnnotationAsyncExecutionInterceptor#getExecutorQualifier
發起呼叫之後,果然跑到了斷點這個地方:
順著斷點往下除錯,就會來到這個地方:
org.springframework.aop.interceptor.AsyncExecutionAspectSupport#determineAsyncExecutor
這個程式碼結構非常的清晰。
編號為 ① 的地方,是獲取對應方法上的 @Async
註解的 value 值。這個值其實就是 bean 名稱,如果不為空則從 Spring 容器中獲取對應的 bean。
如果 value 是沒有值的,也就是我們 Demo 的這種情況,會走到編號為 ② 的地方。
這個地方就是我要找的預設的執行緒池。
最後,不論是預設的執行緒池還是 Spring 容器中我們自定義的執行緒池。
都會以方法為維度,在 map 中維護方法和執行緒池的對映關係。
也就是編號為 ③ 的這一步,程式碼中的 executors 就是一個 map:
所以,我要找的東西,就是編號為 ② 的這個地方的邏輯。
這裡面主要是一個 defaultExecutor 物件:
這個玩意是一個函數語言程式設計,所以如果你不知道這個玩意是幹什麼的,除錯起來可能有點懵逼:
我建議你去惡補一下, 10 分鐘就能入門。
最終你會除錯到這個地方來:
org.springframework.aop.interceptor.AsyncExecutionAspectSupport#getDefaultExecutor
這個程式碼就有點意思了,就是從 BeanFactory 裡面獲取一個預設的執行緒池相關的 Bean 出來。流程很簡單,日誌也列印的很清楚,就不贅述了。
但是我想說的有意思的點是,我不知道你看到這份程式碼,有沒有看出一絲絲雙親委派內味。
都是利用異常,在異常裡面處理邏輯。
就上面這“垃圾”程式碼,直接就觸犯了阿里開發規範中的兩大條:
在原始碼裡面這就是好程式碼。
在業務流程裡面,這就是違反了規範。
所以,說一句題外話。
就是阿里開發規範我個人感覺,其實是針對我們寫業務程式碼的同事一個最佳實踐。
但是當把這個尺度拉到中介軟體、基礎元件、框架原始碼的範圍時,就會出現一點水土不服的症狀,這個東西見仁見智,我是覺得阿里開發規範的 idea 外掛,對於我這樣寫增刪查改的程式設計師來說,是真的香。
不說遠了,我們還是回來看看獲取到的這個執行緒池:
這不就找到我想要的東西了嗎,這個執行緒池的相關引數都可以看到了。
也證實了我之前猜想:
我覺得核心執行緒數配置是 8 ,佇列長度應該是 Integer.MAX_VALUE。
但是,現在我是直接從 BeanFactory 獲取到了這個執行緒池的 Bean,那麼這個 Bean 是什麼時候注入的呢?
朋友們,這還不簡單嗎?
我都已經拿到這個 Bean 的 beanName 了,就是 applicationTaskExecutor,但凡你把 Spring 獲取 bean 的流程的八股文背的熟練一點,你都知道在這個地方打上斷點,加上除錯條件,慢慢去 Debug 就知道了:
org.springframework.beans.factory.support.AbstractBeanFactory#getBean(java.lang.String)
假設你就是不知道在上面這個地方打斷點去除錯呢?
再說一個簡單粗暴的方法,你都拿到 beanName 了,在程式碼裡面一搜不就出來了嘛。
簡單粗暴效果好:
org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration
都找到這個類了,隨便打個斷點,就可以開始除錯了。
再說一個騷一點的操作。
假設我現在連 beaName 都不知道,但是我知道它肯定是一個被 Spring 管理的執行緒池。
那麼我就獲取專案裡面所有被 Spring 管理的執行緒池,總有一個得是我要找的吧?
你看下面截圖,當前這個 bean 不就是我要找的 applicationTaskExecutor 嗎?
這都是一些野路子,騷操作,知道就好,有時候多個排查思路。
返回型別的支援
前面我們卷完了第一個關於配置的問題。
接下來,我們看另外一個前面提出的問題:
原始碼是怎麼做到只支援 void 和 Future 的?
答案就藏在這個方法裡面:
org.springframework.aop.interceptor.AsyncExecutionInterceptor#invoke
標號為 ① 的地方,其實就是我們前面分析的從 map 裡面拿 method 對應的執行緒池的方法。
拿到執行緒池之後來到標號為 ② 的地方,就是封裝一個 Callable 物件。
那麼是把什麼封裝到 Callable 物件裡面呢?
這個問題先按下不表,我們先牢牢的圍繞我們的問題往下走,不然問題會越來越多。
標號為 ③ 的地方,doSubmit,見名知意,這個地方就是執行任務的地方了。
org.springframework.aop.interceptor.AsyncExecutionAspectSupport#doSubmit
其實這裡就是我要找的答案。
你看這個方法的入參 returnType 是 String,其實就是被 @Async 註解修飾的 asyncSay 方法。
你要不信,我可以帶你看看前一個呼叫棧,這裡可以看到具體的方法:
怎麼樣,沒有騙你吧。
所以,現在你再看 doSubmit 方法拿著這個方法的返回型別幹啥了。
一共四個分支,前面三個都是判斷是否是 Future 型別的。
其中的 ListenableFuture 和 CompletableFuture 都是繼承自 Future 的。
這個兩個類在 @Async 註解的方法註釋裡面也提到了:
而我們的程式走到了最後的一個 else,含義就是返回值不是 Future 型別的。
那麼你看它幹了啥事兒?
直接把任務 submit 到執行緒池之後,就返回了一個 null。
這可不得爆出空指標異常嗎?
到這個地方,我們也解決了這個問題:
原始碼是怎麼做到只支援 void 和 Future 的?
其實道理很簡單,我們正常的使用執行緒池提交不也就這兩個返回型別嗎?
用 submit 的方式提交,返回一個 Future,把結果封裝到 Future 裡面:
用 execute 的方式提交,沒有返回值:
而框架通過一個簡單的註解幫我們實現非同步化,它玩的再花裡胡哨 ,就算是玩出花來了,它也得遵守執行緒池提交的底層原理啊。
所以,原始碼為什麼只支援 void 和 Future 的返回型別?
因為底層的執行緒池只支援這兩種型別的返回。
只是它的做法稍微有點坑,直接把其他的返回型別的返回值都處理為 null 了。
你還別不服,誰叫你不讀註釋上的說明呀。
另外,我發現這個地方還有個小的優化點:
當它走到這個方法的時候,返回值已經明確是 null 了。
為什麼還用 executor.submit(task)
提交任務呢?
用 execute 就行了啊。
區別,你問我區別?
不是剛剛才說了嗎, submit 方法是有返回值的。
雖然你不用,但是它還是會去構建一個返回的 Future 物件呀。
然而構建出來了,也沒用上呀。
所以直接用 execute 提交就行了。
少生成一個 Future 物件,算不算優化?
有一說一,不算什麼有價值的優化,但是說出去可是優化過 Spring 的原始碼的,裝逼夠用了。
接著,再說一下我們前面按下不表的部分,這裡編號為 ② 的地方封裝的到底是什麼?
其實這個問題用腳指頭應該也猜到了:
只是我單獨擰出來說的原因是我要給你證明,這裡返回的 result 就是我們方法返回的真實的值。
只是判斷了一下型別不是 Future 的話就不做處理,比如我這裡其實是返回了 hi:1
字串的,只是不符合條件,就被扔掉了:
另外,idea 還是很智慧的,它會提示你這個地方的返回值是有問題的:
甚至修改方法都給你標出來了,你只需要一點,它就給你重新改好了。
對於為什麼要這麼改,現在我們已經拿捏的非常清楚了。
知其然,也知其所以然。
@Async 註解的 value
接下來我們看看 @Async 註解的 value 屬性是幹什麼的。
其實在前面我已經悄悄的提到了,只是一句話就帶過了,就是這個地方:
前面說編號為 ① 的地方,是獲取對應方法上的 @Async
註解的 value 值。這個值其實就是 bean 名稱,如果不為空則從 Spring 容器中獲取對應的 bean。
然後我就直接分析到標號為 ② 的地方了。
現在我們重新看看標號為 ① 的地方。
我也重新安排一個測試用例去驗證我的想法。
反正 value 值應該是 Spring 的 bean 名稱,而且這個 bean 一定是一個執行緒池物件,這個沒啥說的。
所以,我把 Demo 程式修改為這樣:
再次跑起來,跑到這個斷點的地方,就和我們預設的情況不一樣了,這個時候 qualifier 有值了:
接下來就是去 beanFactory 裡面拿名字為 whyThreadPool 的 bean 了。
最後,拿出來的執行緒池就是我自定義的這個執行緒池:
這個其實是一個很簡單的探索過程,但是這背後蘊涵了一個道理。
就是之前有同學問我的這個問題:
其實這個問題挺有代表性的,很多同學都認為執行緒池不能濫用,一個專案共用一個就好了。
執行緒池確實不能濫用,但是一個專案裡面確實是可以有多個自定義執行緒池的。
根據你的業務場景來劃分。
比如舉個簡單的例子,業務主流程上可以用一個執行緒池,但是當主流程中的某個環節出問題了,假設需要傳送預警簡訊。
傳送預警簡訊的這個操作,就可以用另外一個執行緒池來做。
它們可以共用一個執行緒池嗎?
可以,能用。
但是會出現什麼問題呢?
假設專案中某個業務出問題了,在不斷的,瘋狂的傳送預警簡訊,甚至把執行緒池都佔滿了。
這個時候如果主流程的業務和傳送簡訊用的是同一個執行緒池,會出現什麼美麗的場景?
是不是一提交任務,就直接走到拒絕策略裡面去了?
預警簡訊傳送這個附屬功能,導致了業務不可以,本末倒置的了吧?
所以,建議使用兩個不同的執行緒池,各司其職。
這其實就是聽起來很高大上的執行緒池隔離技術。
那麼落到 @Async
註解上是怎麼回事呢?
其實就是這樣的:
然後,還記得我們前面提到的那個維護方法和執行緒池的對映關係的 map 嗎?
就是它:
現在,我把程式跑起來呼叫一下上面的三個方法,目的是為了把值給放進去這個 map:
看明白了嗎?
再次複述一次這句話:
以方法維度維護方法和執行緒池之間的關係。
現在,我對於 @Async
這個註解算是有了一點點的瞭解,我覺得它也還是很可愛的。後面也許我會考慮在專案裡面把它給用起來。畢竟它更加符合 SpringBoot 的基於註解開發的程式設計理念。
最後說一句
好了,看到了這裡了,點贊、關注隨便安排一個吧,要是你都安排上我也不介意。寫文章很累的,需要一點正反饋。
給各位讀者朋友們磕一個了: