線程池的參數動態調整

 

經典面試題

這次的文章還是繞回了我寫的第三篇原創文章《有的線程它死了,於是它變成一道面試題》中留下的幾個問題:

哎,兜兜轉轉,走走停停。天道好輪迴,蒼天饒過誰?

在這篇文章中我主要回答上面拋出的這個問題:你這幾個參數的值怎麼來的呀?

要回答這個問題,我們得先說說這幾個參數是什麼,請看截圖:

其實,官方的註釋寫的都非常明白了。你看文章的時一定要結合英文,因爲英文是 Doug Lea(作者)他自己寫的,表達的是作者自己的準確的想法。

不要瞎猜好嗎?

1.corePoolSize:the number of threads to keep in the pool, even if they are idle, unless {@code allowCoreThreadTimeOut} is set

(核心線程數大小:不管它們創建以後是不是空閒的。線程池需要保持 corePoolSize 數量的線程,除非設置了 allowCoreThreadTimeOut。)

2.maximumPoolSize:the maximum number of threads to allow in the pool。

(最大線程數:線程池中最多允許創建 maximumPoolSize 個線程。)

3.keepAliveTime:when the number of threads is greater than the core, this is the maximum time that excess idle threads will wait for new tasks before terminating。

(存活時間:如果經過 keepAliveTime 時間後,超過核心線程數的線程還沒有接受到新的任務,那就回收。)

4.unit:the time unit for the {@code keepAliveTime} argument

(keepAliveTime 的時間單位。)

5.workQueue:the queue to use for holding tasks before they are executed. This queue will hold only the {@code Runnable} tasks submitted by the {@code execute} method。

(存放待執行任務的隊列:當提交的任務數超過核心線程數大小後,再提交的任務就存放在這裏。它僅僅用來存放被 execute 方法提交的 Runnable 任務。所以這裏就不要翻譯爲工作隊列了,好嗎?不要自己給自己挖坑。)

6.threadFactory:the factory to use when the executor creates a new thread。

(線程工程:用來創建線程工廠。比如這裏面可以自定義線程名稱,當進行虛擬機棧分析時,看着名字就知道這個線程是哪裏來的,不會懵逼。)

7.handler :the handler to use when execution is blocked because the thread bounds and queue capacities are reached。

(拒絕策略:當隊列裏面放滿了任務、最大線程數的線程都在工作時,這時繼續提交的任務線程池就處理不了,應該執行怎麼樣的拒絕策略。)

7 個參數介紹完了,我希望當面試官問你自定義線程池可以指定哪些參數的時候,你能回答的上來。

當然,不能死記硬背,這樣回答起來磕磕絆絆的,像是在背書。也最好別給我回答什麼:我給你舉個例子吧,就是一開始有多少多少工人....

沒必要,真的,直接回答每個參數的名稱和含義就行了,牛逼的話你就給我說英文也行,我也能聽懂。

這玩意大家都懂,又不抽象,你舉那例子幹啥?拖延時間嗎?

面試要求的是儘量精簡、準確的回答問題,不要讓面試官去你冗長的回答中提煉關鍵字。

一是面試官面試體驗不好。面試完了後,常常是面試者在強調自己的面試體驗。朋友,你多慮了,你面試體驗不好,回去一頓吐槽,叫你進入下一輪面試的時候,大部分人還不是腆着個臉就來了。面試官的體驗不好,那你是真的沒有下一輪了。

二是面試官面試都是有一定的時間限制的,有限的面試時間內,前面太囉嗦了,能問你的問題就少了。問的問題少了,面試官寫評分表的時候一想,我靠,還有好多問題沒問呢,也不知道這小子能不能回答上來,算了,就不進入下一輪了吧。

好了好了,一不下心又暴露了幾個面試小技巧,扯遠了,說回來。

上面的 7 個參數中,我們主要需要關心的參數是: corePoolSize、maximumPoolSize、workQueue(隊列長度)

所以,文本主要討論這個問題:

當我們自定義線程池的時候 corePoolSize、maximumPoolSize、workQueue(隊列長度)該如何設置?

你以爲我要給你講分 IO 密集型任務或者分 CPU 密集型任務?

不會的,說好的是讓面試官眼前一亮、虎軀一震、直呼牛皮的答案。不騙你。

美團騷操作

怎麼虎軀一震的呢?

因爲我看到了美團技術團隊發表的一篇文章:《Java線程池實現原理及其在美團業務中的實踐》

