線程是每個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,一個有深度,有態度,有温度的程序員。工作之餘分享編程技術和生活,如果喜歡我的文章,可以順手「關注」一下公眾號,也歡迎「轉發」分享給你的朋友~
在公眾號回覆“面試”或者“學習”可以領取相應的資源哦~