執行緒是每個Java開發者都繞不開的大山。而執行緒池更是工作中用得最多,面試也問得最多的知識點。
但很多小夥伴其實對執行緒池還處於一知半解的狀態,不清楚它的核心原理和邏輯,甚至不知道該如何更好地配置執行緒池引數。接下來咱們就來聊聊,如何更好地駕馭執行緒池。
為什麼使用執行緒池?
瞭解一個技術,首先要了解它產生的原因。
大家可以想一想,如果只是為了開多執行緒去跑一些任務,我們不用執行緒池也可以做到,new一個Thread
,呼叫start()
方法就走起。那為什麼我們還需要執行緒池?
執行緒池主要有這三個作用:
統一管理 複用執行緒 控制併發數量
統一管理不難理解,執行緒池其實算是一個執行緒的排程系統。執行緒池裡面有一個排程執行緒,這個排程執行緒用於管理布控整個執行緒池裡的各種任務和事務,例如建立執行緒、銷燬執行緒、任務佇列管理、執行緒佇列管理等等。
複用執行緒是執行緒池最大的優勢。因為建立和銷燬執行緒開銷比較大,如果為每個任務都建立一個新的執行緒,那其實是不划算的。執行緒池實現了執行緒的複用,使得一個執行緒可以執行多個任務,這在需要大量執行緒的場景下(比如HTTP請求等),可以很大程度地節約機器資源。
控制併發數量指的是使用執行緒池可以控制同時執行的執行緒數量。大家知道多執行緒的優勢在於利用計算機的多核心處理能力,但計算機的核心數量是有限的,比如4核、8核等,如果執行緒數量太多,切換執行緒有上下文的開銷,反而會讓整個機器的吞吐量下降。
❝吞吐量指的是單位時間內能夠處理的任務數量。
❞
執行緒池的原理
瞭解到為什麼使用執行緒池以後,我們再來看看它的原理。
首先我們上一個圖:
然後我們來解釋一下圖裡說的幾個概念:
「核心執行緒」:執行緒池中有兩類執行緒,核心執行緒和非核心執行緒。核心執行緒預設情況下建立後,就會一直存在於執行緒池中,即使這個核心執行緒什麼都不幹(鐵飯碗),而非核心執行緒如果長時間的閒置,就會被銷燬(臨時工)。
「任務佇列」:等待佇列,維護著等待執行的Runnable任務物件,是一個執行緒安全的阻塞佇列。
「執行緒池滿」:指的是核心執行緒+非核心執行緒的匯流排程數量達到執行緒池設定的閾值。
「拒絕策略」:執行緒池滿以後,表示當前執行緒池沒有能力處理更多的任務了,那如果來了新的任務該怎麼辦呢?所以在建立執行緒池的時候,可以指定這個拒絕策略。
執行緒池的七個引數
上面介紹了執行緒池的原理,裡面提到的各種閾值,都是線上程池的構造方法裡可以指定的。Java使用ThreadPoolExecutor
這個類來實現的執行緒池。它有幾個過載的構造方法,引數從5個到7個不等,但最終都是呼叫的7個引數的這個構造方法,下面我們分別來介紹一下這幾個引數。
// 七個引數的建構函式
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
複製程式碼
其中,前面是個引數好理解,名字也很表意,它們代表的意義分別是:核心執行緒的最大值,執行緒總數(核心+非核心)最大值,非核心執行緒空閒時間,非核心執行緒空閒時間的單位。
然後介紹一下後面三個引數。
workQueue
任務佇列,是一個執行緒安全阻塞佇列BlockingQueue<Runnable>
,這是一個介面,它有很多實現。任務佇列也是執行緒池用來控制併發數量的關鍵。常見的阻塞佇列實現有這幾種:
LinkedBlockingQueue:鏈式阻塞佇列,底層資料結構是連結串列,預設大小是 Integer.MAX_VALUE
,也可以指定大小;ArrayBlockingQueue:陣列阻塞佇列,底層資料結構是陣列,需要指定佇列的大小; SynchronousQueue:同步佇列,內部容量為0,每個put操作必須等待一個take操作,反之亦然。 DelayQueue:延遲佇列,該佇列中的元素只有當其指定的延遲時間到了,才能夠從佇列中獲取到該元素。
❝一般來說,用LinkedBlockingQueue和ArrayBlockingQueue的場景較多。選擇哪個在於你想不想限制任務佇列的數量。
❞
threadFactory
建立執行緒的工廠 ,用於批量建立執行緒,統一在建立執行緒時設定一些引數,如執行緒名稱、是否守護執行緒、執行緒的優先順序等。ThreadFactory
也是一個介面。如果不指定,會使用DefaultThreadFactory
新建一個預設的執行緒工廠。
很多時候我們會自己實現一個ThreadFactory,在裡面指定執行緒的名稱字首,這樣在排查問題的時候就能一眼看到這個執行緒是在這個執行緒池裡面建立的。
handler
「拒絕處理策略」,執行緒數量大於最大執行緒數就會採用拒絕處理策略,四種拒絕處理的策略為:
「ThreadPoolExecutor.AbortPolicy」:「預設拒絕處理策略」,丟棄任務並丟擲RejectedExecutionException異常。 「ThreadPoolExecutor.DiscardPolicy」:丟棄新來的任務,但是不丟擲異常 「ThreadPoolExecutor.DiscardOldestPolicy」:丟棄佇列頭部(最舊的)的任務,然後重新嘗試執行程式(如果再次失敗,重複此過程)。 「ThreadPoolExecutor.CallerRunsPolicy」:由呼叫執行緒處理該任務。
如何複用執行緒的?
前面我們提到執行緒池的三個好處:統一管理、複用執行緒、控制併發執行緒數量。統一管理體現在threadFactory,控制併發執行緒數量體現在workQueue。那執行緒池是如何複用執行緒的呢?
ThreadPoolExecutor在建立執行緒時,會將執行緒封裝成「工作執行緒worker」,並放入「工作執行緒組」中,然後這個worker反覆從阻塞佇列中拿任務去執行。這個Worker是一個內部類,它繼承了AQS,實現了Runnable:
private final class Worker extends AbstractQueuedSynchronizer implements Runnable{
// 省略
}
複製程式碼
注意這裡的“工作執行緒組”不是前面提到的任務佇列workQueue,而是一個HashSet:
private final HashSet<Worker> workers = new HashSet<Worker>();
複製程式碼
這個worker在建立後,就會去任務佇列裡不斷拿新的任務出來,然後呼叫這個任務的run()
方法。具體原始碼在worker.runWorker
方法裡。
所以看到這裡你明白了嗎?我們通過執行緒池的execute(Runnable command)
方法,扔進執行緒池的執行緒,並沒有像我們平時建立執行緒一樣,新建一個Thread
,然後呼叫start
方法去啟動,而是由一個個worker直接呼叫run()
方法去執行的,這樣就達到了複用執行緒的目的。
使用執行緒池應該注意什麼?
最最重要的一點,注意引數。每一個引數都需要仔細考量,尤其是核心執行緒數量、最大執行緒的數量、非核心執行緒存活時間。
如何配置執行緒池的引數是一個難題,它需要你考慮到方方面面,尤其是你的程式不只一個執行緒池的時候。而且這跟你的任務數量也有一定的關係,所以最好提前做好預估和調研。
核心執行緒不要太多,一般是CPU核心數量的2倍即可。
瞭解原理後,可以根據業務場景去設定執行緒池的引數。
絕大多數時候其實是核心執行緒在工作,只有當任務佇列滿之後,才會啟動非核心執行緒。所以任務佇列是有講究的,如果你使用基於連結串列的阻塞佇列,那它的最大長度是Integer.MAX_VALUE,大量的任務堆積可能會導致OOM。
所以在任務數量可以大概預估的時候,尤其是執行一些自己寫的task之類的程式,比較推薦用基於陣列的阻塞佇列,限制一下阻塞佇列的長度。這樣超過長度的,就可以啟動一些臨時執行緒去處理,加大系統的吞吐量。
拒絕策略也很重要,如果不是很重要的任務,可以直接丟棄掉。如果任務比較重要,會影響到應用的主要邏輯,那還是拋一下異常比較好。
JDK提供了一個建立執行緒池的工具類Executors
,提供了一些靜態方法用於方便地建立一些特殊的執行緒池。它其實也是呼叫的ThreadPoolExecutor
的構造方法,只是封裝了一下,看起來更語義化。
其實如果你瞭解了執行緒池的原理,可以看看這幾個靜態方法的原始碼,看看它們分別是用的什麼引數,對自己以後配置執行緒池引數也有一些參考價值。
執行緒池大概就介紹到這裡,如果你想了解更多的Java執行緒知識,可以去github搜尋RedSpider1/concurrent,這是我們之前寫的一本關於Java多執行緒的,成體系的開源電子書,基本上涵蓋了絕大多數Java多執行緒知識點。歡迎star,issue,pr。
關於作者
微信公眾號:編了個程
個人網站:https://yasinshaw.com
筆名Yasin,一個有深度,有態度,有溫度的程式設計師。工作之餘分享程式設計技術和生活,如果喜歡我的文章,可以順手「關注」一下公眾號,也歡迎「轉發」分享給你的朋友~
在公眾號回覆“面試”或者“學習”可以領取相應的資源哦~