第一次看到這篇文章的時候我真是眼前一亮,看到美團的這騷操作,我真是直呼牛皮。

(哎,還是自己見的太少了。)

這篇文章寫的很好,很全面,比如我之前說的線程執行流程,它配了一張圖,一圖勝千言:

阻塞隊列成員表,一覽無餘:

前面都是些基礎知識,文中的後半部分才拋出了一個實際問題:

線程池使用面臨的核心的問題在於:線程池的參數並不好配置。


一方面線程池的運行機制不是很好理解,配置合理需要強依賴開發人員的個人經驗和知識;


另一方面,線程池執行的情況和任務類型相關性較大,IO密集型和CPU密集型的任務運行起來的情況差異非常大。


這導致業界並沒有一些成熟的經驗策略幫助開發人員參考。

美團給出的對應的解決方案是什麼呢?

線程池參數動態化。

儘管經過謹慎的評估,仍然不能夠保證一次計算出來合適的參數,那麼我們是否可以將修改線程池參數的成本降下來,這樣至少可以發生故障的時候可以快速調整從而縮短故障恢復的時間呢?


基於這個思考,我們是否可以將線程池的參數從代碼中遷移到分佈式配置中心上,實現線程池參數可動態配置和即時生效,線程池參數動態化前後的參數修改流程對比如下:

說實話看到這個圖的時候我想起之前也有這樣的想法的。

因爲有一次我這邊有個項目裏面的定時任務用到了線程池,但是核心線程數和隊列長度都設置的比較大,某一次任務觸發後查出了大批數據,通過線程池提交任務,每個任務裏面都會調用下游服務,導致下游服務長時間的壓力過大,也沒有做限流,所以影響了其對外提供的其他功能。

於是我叫運維幫我在 Apollo(配置中心)調小了核心線程數,並且重啓了服務。

那一次我就在想,我們使用的是 Apollo 天然支持動態更新,那我能不能動態的修改線程池呢?

因爲那個時候不知道一個構建好了的線程池,它的核心線程數和最大線程數是可以動態修改的。

所以最開始的想法是監聽到參數變化後,直接弄一個新的線程池把原來的給替換掉。

但這樣的問題是,偷天換日之後,原來的線程池裏面的任務我怎麼處理呢?

我不能等原來的線程池裏面的任務執行完成後再換,因爲這個時候任務一定是源源不斷的過來的。

於是就卡在了這個地方。

說來慚愧,這塊源碼我看過幾次,但還是差點火候,學藝不精,怨不得別人。

先勸退一波

爲了不浪費你的時間,先檢測一下你是否有閱讀本文的基礎知識儲備:

首先,我們先自定義一個線程池:

拿着這個線程池,當這個線程池在正常工作的前提下,我先問你兩個問題:

1.如果這個線程池接受到了 30 個比較耗時的任務,這個時候線程池的狀態(或者說數據)是怎樣的?

2.在前面 30 個比較耗時的任務還沒執行完成的情況下,再來多少個任務會觸發拒絕策略?

其實這就是在問你線程池的執行流程了,簡單的說一下就是:

1.當接收到了 30 個比較耗時的任務時,10 個核心線程數都在工作,剩下的 20 個去隊列裏面排隊。這個時候和最大線程數是沒有關係的,所以和線程存活時間也就沒有關係。

2.其實你知道這個線程池最多能接受多少任務,你就知道這個題的答案是什麼了,上面的線程池中最多接受 1000(隊列長度) + 30(最大線程數) = 1030 個任務。所以當已經接收了30個任務的情況下,如果再來 1000 個比較耗時的任務,這個時候隊列也滿了,最大線程數的線程也都在工作,這個時候線程池滿載了。因此,在前面 30 個比較耗時的任務還沒執行完成的情況下,再來 1001 個任務,第 1001 個任務就會觸發線程池的拒絕策略了。

這兩個問題你得會,如果答不上來你也別往下看了,大概率看的一臉懵逼。

我建議你先給本文點個贊,接着去網上搜一下線程池執行流程的文章(其實美團的那篇文章也寫了執行流程),寫個 Demo 跑一下,摸清楚了,再來看這篇文章。

巨人肩膀

對於線程池參數到底如何設置的問題美團的那篇文章提供了一個很好的思路和解決方案,展現的是一個大而全的東西。

