這是why的第 45 篇原創文章。說點不一樣的線程池執行策略和線程拒絕策略,探討怎么讓線程池先用完最大線程池再把任務放到隊列中。
荒腔走板
大家好,我是 why,一個四川程序猿,成都好男人。
先是本號的特色,技術分享之前先簡短的荒腔走板聊聊生活。讓文章的溫度更多一點點。
上面的圖是我在一次跑步的過程中拍的。活動之前賽事方搞了個留言活動,收集每公里路牌的一個宣傳語。
我的留言有幸被選中了:
每人知道你在堅持什么,但你自己心里應該清楚。
是在說跑馬拉松,也是在說其他的事情。
我記得那天的太陽,驕陽似火,路上的樹蔭也非常的少。苦就苦在我還報的是超級馬拉松(說是超級馬拉松,其實就是一個全馬 42 km加最后 3 km純上坡的馬拉松)
到底有多曬,我給你看一下對比:
酷暑難耐,以至於 30 公里左右的地方我的心里出現了兩個小人:
一個說:我好累啊,我跑不動了,我要退賽。
一個說:好呀好呀,我也好曬啊,退賽退賽。
我說:呸,看你們兩個不爭氣的東西,讓我帶你去終點
於是在 36 公里的地方碰到了我提交的標語,非常開心,停下來拍了幾張照片。給自己說:堅持不住的時候再堅持一下。
最后的 3 公里上坡,抽筋了不知道多少次。遠遠看見終點拱門的時候我突然想到了在敦煌的時候悟出的一句話:自己給自己的辛苦,不是辛苦,是幸福。
好了,說回文章。
違背直覺的JDK線程池
先用 JDK 線程池來開個題。
還是用我之前這個文章《如何設置線程池參數?美團給出了一個讓面試官虎軀一震的回答。》“先勸退一波”這一小節里面的例題:
問:這是一個自定義線程池,假設這個時候來了 100 個比較耗時的任務,請問有多少個線程在運行?
正確回答在之前的文章中回答了,這里不在贅述。
但是我面試的時候曾經遇到過很多對於 JDK 線程池不了解的朋友。
而這些人當中大多數都有一個通病,那就是遇到不太會的問題,那就去猜。
面試者遇到這個不會的題的時候,表面上微微一笑,實際上我都已經揣摩出他們的內心活動了:
MD,這題我沒背過呀,但是剛剛聽面試官說核心線程數是 10,最大線程數是 30。看題也知道答案不是 10 就是 30。
選擇題,百分之 50 的命中率,難道不賭一把?
等等,30 是最大線程數?最大?我感覺就是它了。
於是在電光火石一瞬間的思考后,和我對視起來,自信的說:
於是我也是微微一笑,告訴他:下去再了解一下吧,我們聊聊別的。
確實,如果完全不了解 JDK 線程池運行規則,按照直覺來說,我也會覺得應該是,不管是核心還是最大線程數,有任務來了應該先把線程池里面可用的線程用完了,然后再把任務提交到隊列里面去排隊。
可惜 JDK 的線程池,就是反直覺的。
那有符合我們直覺的線程池嗎?
有的,你經常用的的 Tomcat ,它里面的線程池的運行過程就是先把最大線程數用完,然后再提交任務到隊列里面去的。
我帶你剖析一下。
Tomcat線程池
先打開 Tomcat 的 server.xml 看一下:
眼熟吧?哪一個學過 java web 的人沒有配置過這個文件?哪一個配置過這個文件的人沒有留意過 Executor 配置?
具體的可配置項可以查看官方文檔:
http://tomcat.apache.org/tomcat-9.0-doc/config/executor.html
同時我找到一個可配置的參數的中文說明如下:
注意其中的第一個參數是 className,圖片中少了個字母 c。
然后還有兩個參數沒有介紹,我補充一下:
1.prestartminSpareThreads:boolean 類型,當服務器啟動時,是否要創建出最小空閑線程(核心線程)數量的線程,默認值為 false 。
2.threadRenewalDelay:long 類型,當我們配置了 ThreadLocalLeakPreventionListener 的時候,它會監聽一個請求是否停止。當線程停止后,如果有需要,會進行重建,為了避免多個線程,該設置可以檢測是否有 2 個線程同時被創建,如果是,則會按照該參數,延遲指定時間創建。 如果拒絕,則線程不會被重建。默認為 1000 ms,設定為負值表示不更新。
我們主要關注 className 參數,如果不配置,默認實現是:
org.apache.catalina.core.StandardThreadExecutor
我們先解讀一下這個方法(注意,本文中 Tomcat 源碼版本號為:10.0.0-M4):
org.apache.catalina.core.StandardThreadExecutor#startInternal
從 123 行到 130 行,就是構建 Tomcat 線程池的地方,很關鍵,我解讀一下:
123行
taskqueue = new TaskQueue(maxQueueSize);
創建一個 TaskQueue 隊列,這個隊列是繼承自 LinkedBlockingQueue 的:
該隊列上的注釋值得關注一下:
主要是說這是一個專門為線程池設計的一個任務隊列。配合線程池使用的時候和普通隊列有不一樣的地方。
同時傳遞了一個隊列長度,默認為 Integer.MAX_VALUE:
124行
TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon,getThreadPriority());
構建一個 ThreadFactory,其三個入參分為如下:
namePrefix:名稱前綴。可以指定,其默認是“tomcat-exec-”。
daemon:是否以守護線程模式啟動。默認是 true。
priority:線程優先級。是一個 1 到 10 之前的數,默認是 5。
125行
executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS,taskqueue, tf);
構建線程池,其 6 個入參分別如下:
這個具體含義我就不解釋了,和 JDK 線程池是一樣的。
只是給大家看一下默認參數。
另外還需要十分注意的一點是,這里的 ThreadPoolExecuteor 是 Tomcat 的,不是 JDK 的,雖然名字一樣。
看一下 Tomcat 的 ThreadPoolExecuteor注釋,里面提到了兩個點,一是已提交總數,二是拒絕策略。后面都會講到。
126行
executor.setThreadRenewalDelay(threadRenewalDelay);
設置 threadRenewalDelay 參數。不是本文重點,可以先不關心。
127 - 129行
if (prestartminSpareThreads) {
executor.prestartAllCoreThreads();
}
設置是否預啟動所有的核心線程池,這個參數在之前文章中也有講到過。
prestartminSpareThreads 參數默認是 false。但是我覺得這個地方你設置為 true 也是多次一舉。完全沒有必要。
為什么呢?
因為在 125 行構建線程池的時候已經調用過這個方法了:
從源碼可以看出,不管你調用哪一個線程池構造方法,都會去調用 prestartAllCoreThreads 方法。
所以,這算不算 Tomcat 的一個小 Bug 呢?快拿起你的鍵盤給它提 pr 吧。
130行
taskqueue.setParent(executor);
這行代碼非常關鍵。沒有這行代碼,Tomcat 的線程池則會表現的和 JDK 的線程池一樣。
拿下面的程序舉例:
自定義線程池最多可以容納 150+300 個任務。
當 24 行注釋的時候,Tomcat 線程池運行的過程和 JDK 線程池的運行過程一樣,運行的線程數只會是核心程序數 5。
當 24 行取消注釋的時候,Tomcat 線程池就會一直創建線程個數到 150 個,然后把剩下的任務提交到自定義的 TaskQueue 隊列里面去。
我再提供一個復制粘貼直接運行版本,你分別運行一下,試一試,看看結果:
public class TomcatThreadPoolExecutorTest {
public static void main(String[] args) throws InterruptedException {
String namePrefix = "why不止技術-exec-";
boolean daemon = true;
TaskQueue taskqueue = new TaskQueue(300);
TaskThreadFactory tf = new TaskThreadFactory(namePrefix, daemon, Thread.NORM_PRIORITY);
ThreadPoolExecutor executor = new ThreadPoolExecutor(5,
150, 60000, TimeUnit.MILLISECONDS, taskqueue, tf);
//taskqueue.setParent(executor);
for (int i = 0; i < 300; i++) {
try {
executor.execute(() -> {
logStatus(executor, "創建任務");
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
} catch (Exception e) {
e.printStackTrace();
}
}
Thread.currentThread().join();
}
private static void logStatus(ThreadPoolExecutor executor, String name) {
TaskQueue queue = (TaskQueue) executor.getQueue();
System.out.println(Thread.currentThread().getName() + "-" + name + "-:" +
"核心線程數:" + executor.getCorePoolSize() +
"\t活動線程數:" + executor.getActiveCount() +
"\t最大線程數:" + executor.getMaximumPoolSize() +
"\t總任務數:" + executor.getTaskCount() +
"\t當前排隊線程數:" + queue.size() +
"\t隊列剩余大小:" + queue.remainingCapacity());
}
}
接着就去分析這行代碼的用途,看看這一行代碼,怎么就反轉了 JDK 線程池的運行過程。
源碼之下無秘密
如果你對 JDK 線程池的源碼熟悉一點的話,你大概能猜到 Tomcat 肯定是在控制新建線程的地方做了手腳,也就是下面這個地方:
PS:需要說明一下的是,上面的截圖是 JDK 線程池的 execute 方法。因為 Tomcat 線程池的提交也是復用的這個方法。但是 workQueue 不是同一個隊列。
那你先把工作流程和各個參數都摸熟了,然后寫個 Demo ,接着去瘋狂的 Debug 吧。然后你總會找到這個地方的,而且你會發現,不難找。
好了,上面主要關注我圈起來的部分。
在截圖的 1371 行,如果沒有把任務成功放到隊列里面(前提是線程池是運行狀態),則會執行 1378 行的邏輯,而這個邏輯,就是創建非核心線程的邏輯。
所以,經過上面的推導之后,一切都清晰了,Tomcat 只需要在自定義隊列的 offer 方法中做文章即可。
所以,我們重點關注一下該方法:
org.apache.tomcat.util.threads.TaskQueue#offer
為了更加直觀的看出來其運行流暢,我在第 80 行打了個斷點運行程序如下:
可以看到里面的幾個參數,下面的講解會用到這里面的參數:
第一個 if 判斷
首先第一個 if,判斷 parent 是否為空:
從斷點運行參數截圖可以看出,這里的 parent 就是 Tomcat 的 ThreadPoolExecutor 類。
當 parent 為 null 時,直接調用原始的 offer 方法。
所以,還記得我前面說的嗎?
現在你知道為什么了吧?
源碼,就是這個源碼。道理,就是這么個道理。
所以,這里不為空,不滿足條件,進入下一個 if 判斷。
第二個 if 判斷
首先,需要明確的是,能進入到第二個判斷的時候,當前運行中的線程數肯定是大於等於核心線程數(因為已經在執行往隊列里面放的邏輯了,說明核心線程數肯定是滿了),小於最大線程數的。
其中 getPoolSize 方法是獲取線程池中當前運行的線程數量:
所以,第二個 if 判斷的是運行中的線程數是否等於最大線程數。如果等於,說明所有線程都在工作了,把任務扔到隊列里面去。
從斷點運行參數截圖可以看到, 當前運行數為 5 ,最大線程數為 150。不滿足條件,進入下一個 if 判斷。
第三個 if 判斷
首先我們看看 getSubmittedCount 獲取的是個什么玩意:
getSubmittedCount 獲取的是當前已經提交但是還未完成的任務的數量,其值是隊列中的數量加上正在運行的任務的數量。
從斷點運行參數截圖可以看到,當前情況下該數據為 6。
而 parent.getPoolSize() 為 5。
不滿足條件,進入下一個 if 判斷。
但是這個地方需要多說一句的是,如果當已經提交但是還未完成的任務的數量小於線程池中運行線程的數量時,Tomcat 的做法是把任務放到隊列里面去,而不是立即執行。
其實這樣想來也是很符合邏輯且簡單的做法的。
反正有空閑的線程嘛,扔到隊列里面去就被空閑的線程消費了。又何必立即執行呢?破壞流程不說,還需要額外實現。
出力不討好。沒必要。
第四個 if 判斷
這個判斷就很關鍵了。
如果當前運行中的線程數量小於最大線程數,返回 false。
注意哦,前面的幾個 if 判斷都是不滿足條件就放入隊列哦。而這里是不滿足條件,就返回 false。
返回 false 意味着什么?
意味着要執行 1378 行代碼,去創建線程了呀。
所以,整個流程圖大概就是這樣:
再聊聊拒絕策略
拒絕策略需要看這個方法:
org.apache.tomcat.util.threads.ThreadPoolExecutor#execute(java.lang.Runnable, long, java.util.concurrent.TimeUnit)
看一下該方法上的注釋:
如果隊列滿了,則會等待指定時間后再次放入隊列。
如果再次放入隊列的時候還是滿的,則拋出拒絕異常。
這個邏輯就類似於你去上廁所,發現坑位全都被人占着。這個時候你的身體告訴你,你括弧肌最多還能在忍一分鍾。
於是,你掐着表在門口,深呼吸,閉眼冥想,等了一分鍾。
運氣好的,再去一看:哎,有個空的坑位了,趕緊占着。
運氣不好,再去一看:哎,還是沒有位置,怎么辦呢?拋異常吧。具體怎么拋就不說了,自行想象。
所以我們看看這個地方,Tomcat 的代碼是怎么實現的:
catch 部分首先判斷隊列是不是 Tomcat 的自定義隊列。如果是,則進入這個 if 分支。
關鍵的邏輯就在這個 if 判斷里面了。
可以看到 172 行:
if (!queue.force(command, timeout, unit))
調用了隊列的 force 方法。我們知道 BlockingQueue 是沒有 force 方法的。
所以這個force 是 Tomcat 自定義隊列特有的方法:
public boolean force(Runnable o, long timeout, TimeUnit unit) throws InterruptedException {
if (parent == null || parent.isShutdown())
throw new RejectedExecutionException(sm.getString("taskQueue.notRunning"));
//forces the item onto the queue, to be used if the task is rejected
return super.offer(o,timeout,unit);
}
進去一看發現:害,也不過如此嘛。就是對原生 offer 方法的一層包裝而已。
如果成功加入隊列則萬事大吉,啥事沒有。
如果沒有成功加入隊列,則拋出異常,並維護 submittedCount 參數。
前面說過:submittedCount 參數 = 隊列中的任務個數 + 正在運行的任務數。
所以,這里需要進行減一操作。
拒絕策略就說完了。
但是這個地方的源碼是我帶你找到的。如果你想自己找到應該怎么操作呢?
你想啊,你是想測試拒絕策略。那只要觸發其拒絕策略就行了。
比如下面這樣:
給一個只能容納 450 個任務的線程池提交 500 個任務。
然后就會拋出這個異常:
就會找到 Tomcat 線程池的 174 行:
然后你打上一個斷點,玩去吧。
那我們剛剛說的,可以在門口等一分鍾再進坑是怎么回事呢?
我們把參數告訴線程池就可以了,比如下面這樣:
然后再去運行,因為隊列滿了后,觸發拒絕異常,然后等 3 秒再去提交任務。而我們提交的一個任務 2 秒就能被執行完。
所以,這個場景下,所有的任務都會被正常執行。
現在你知道為了把你給它的任務盡量快速、全部的執行完成,Tomcat有多努力了嗎?
小彩蛋
在看 Tomcat 自定義隊列的時候我發現了作者這樣的注釋:
這個地方作用是把 forcedRemainingCapacity 參數設置為 0。
這個參數是在什么時候設置的呢?
就是下面這個關閉方法的時候:
org.apache.tomcat.util.threads.ThreadPoolExecutor#contextStopping
可以看到,調用 setCorePoolSize 方法之前,作者直接把 forcedRemainingCapacity 參數設置為了 0。
注釋上面寫的原因是JDK ThreadPoolExecutor.setCorePoolSize 方法會去檢查 remainingCapacity 是否為 0。
至於為什么會去做這樣的檢查,Tomcat 的作者兩次表示:I don't see why。I did not understand why。
so,他 fake 了 condition。
總之就是他說他也不明白為什么JDK 線程池 setCorePoolSize 方法調小核心線程池的時候要的限制隊列剩余長度為 0 ,反正這樣寫就對了。
別問,問就是規定。
於是我去看了 JDK 線程池的 setCorePoolSize 方法,發現這個限制是在 jdk 1.6 里有,1.6 之后的版本對線程池進行了大規模的重構,取消了這個限制:
那 Tomcat 直接設置為 0 會帶來什么問題呢?
正常的邏輯是隊列剩余大小 = 隊列長度 - 隊列里排隊的任務數。
而當你對其線程池(隊列長度為300)進行監控的時候正常情況應該是這樣:
但是當你調用 contextStopping 方法后可能會出現這樣的問題:
很明顯不符合上面的算法了。
好了,如果你們以后需要對 Tomcat 的線程池進行監控,且 JDK 版本在 1.6版本以上。那你可以去掉這個限制,以免誤報警。
好了,恭喜你,朋友。又學到了一個基本用不上的知識點,奇怪的知識又增加了一點點。
Dubbo 線程池
這里再擴展一個 Dubbo 的線程池實現。
org.apache.dubbo.common.threadpool.support.eager.EagerThreadPoolExecutor
你可以看一下,思想還是這個思想:
但是 execute 方法有點不一樣:
從代碼上看,這里放入失敗之后又立馬調了一次 offer 方法,且沒有等待時間。
也就是說兩次 offer 的間隔是非常的短的。
其實我不太明白為什么這樣去寫,可能是作者留着口子好擴展吧?
因為如果這樣寫,為什么不直接調用這個方法呢?
java.util.concurrent.LinkedBlockingQueue#offer(E)
也是作者是想在極短的時間能賭一下吧?誰知道呢?
然后可以發現該線程池在拒絕策略上也做了很大的文章:
可以看到日志打印的非常詳盡,warn 級別:
dumpJStack 方法,看名字也知道它是要去 Dump 線程了,保留現場:
在這個方法里面,他用了 JDK 默認的線程池,去異常 Dump 線程。
等等,阿里開發規范不是說了不建議用默認線程池嗎?
其實這個規范看你怎么去拿捏。在這個場景下,用自帶的線程池就能滿足需求了。
而且你看第二個紅框:提交之后就執行了 shutdown 方法,上面還給了個貼心警告。
必須要 shutdown 線程池,不然會導致 OOM。
這就是細節呀,朋友們。魔鬼都在細節里!
這里為什么用的 shutdown 不是 shutdownNow ?他們的區別是什么?為什么不調用 shutdown 方法會 OOM?
知識點呀,朋友們,都是知識點啊!
好了,到這里本文的分享也到了尾聲。
以后當面試官問你 JDK 線程池的運行流程的時候,你答完之后畫風一轉,再來一個:
其實我們也可以先把最大線程數用完,然后再讓任務進入隊列。通過自定義隊列,重寫其 offer 方法就可以實現。目前我知道的 Tomcat 和 Dubbo 都提供了這樣策略的線程池。
輕描淡寫之間又裝了一個漂亮逼。讓面試官能進入下一個知識點,讓你能更多的展現自己。
最后說一句(求關注)
本文主要介紹了 Tomcat 線程池的運行流程,和 JDK 線程池的流程比起來,它確實不一樣。
而 Tomcat 線程池為什么要這樣做呢?
其實就是因為 Tomcat 處理的多是 IO 密集型任務,用戶在前面等着響應呢,結果你明明還能處理,卻讓用戶的請求入隊等待?
這樣不好,不好。
說到底,又回到了任務類型是 IO 密集型還是 CPU 密集型這個話題上來。
有興趣的可以看看我的這篇文章:《如何設置線程池參數?美團給出了一個讓面試官虎軀一震的回答。》
點個贊吧,周更很累的,不要白嫖我,需要一點正反饋。
感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。
我是why技術,一個不是大佬,但是喜歡分享,又暖又有料的四川好男人。
歡迎關注公眾號【why不止技術】,堅持輸出原創。分享技術、品味生活,願你我共同進步。