Java線程池配置的常見誤區(qū)
前言
由于線程的創(chuàng)建和銷毀對(duì)操作系統(tǒng)來說都是比較重量級(jí)的操作,所以線程的池化在各種語言內(nèi)都有實(shí)踐,當(dāng)然在 Java 語言中線程池是也非常重要的一部分,有 Doug Lea 大神對(duì)線程池的封裝,我們使用的時(shí)候是非常方便,但也可能會(huì)因?yàn)椴涣私馄渚唧w實(shí)現(xiàn),對(duì)線程池的配置參數(shù)存在誤解。
我們經(jīng)常在一些技術(shù)書籍或博客上看到,向線程池提交任務(wù)時(shí),線程池的執(zhí)行邏輯如下:
- 當(dāng)一個(gè)任務(wù)被提交后,線程池首先檢查正在運(yùn)行的線程數(shù)是否達(dá)到核心線程數(shù),如果未達(dá)到則創(chuàng)建一個(gè)線程。
- 如果線程池內(nèi)正在運(yùn)行的線程數(shù)已經(jīng)達(dá)到了核心線程數(shù),任務(wù)將會(huì)被放到 BlockingQueue 內(nèi)。
- 如果 BlockingQueue 已滿,線程池將會(huì)嘗試將線程數(shù)擴(kuò)充到最大線程池容量。
- 如果當(dāng)前線程池內(nèi)線程數(shù)量已經(jīng)達(dá)到最大線程池容量,則會(huì)執(zhí)行拒絕策略拒絕任務(wù)提交。
流程如圖(摘自美團(tuán)技術(shù)博客):
流程描述沒有問題,但如果某些點(diǎn)未經(jīng)過推敲,容易導(dǎo)致誤解,而且描述中的情境太理想化,如果配置時(shí)不考慮運(yùn)行時(shí)環(huán)境,也會(huì)出現(xiàn)一些非常詭異的問題。
核心池
線程池內(nèi)線程數(shù)量小于等于 coreSize 的部分我稱為核心池,核心池是線程池的常駐部分,內(nèi)部的線程一般不會(huì)被銷毀,我們提交的任務(wù)也應(yīng)該絕大部分都由核心池內(nèi)的線程來執(zhí)行。
線程創(chuàng)建時(shí)機(jī)的誤解
有關(guān)核心池最常見的一個(gè)誤區(qū)是沒搞清楚核心池內(nèi)線程的創(chuàng)建時(shí)機(jī),這個(gè)問題,我覺得甩 10% 的鍋給 Doug Lea 大神應(yīng)該不算過分,因?yàn)樗谖臋n里寫道 “If fewer than corePoolSize threads are running, try to start a new thread with the given command as its first task”,其中 "running" 這個(gè)詞就比較有歧義,因?yàn)樵谖覀兝斫饫?running 是指當(dāng)前線程已被操作系統(tǒng)調(diào)度,擁有操作系統(tǒng)時(shí)間分片,或者被理解為正在執(zhí)行某個(gè)任務(wù)。
基于以上的理解,我們很容易就認(rèn)為如果任務(wù)的 QPS 非常低,線程池內(nèi)線程數(shù)量永遠(yuǎn)也達(dá)不到 coreSize。即如果我們配置了 coreSize 為 1000,實(shí)際上 QPS 只有 1,單個(gè)任務(wù)耗時(shí) 1s,那么核心池大小就會(huì)一直是 1,即使有流量抖動(dòng),核心池也只會(huì)被擴(kuò)容到 3。因?yàn)橐粋€(gè)線程每秒執(zhí)行執(zhí)行一個(gè)任務(wù),剛好不用創(chuàng)建新線程就足以應(yīng)對(duì) 1QPS。
創(chuàng)建過程
但如果簡單設(shè)計(jì)一個(gè)測試,使用 jstack 打印出線程棧并數(shù)一下線程池內(nèi)線程數(shù)量,會(huì)發(fā)現(xiàn)線程池內(nèi)的線程數(shù)會(huì)隨著任務(wù)的提交而逐漸增大,直到達(dá)到 coreSize。
因?yàn)楹诵某氐脑O(shè)計(jì)初衷是想它能作為常駐池,承載日常流量,所以它應(yīng)該被盡快初始化,于是線程池的邏輯是在沒有達(dá)到 coreSize 之前,每一個(gè)任務(wù)都會(huì)創(chuàng)建一個(gè)新的線程,對(duì)應(yīng)的源碼為:
- public void execute(Runnable command) {
- ...
- int c = ctl.get();
- if (workerCountOf(c) < corePoolSize) { // workerCountOf() 方法是獲取線程池內(nèi)線程數(shù)量
- if (addWorker(command, true))
- return;
- c = ctl.get();
- }
- ...
- }
而文檔里的 running 狀態(tài)也指的是線程已經(jīng)被創(chuàng)建,我們也知道線程被創(chuàng)建后,會(huì)在一個(gè) while 循環(huán)里嘗試從 BlockingQueue 里獲取并執(zhí)行任務(wù),說它正在 running 也不為過。
基于此,我們對(duì)一些高并發(fā)服務(wù)進(jìn)行的預(yù)熱,其實(shí)并不是期望 JVM 能對(duì)熱點(diǎn)代碼做 JIT 等優(yōu)化,對(duì)線程池、連接池和本地緩存的預(yù)熱才是重點(diǎn)。
BlockingQueue
BlockingQueue 是線程池內(nèi)的另一個(gè)重要組件,首先它是線程池”生產(chǎn)者-消費(fèi)者”模型的中間媒介,另外它也可以為大量突發(fā)的流量做緩沖,但理解和配置它也經(jīng)常會(huì)出錯(cuò)。
運(yùn)行模型
最常見的錯(cuò)誤是不理解線程池的運(yùn)行模型。首先要明確的一點(diǎn)是線程池并沒有準(zhǔn)確的調(diào)度功能,即它無法感知有哪些線程是處于空閑狀態(tài)的,并把提交的任務(wù)派發(fā)給空閑線程。線程池采用的是”生產(chǎn)者-消費(fèi)者”模式,除了觸發(fā)線程創(chuàng)建的任務(wù)(線程的 firstTask)不會(huì)入 BlockingQueue 外,其他任務(wù)都要進(jìn)入到 BlockingQueue,等待線程池內(nèi)的線程消費(fèi),而任務(wù)會(huì)被哪個(gè)線程消費(fèi)到完全取決于操作系統(tǒng)的調(diào)度。
對(duì)應(yīng)的生產(chǎn)者源碼如下:
- public void execute(Runnable command) {
- ...
- if (isRunning(c) && workQueue.offer(command)) { isRunning() 是判斷線程池處理戚狀態(tài)
- int recheck = ctl.get();
- if (! isRunning(recheck) && remove(command))
- reject(command);
- else if (workerCountOf(recheck) == 0)
- addWorker(null, false);
- }
- ...
- }
對(duì)應(yīng)的消費(fèi)者源碼如下:
- private Runnable getTask() {
- for (;;) {
- ...
- Runnable r = timed ?
- workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
- workQueue.take();
- if (r != null)
- return r;
- ...
- }
- }
BlockingQueue 的緩沖作用
基于”生產(chǎn)者-消費(fèi)者”模型,我們可能會(huì)認(rèn)為如果配置了足夠的消費(fèi)者,線程池就不會(huì)有任何問題。其實(shí)不然,我們還必須考慮并發(fā)量這一因素。
設(shè)想以下情況:有 1000 個(gè)任務(wù)要同時(shí)提交到線程池內(nèi)并發(fā)執(zhí)行,在線程池被初始化完成的情況下,它們都要被放到 BlockingQueue 內(nèi)等待被消費(fèi),在極限情況下,消費(fèi)線程一個(gè)任務(wù)也沒有執(zhí)行完成,那么這 1000 個(gè)請(qǐng)求需要同時(shí)存在于 BlockingQueue 內(nèi),如果配置的 BlockingQueue Size 小于 1000,多余的請(qǐng)求就會(huì)被拒絕。
那么這種極限情況發(fā)生的概率有多大呢?答案是非常大,因?yàn)椴僮飨到y(tǒng)對(duì) I/O 線程的調(diào)度優(yōu)先級(jí)是非常高的,一般我們的任務(wù)都是由 I/O 的準(zhǔn)備或完成(如 tomcat 受理了 http 請(qǐng)求)開始的,所以很有可能被調(diào)度到的都是 tomcat 線程,它們?cè)谝恢蓖€程池內(nèi)提交請(qǐng)求,而消費(fèi)者線程卻調(diào)度不到,導(dǎo)致請(qǐng)求堆積。
我負(fù)責(zé)的服務(wù)就發(fā)生過這種請(qǐng)求被異常拒絕的情況,壓測時(shí) QPS 2000,平均響應(yīng)時(shí)間為 20ms,正常情況下,40 個(gè)線程就可以平衡生產(chǎn)速度,不會(huì)堆積。但在 BlockingQueue Size 為 50 時(shí),即使線程池 coreSize 為 1000,還會(huì)出現(xiàn)請(qǐng)求被線程池拒絕的情況。
這種情況下,BlockingQueue 的重要的意義就是它是一個(gè)能長時(shí)間存儲(chǔ)任務(wù)的容器,能以很小的代價(jià)為線程池提供緩沖。根據(jù)上文可知,線程池能支持BlockingQueue Size個(gè)任務(wù)同時(shí)提交,我們把最大同時(shí)提交的任務(wù)個(gè)數(shù),稱為并發(fā)量,配置線程池時(shí),了解并發(fā)量異常重要。
并發(fā)量的計(jì)算
我們常用 QPS 來衡量服務(wù)壓力,所以配置線程池參數(shù)時(shí)也經(jīng)常參考這個(gè)值,但有時(shí)候 QPS 和并發(fā)量有時(shí)候相關(guān)性并沒有那么高,QPS 還要搭配任務(wù)執(zhí)行時(shí)間來推算峰值并發(fā)量。
比如請(qǐng)求間隔嚴(yán)格相同的接口,平均 QPS 為 1000,它的并發(fā)量峰值是多少呢?我們并沒有辦法估算,因?yàn)槿绻蝿?wù)執(zhí)行時(shí)間為 1ms,那么它的并發(fā)量只有 1;而如果任務(wù)執(zhí)行時(shí)間為 1s,那么并發(fā)量峰值為 1000。
可是知道了任務(wù)執(zhí)行時(shí)間,就能算出并發(fā)量了嗎?也不能,因?yàn)槿绻?qǐng)求的間隔不同,可能 1min 內(nèi)的請(qǐng)求都在一秒內(nèi)發(fā)過來,那這個(gè)并發(fā)量還要乘以 60,所以上面才說知道了 QPS 和任務(wù)執(zhí)行時(shí)間,并發(fā)量也只能靠推算。
計(jì)算并發(fā)量,我一般的經(jīng)驗(yàn)值是 QPS*平均響應(yīng)時(shí)間,再留上一倍的冗余,但如果業(yè)務(wù)重要的話,BlockingQueue Size 設(shè)置大一些也無妨(1000 或以上),畢竟每個(gè)任務(wù)占用的內(nèi)存量很有限。
考慮運(yùn)行時(shí)
GC
除了上面提到的各種情況下,GC 也是一個(gè)很重要的影響因素。
我們都知道 GC 是 Stop the World 的,但這里的 World 指的是 JVM,而一個(gè)請(qǐng)求 I/O 的準(zhǔn)備和完成是操作系統(tǒng)在進(jìn)行的,JVM 停止了,但操作系統(tǒng)還是會(huì)正常受理請(qǐng)求,在 JVM 恢復(fù)后執(zhí)行,所以 GC 是會(huì)堆積請(qǐng)求的。
上文中提到的并發(fā)量計(jì)算一定要考慮到 GC 時(shí)間內(nèi)堆積的請(qǐng)求同時(shí)被受理的情況,堆積的請(qǐng)求數(shù)可以通過 QPS*GC時(shí)間 來簡單得出,還有一定要記得留出冗余。
業(yè)務(wù)峰值
除此之外,配置線程池參數(shù)時(shí),一定要考慮業(yè)務(wù)場景。
假如接口的流量大部分來自于一個(gè)定時(shí)程序,那么平均 QPS 就沒有了任何意義,線程池設(shè)計(jì)時(shí)就要考慮給 BlockingQueue 的 Size 設(shè)置一個(gè)大一些的值;而如果流量非常不平均,一天內(nèi)只有某一小段時(shí)間才有高流量的話,而且線程資源緊張的情況下,就要考慮給線程池的 maxSize 留下較大的冗余;在流量尖刺明顯而響應(yīng)時(shí)間不那么敏感時(shí),也可以設(shè)置較大的 BlockingQueue,允許任務(wù)進(jìn)行一定程度的堆積。
當(dāng)然除了經(jīng)驗(yàn)和計(jì)算外,對(duì)服務(wù)做定時(shí)的壓測無疑更能幫助掌握服務(wù)真實(shí)的情況。
小結(jié)
總結(jié)線程池的配置時(shí),我最大的感受是一定要讀源碼!讀源碼!讀源碼!只看一些書和文章的總結(jié)是無法吃透一些重要概念的,即使搞懂了大部分也很容易會(huì)在一些角落踩坑。深入理解原理后,面對(duì)復(fù)雜情況,才有靈活配置的能力。