但是,對於實施起來的細節就沒有具體的展示了。

所以文本斗膽,站在巨人的肩膀上對細節處進行一些補充說明。

1.現有的解決方案的痛點。

2.動態更新的工作原理是什麼?

3.動態設置的注意點有哪些?

4.如何動態指定隊列長度?

5.這個過程中涉及到的面試題有哪些?

下面從這五點進行展開說明。

現有的解決方案的痛點。

現在市面上大多數的答案都是先區分線程池中的任務是 IO 密集型還是 CPU 密集型。

如果是 CPU 密集型的,可以把核心線程數設置爲核心數+1。

爲什麼要加一呢?

《Java併發編程實戰》一書中給出的原因是:即使當計算(CPU)密集型的線程偶爾由於頁缺失故障或者其他原因而暫停時,這個“額外”的線程也能確保 CPU 的時鐘週期不會被浪費。

看不懂是不是?沒關係我也看不懂。反正把它理解爲一個備份的線程就行了。

這個地方還有個需要注意的小點就是,如果你的服務器上部署的不止一個應用,你就得考慮其他的應用的線程池配置情況。

經過精密的計算,你咔一下設置爲核心數,結果項目部署上去了,發現還有其他的應用在和你搶 CPU,你想想難不難受。

如果是包含 IO 操作的任務呢?這個纔是我們關心的東西。

《Java併發編程實戰》一書中給出的計算方式是這樣的:

理想很豐滿,現實很骨感。

我之前有個系統就是按照這個公式算出來的參數去配置的。

結果效果並不好,甚至讓下游系統直呼受不了。

這個東西怎麼說呢,還是得記住,面試的時候有用。真實場景中只能得到一個參考值,基於這個參考值,再去進行調整。

我們再看一下美團的那篇文章調研的現有解決方案列表:

第一個就是我們上面說的,和實際業務場景有所偏離。

第二個設置爲 2*CPU 核心數,有點像是把任務都當做 IO 密集型去處理了。而且一個項目裏面一般來說不止一個自定義線程池吧?比如有專門處理數據上送的線程池,有專門處理查詢請求的線程池,這樣去做一個簡單的線程隔離。但是如果都用這樣的參數配置的話,顯然是不合理的。

第三個不說了,理想狀態。流量是不可能這麼均衡的,就拿美團來說,下午3,4點的流量,能和 12 點左右午飯時的流量比嗎?

基於上面的這些解決方案的痛點,美團給出了動態化配置的解決方案。

動態更新的工作原理是什麼?

先來一個動態更新的代碼示例:

上面的程序就是自定義了一個核心線程數爲 2,最大線程數爲 5,隊列長度爲 10 的線程池。

然後給它塞 15 個耗時 10 秒的任務,直接讓它 5 個最大線程都在工作,隊列長度 10 個都塞滿。

當前的情況下,隊列裏面的 10 個,前 5 個在 10 秒後會被執行,後 5 個在 20 秒後會被執行。

再加上最大線程數正在執行的 5 個,15 個任務全部執行完全需要 3 個 10 秒即 30 秒的時間。

這個時候,如果我們把核心線程數和最大線程數都修改爲 10。

那麼 10 個任務會直接被 10 個最大線程數接管,10 秒就會被處理完成。

剩下的 5 個任務會在 10 秒後被執行完成。

所以,15 個任務執行完成需要 2 個 10 秒即 20 秒的時間處理完成了。

看一下上面程序的打印日誌:

效果實現了,我先看一下原理是什麼。

先看 setCorePoolSize 方法:

這個方法在美團的文章中也說明了:

在運行期線程池使用方調用此方法設置corePoolSize之後,線程池會直接覆蓋原來的corePoolSize值,並且基於當前值和原始值的比較結果採取不同的處理策略。

對於當前值小於當前工作線程數的情況,說明有多餘的worker線程,此時會向當前idle的worker線程發起中斷請求以實現回收,多餘的worker在下次idel的時候也會被回收;

對於當前值大於原始值且當前隊列中有待執行任務,則線程池會創建新的worker線程來執行隊列任務,setCorePoolSize具體流程如下:

看了美團的那篇文章後,我又去看了 Spring 的 ThreadPoolTaskExecutor類 (就是對JDK ThreadPoolExecutor 的一層包裝,可以理解爲裝飾者模式)的 setCorePoolSize 方法: 註釋上寫的清清楚楚,可以在線程池運行時修改該參數。

