原生線程池這么強(qiáng)大,Tomcat 為何還需擴(kuò)展線程池?
前言
Tomcat/Jetty 是目前比較流行的 Web 容器,兩者接受請求之后都會轉(zhuǎn)交給線程池處理,這樣可以有效提高處理的能力與并發(fā)度。JDK 提高完整線程池實(shí)現(xiàn),但是 Tomcat/Jetty 都沒有直接使用。Jetty 采用自研方案,內(nèi)部實(shí)現(xiàn) QueuedThreadPool 線程池組件,而 Tomcat 采用擴(kuò)展方案,踩在 JDK 線程池的肩膀上,擴(kuò)展 JDK 原生線程池。
JDK 原生線程池可以說功能比較完善,使用也比較簡單,那為何 Tomcat/Jetty 卻不選擇這個(gè)方案,反而自己去動手實(shí)現(xiàn)那?
JDK 線程池
通常我們可以將執(zhí)行的任務(wù)分為兩類:
- cpu 密集型任務(wù)
- io 密集型任務(wù)
cpu 密集型任務(wù),需要線程長時(shí)間進(jìn)行的復(fù)雜的運(yùn)算,這種類型的任務(wù)需要少創(chuàng)建線程,過多的線程將會頻繁引起上文切換,降低任務(wù)處理處理速度。
而 io 密集型任務(wù),由于線程并不是一直在運(yùn)行,可能大部分時(shí)間在等待 IO 讀取/寫入數(shù)據(jù),增加線程數(shù)量可以提高并發(fā)度,盡可能多處理任務(wù)。
JDK 原生線程池工作流程如下:
線程池執(zhí)行流程圖
“詳情可以查看 一文教你安全的關(guān)閉線程池, 上圖假設(shè)使用 LinkedBlockingQueue。
“靈魂拷問:上述流程是否記錯(cuò)過?在很長一段時(shí)間內(nèi),我都認(rèn)為線程數(shù)量到達(dá)最大線程數(shù),才放入隊(duì)列中。 ̄□ ̄||
上圖中可以發(fā)現(xiàn)只要線程池線程數(shù)量大于核心線程數(shù),就會先將任務(wù)加入到任務(wù)隊(duì)列中,只有任務(wù)隊(duì)列加入失敗,才會再新建線程。也就是說原生線程池隊(duì)列未滿之前,最多只有核心線程數(shù)量線程。
這種策略顯然比較適合處理 cpu 密集型任務(wù),但是對于 io 密集型任務(wù),如數(shù)據(jù)庫查詢,rpc 請求調(diào)用等,就不是很友好了。
由于 Tomcat/Jetty 需要處理大量客戶端請求任務(wù),如果采用原生線程池,一旦接受請求數(shù)量大于線程池核心線程數(shù),這些請求就會被放入到隊(duì)列中,等待核心線程處理。這樣做顯然降低這些請求總體處理速度,所以兩者都沒采用 JDK 原生線程池。
解決上面的辦法可以像 Jetty 自己實(shí)現(xiàn)線程池組件,這樣就可以更加適配內(nèi)部邏輯,不過開發(fā)難度比較大,另一種就像 Tomcat 一樣,擴(kuò)展原生 JDK 線程池,實(shí)現(xiàn)比較簡單。
下面主要以 Tomcat 擴(kuò)展線程池,講講其實(shí)現(xiàn)原理。
擴(kuò)展線程池
首先我們從 JDK 線程池源碼出發(fā),查看如何這個(gè)基礎(chǔ)上擴(kuò)展。
可以看到線程池流程主要分為三步,第二步根據(jù) queue#offer 方法返回結(jié)果,判斷是否需要新建線程。
JDK 原生隊(duì)列類型 LinkedBlockingQueue, SynchronousQueue,兩者實(shí)現(xiàn)邏輯不盡相同。
LinkedBlockingQueue
offer 方法內(nèi)部將會根據(jù)隊(duì)列是否已滿作為判斷條件。若隊(duì)列已滿,返回 false,若隊(duì)列未滿,則將任務(wù)加入隊(duì)列中,且返回 true。
SynchronousQueue
這個(gè)隊(duì)列比較特殊,內(nèi)部不會儲存任何數(shù)據(jù)。若有線程將任務(wù)放入其中將會被阻塞,直到其他線程將任務(wù)取出。反之,若無其他線程將任務(wù)放入其中,該隊(duì)列取任務(wù)的方法也將會被阻塞,直到其他線程將任務(wù)放入。
對于 offer 方法來說,若有其他線程正在被取方法阻塞,該方法將會返回 true。反之,offer 方法將會返回 false。
所以若想實(shí)現(xiàn)適合 io 密集型任務(wù)線程池,即優(yōu)先新建線程處理任務(wù),關(guān)鍵在于 queue#offer 方法??梢灾貙懺摲椒▋?nèi)部邏輯,只要當(dāng)前線程池?cái)?shù)量小于最大線程數(shù),該方法返回false,線程池新建線程處理。
當(dāng)然上述實(shí)現(xiàn)邏輯比較糙,下面我們就從 Tomcat 源碼查看其實(shí)現(xiàn)邏輯。
Tomcat 擴(kuò)展線程池
Tomcat 擴(kuò)展線程池直接繼承 JDK 線程池 java.util.concurrent.ThreadPoolExecutor,重寫部分方法的邏輯。另外還實(shí)現(xiàn)了 TaskQueue,直接繼承 LinkedBlockingQueue,重寫 offer 方法。
首先查看 Tomcat 線程池的使用方法。
可以看到 Tomcat 線程池使用方法與普通的線程池差不太多。
接著我們查看一下 Tomcat 線程池核心方法 execute 的邏輯。
execute 方法邏輯比較簡單,任務(wù)核心還是交給 Java 原生線程池處理。這里主要增加一個(gè)重試策略,如果原生線程池執(zhí)行拒絕策略的情況,拋出 RejectedExecutionException 異常。這里將會捕獲,然后重新再次嘗試將任務(wù)加入到 TaskQueue ,盡最大可能執(zhí)行任務(wù)。
這里需要注意 submittedCount 變量。這是 Tomcat 線程池內(nèi)部一個(gè)重要的參數(shù),它是一個(gè) AtomicInteger 變量,將會實(shí)時(shí)統(tǒng)計(jì)已經(jīng)提交到線程池中,但還沒有執(zhí)行結(jié)束的任務(wù)。也就是說 submittedCount 等于線程池隊(duì)列中的任務(wù)數(shù)加上線程池工作線程正在執(zhí)行的任務(wù)。TaskQueue#offer 將會使用該參數(shù)實(shí)現(xiàn)相應(yīng)的邏輯。
接著我們主要查看 TaskQueue#offer 方法邏輯。
核心邏輯在于第三步,這里如果 submittedCount 小于當(dāng)前線程池線程數(shù)量,將會返回false。上面我們講到 offer 方法返回 false,線程池將會直接創(chuàng)建新線程。
Dubbo 2.6.X 版本增加 EagerThreadPool,其實(shí)現(xiàn)原理與 Tomcat 線程池差不多,感興趣的小伙伴可以自行翻閱。
折衷方法
上述擴(kuò)展方法雖然看起不是很難,但是自己實(shí)現(xiàn)代價(jià)可能就比較大。若不想擴(kuò)展線程池運(yùn)行 io 密集型任務(wù),可以采用下面這種折衷方法。
new ThreadPoolExecutor(10, 10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue
不過使用這種方式將會使 keepAliveTime 失效,線程一旦被創(chuàng)建,將會一直存在,比較浪費(fèi)系統(tǒng)資源。
總結(jié)
JDK 實(shí)現(xiàn)線程池功能比較完善,但是比較適合運(yùn)行 CPU 密集型任務(wù),不適合 IO 密集型的任務(wù)。對于 IO 密集型任務(wù)可以間接通過設(shè)置線程池參數(shù)方式做到。