而且,你再品一品 JDK 的源碼,其實源碼也體現出了有修改的含義的,兩個值去做差值,只是第一次設置的時候原來的值爲 0 而已。

哎,當時沒有細細研究,恨自己看源碼的時候不仔細。

接着看 setMaximumPoolSize 源碼:

這個地方就很簡單了,邏輯不太複雜。

1.首先是參數合法性校驗。

2.然後用傳遞進來的值,覆蓋原來的值。

3.判斷工作線程是否是大於最大線程數,如果大於,則對空閒線程發起中斷請求。

經過前面兩個方法的分析,我們知道了最大線程數和核心線程數可以動態調整。

動態設置的注意點有哪些?

調整的時候可能會出現核心線程數調整之後無效的情況,比如下面這種:

改變之前的核心線程數是 2,最大線程數爲 5,我們動態修改核心線程數爲 10。

但是從日誌還是可以看出,修改之後核心線程數確實變成了 10,但活躍線程數還是爲 5。

而且我調用了 prestartCoreThread 方法,該方法見名知意,你也知道是啓動所有的核心線程數,所有不存在線程沒有創建的問題。

這是爲什麼呢?

源碼之下無祕密,我帶你去看一眼:

java.util.concurrent.ThreadPoolExecutor#getTask

在這個方法中我們可以看到,如果工作線程數大於最大線程數,則對工作線程數量進行減一操作,然後返回 null。

所以,這個地方的實際流程應該是: 創建新的工作線程 worker,然後工作線程數進行加一操作。 運行創建的工作線程 worker,開始獲取任務 task。 工作線程數量大於最大線程數,對工作線程數進行減一操作。 返回 null,即沒有獲取到 task。 清理該任務,流程結束。

這樣一加一減,所以真正在執行任務的工作線程數的數量一直沒有發生變化,也就是最大線程數。

怎麼解決這個問題呢?

答案已經呼之欲出啦。

設置核心線程數的時候,同時設置最大線程數即可。其實可以把二者設置爲相同的值:

這樣,活動線程數就能正常提高了。

有的小夥伴就會問了:如果調整之後把活動線程數設置的值太大了,豈不是業務低峯期我們還需要人工把值調的小一點?

不存在的,還記得前面介紹 corePoolSize 參數的含義時的註解嗎:

當 allowCoreThreadTimeOut 參數設置爲 true 的時候,核心線程在空閒了 keepAliveTime 的時間後也會被回收的,相當於線程池自動給你動態修改了。

如何動態指定隊列長度?

前面介紹了最大線程數和核心線程數的動態設置,但是你發現了嗎,並沒有設置隊列長度的 set 方法啊?

有的小機靈鬼說先獲取 Queue 對象出來再看一下呢?

還是沒有,這可咋整呢?

首先我們看一下爲什麼沒有提供隊列長度的 set 方法呢:

因爲隊列的 capacity 是被 final 修飾了呀。

但是美團的那篇文章明明說了,他們也支持隊列的動態調整呀:

可是沒有詳細說明,但是彆着急,接着看後面的內容可以發現他們有一個名字爲 ResizableCapacityLinkedBlockIngQueue 的隊列:

很明顯,這是一個自定義隊列了。

我們也可以按照這個思路自定義一個隊列,讓其可以對 Capacity 參數進行修改即可。

操作起來也非常方便,把 LinkedBlockingQueue 粘貼一份出來,修改個名字,然後把 Capacity 參數的 final 修飾符去掉,並提供其對應的 get/set 方法。

然後在程序裏面把原來的隊列換掉:

運行起來看看效果:

可以看到,隊列大小確實從 10 變成了 100,隊列使用度從 100% 降到了 9%。

我後來去看了美團的那篇文章下面的評論,有個評論是這樣的:

果然不出我所料。

這個過程中涉及到的面試題有哪些?

問題一:線程池被創建后里面有線程嗎?如果沒有的話,你知道有什麼方法對線程池進行預熱嗎?

線程池被創建後如果沒有任務過來,裏面是不會有線程的。如果需要預熱的話可以調用下面的兩個方法:

全部啓動:

僅啓動一個:

問題二:核心線程數會被回收嗎?需要什麼設置?

核心線程數默認是不會被回收的,如果需要回收核心線程數,需要調用下面的方法:

allowCoreThreadTimeOut 該值默認爲 false。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章