7000字+24張圖帶你徹底弄懂執行緒池

三友的java日記發表於2022-05-21
7000字+24張圖帶你徹底弄懂執行緒池

大家好。今天跟大家聊一聊無論是在工作中常用還是在面試中常問的執行緒池,通過畫圖的方式來徹底弄懂執行緒池的工作原理,以及在實際專案中該如何自定義適合業務的執行緒池。

一、什麼是執行緒池

執行緒池其實是一種池化的技術的實現,池化技術的核心思想其實就是實現資源的一個複用,避免資源的重複建立和銷燬帶來的效能開銷。線上程池中,執行緒池可以管理一堆執行緒,讓執行緒執行完任務之後不會進行銷燬,而是繼續去處理其它執行緒已經提交的任務。

使用執行緒池的好處

  • 降低資源消耗。通過重複利用已建立的執行緒降低執行緒建立和銷燬造成的消耗。
  • 提高響應速度。當任務到達時,任務可以不需要的等到執行緒建立就能立即執行。
  • 提高執行緒的可管理性。執行緒是稀缺資源,如果無限制的建立,不僅會消耗系統資源,還會降低系統 的穩定性,使用執行緒池可以進行統一的分配,調優和監控。

二、執行緒池的構造

Java中主要是通過構建ThreadPoolExecutor來建立執行緒池的。接下來我們看一下執行緒池是如何構造出來的

ThreadPoolExecutor的構造方法

7000字+24張圖帶你徹底弄懂執行緒池
  • corePoolSize:執行緒池中用來工作的核心的執行緒數量。
  • maximumPoolSize:最大執行緒數,執行緒池允許建立的最大執行緒數。
  • keepAliveTime:超出 corePoolSize 後建立的執行緒存活時間或者是所有執行緒最大存活時間,取決於配置。
  • unit:keepAliveTime 的時間單位。
  • workQueue:任務佇列,是一個阻塞佇列,當執行緒數已達到核心執行緒數,會將任務儲存在阻塞佇列中。
  • threadFactory :執行緒池內部建立執行緒所用的工廠。
  • handler:拒絕策略;當佇列已滿並且執行緒數量達到最大執行緒數量時,會呼叫該方法處理該任務。

執行緒池的構造其實很簡單,就是傳入一堆引數,然後進行簡單的賦值操作。

三、執行緒池的執行原理

說完執行緒池的核心構造引數的意思,接下來就來畫圖講解這些引數線上程池中是如何工作的。

執行緒池剛建立出來是什麼樣子呢,如下圖

7000字+24張圖帶你徹底弄懂執行緒池

不錯,剛建立出來的執行緒池中只有一個構造時傳入的阻塞佇列而已,此時裡面並沒有的任何執行緒,但是如果你想要在執行之前已經建立好核心執行緒數,可以呼叫prestartAllCoreThreads方法來實現,預設是沒有執行緒的。

當有執行緒通過execute方法提交了一個任務,會發生什麼呢?

提交任務的時候,其實會去進行任務的處理

首先會去判斷當前執行緒池的執行緒數是否小於核心執行緒數,也就是執行緒池構造時傳入的引數corePoolSize。

如果小於,那麼就直接通過ThreadFactory建立一個執行緒來執行這個任務,如圖

7000字+24張圖帶你徹底弄懂執行緒池

當任務執行完之後,執行緒不會退出,而是會去從阻塞佇列中獲取任務,如下圖

7000字+24張圖帶你徹底弄懂執行緒池

接下來如果又提交了一個任務,也會按照上述的步驟,去判斷是否小於核心執行緒數,如果小於,還是會建立執行緒來執行任務,執行完之後也會從阻塞佇列中獲取任務。這裡有個細節,就是提交任務的時候,就算有執行緒池裡的執行緒從阻塞佇列中獲取不到任務,如果執行緒池裡的執行緒數還是小於核心執行緒數,那麼依然會繼續建立執行緒,而不是複用已有的執行緒。

如果執行緒池裡的執行緒數不再小於核心執行緒數呢?那麼此時就會嘗試將任務放入阻塞佇列中,入隊成功之後,如圖

7000字+24張圖帶你徹底弄懂執行緒池

這樣在阻塞的執行緒就可以獲取到任務了。

但是,隨著任務越來越多,佇列已經滿了,任務放入失敗了,那怎麼辦呢?

此時就會判斷當前執行緒池裡的執行緒數是否小於最大執行緒數,也就是入參時的maximumPoolSize引數

如果小於最大執行緒數,那麼也會建立非核心執行緒來執行提交的任務,如圖

7000字+24張圖帶你徹底弄懂執行緒池

所以,從這裡可以發現,就算佇列中有任務,新建立的執行緒還是優先處理這個提交的任務,而不是從佇列中獲取已有的任務執行,從這可以看出,先提交的任務不一定先執行。

但是不幸的事發生了,執行緒數已經達到了最大執行緒數量,那麼此時會怎麼辦呢?

此時就會執行拒絕策略,也就是構造執行緒池的時候,傳入的RejectedExecutionHandler物件,來處理這個任務。

7000字+24張圖帶你徹底弄懂執行緒池

RejectedExecutionHandler的實現JDK自帶的預設有4種

  • AbortPolicy:丟棄任務,丟擲執行時異常
  • CallerRunsPolicy:由提交任務的執行緒來執行任務
  • DiscardPolicy:丟棄這個任務,但是不拋異常
  • DiscardOldestPolicy:從佇列中剔除最先進入佇列的任務,然後再次提交任務

執行緒池建立的時候,如果不指定拒絕策略就預設是AbortPolicy策略。當然,你也可以自己實現RejectedExecutionHandler介面,比如將任務存在資料庫或者快取中,這樣就資料庫或者快取中獲取到被拒絕掉的任務了。

到這裡,我們發現,執行緒池構造的幾個引數corePoolSize、maximumPoolSize、workQueue、threadFactory、handler我們都在上述的執行過程中講到了,那麼還差兩個引數keepAliveTime和unit(unit是keepAliveTime的時間單位)沒講到,所以keepAliveTime是如何起到作用的呢,這個問題留到後面分析。

說完整個執行的流程,接下來看看execute方法程式碼是如何實現的。

7000字+24張圖帶你徹底弄懂執行緒池
  • workerCountOf(c)<corePoolSize:這行程式碼就是判斷是否小於核心執行緒數,是的話就通過addWorker方法,addWorker就是新增執行緒來執行任務。
  • workQueue.offer(command):這行程式碼就表示嘗試往阻塞佇列中新增任務
  • 新增失敗之後就會再次呼叫addWorker方法嘗試新增非核心執行緒來執行任務
  • 如果還是新增非核心執行緒失敗了,那麼就會呼叫reject(command)來拒絕這個任務。

最後再來另畫一張圖總結execute執行流程

7000字+24張圖帶你徹底弄懂執行緒池

四、執行緒池中執行緒實現複用的原理

執行緒池的核心功能就是實現了執行緒的重複利用,那麼執行緒池是如何實現執行緒的複用呢?

執行緒線上程池內部其實是被封裝成一個Worker物件

7000字+24張圖帶你徹底弄懂執行緒池

Worker繼承了AQS,也就是有一定鎖的特性。

建立執行緒來執行任務的方法上面提到是通過addWorker方法建立的。在建立Worker物件的時候,會把執行緒和任務一起封裝到Worker內部,然後呼叫runWorker方法來讓執行緒執行任務,接下來我們就來看一下runWorker方法。

7000字+24張圖帶你徹底弄懂執行緒池

從這張圖可以看出執行緒執行完任務不會退出的原因,runWorker內部使用了while死迴圈,當第一個任務執行完之後,會不斷地通過getTask方法獲取任務,只要能獲取到任務,就會呼叫run方法,繼續執行任務,這就是執行緒能夠複用的主要原因。

但是如果從getTask獲取不到方法的時候,最後就會呼叫finally中的processWorkerExit方法,來將執行緒退出。

這裡有個一個細節就是,因為Worker繼承了AQS,每次在執行任務之前都會呼叫Worker的lock方法,執行完任務之後,會呼叫unlock方法,這樣做的目的就可以通過Woker的加鎖狀態就能判斷出當前執行緒是否正在執行任務。如果想知道執行緒是否正在執行任務,只需要呼叫Woker的tryLock方法,根據是否加鎖成功就能判斷,加鎖成功說明當前執行緒沒有加鎖,也就沒有執行任務了,在呼叫shutdown方法關閉執行緒池的時候,就用這種方式來判斷執行緒有沒有在執行任務,如果沒有的話,來嘗試打斷沒有執行任務的執行緒。

五、執行緒是如何獲取任務的以及如何實現超時的

上一節我們說到,執行緒在執行完任務之後,會繼續從getTask方法中獲取任務,獲取不到就會退出。接下來我們就來看一看getTask方法的實現。

7000字+24張圖帶你徹底弄懂執行緒池

getTask方法,前面就是執行緒池的一些狀態的判斷,這裡有一行程式碼

boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

這行程式碼是判斷,當前過來獲取任務的執行緒是否可以超時退出。如果allowCoreThreadTimeOut設定為true或者執行緒池當前的執行緒數大於核心執行緒數,也就是corePoolSize,那麼該獲取任務的執行緒就可以超時退出。

那是怎麼做到超時退出呢,就是這行核心程式碼

Runnable r = timed ?
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take();

會根據是否允許超時來選擇呼叫阻塞佇列workQueue的poll方法或者take方法。如果允許超時,則會呼叫poll方法,傳入keepAliveTime,也就是構造執行緒池時傳入的空閒時間,這個方法的意思就是從佇列中阻塞keepAliveTime時間來獲取任務,獲取不到就會返回null;如果不允許超時,就會呼叫take方法,這個方法會一直阻塞獲取任務,直到從佇列中獲取到任務位置。從這裡可以看到keepAliveTime是如何使用的了。

所以到這裡應該就知道執行緒池中的執行緒為什麼可以做到空閒一定時間就退出了吧。其實最主要的是利用了阻塞佇列的poll方法的實現,這個方法可以指定超時時間,一旦執行緒達到了keepAliveTime還沒有獲取到任務,那麼就會返回null,上一小節提到,getTask方法返回null,執行緒就會退出。

這裡也有一個細節,就是判斷當前獲取任務的執行緒是否可以超時退出的時候,如果將allowCoreThreadTimeOut設定為true,那麼所有執行緒走到這個timed都是true,那麼所有的執行緒,包括核心執行緒都可以做到超時退出。如果你的執行緒池需要將核心執行緒超時退出,那麼可以通過allowCoreThreadTimeOut方法將allowCoreThreadTimeOut變數設定為true。

整個getTask方法以及執行緒超時退出的機制如圖所示

7000字+24張圖帶你徹底弄懂執行緒池

六、執行緒池的5種狀態

執行緒池內部有5個常量來代表執行緒池的五種狀態

7000字+24張圖帶你徹底弄懂執行緒池
  • RUNNING:執行緒池建立時就是這個狀態,能夠接收新任務,以及對已新增的任務進行處理。
  • SHUTDOWN:呼叫shutdown方法執行緒池就會轉換成SHUTDOWN狀態,此時執行緒池不再接收新任務,但能繼續處理已新增的任務到佇列中任務。
  • STOP:呼叫shutdownNow方法執行緒池就會轉換成STOP狀態,不接收新任務,也不能繼續處理已新增的任務到佇列中任務,並且會嘗試中斷正在處理的任務的執行緒。
  • TIDYING:
    SHUTDOWN 狀態下,任務數為 0, 其他所有任務已終止,執行緒池會變為 TIDYING 狀態。
    執行緒池在 SHUTDOWN 狀態,任務佇列為空且執行中任務為空,執行緒池會變為 TIDYING 狀態。
    執行緒池在 STOP 狀態,執行緒池中執行中任務為空時,執行緒池會變為 TIDYING 狀態。
  • TERMINATED:執行緒池徹底終止。執行緒池在 TIDYING 狀態執行完 terminated() 方法就會轉變為 TERMINATED 狀態。

執行緒池狀態具體是存在ctl成員變數中,ctl中不僅儲存了執行緒池的狀態還儲存了當前執行緒池中執行緒數的大小

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

最後畫個圖來總結一下這5種狀態的流轉

7000字+24張圖帶你徹底弄懂執行緒池

其實,線上程池執行過程中,絕大多數操作執行前都得判斷當前執行緒池處於哪種狀態,再來決定是否繼續執行該操作。

七、執行緒池的關閉

執行緒池提供了shutdown和shutdownNow兩個方法來關閉執行緒池。

shutdown方法

7000字+24張圖帶你徹底弄懂執行緒池

就是將執行緒池的狀態修改為SHUTDOWN,然後嘗試打斷空閒的執行緒(如何判斷空閒,上面在說Worker繼承AQS的時候說過),也就是在阻塞等待任務的執行緒。

shutdownNow方法

7000字+24張圖帶你徹底弄懂執行緒池

就是將執行緒池的狀態修改為STOP,然後嘗試打斷所有的執行緒,從阻塞佇列中移除剩餘的任務,這也是為什麼shutdownNow不能執行剩餘任務的原因。

所以也可以看出shutdown方法和shutdownNow方法的主要區別就是,shutdown之後還能處理在佇列中的任務,shutdownNow直接就將任務從佇列中移除,執行緒池裡的執行緒就不再處理了。

八、執行緒池的監控

在專案中使用執行緒池的時候,一般需要對執行緒池進行監控,方便出問題的時候進行檢視。執行緒池本身提供了一些方法來獲取執行緒池的執行狀態。

  • getCompletedTaskCount:已經執行完成的任務數量
  • getLargestPoolSize:執行緒池裡曾經建立過的最大的執行緒數量。這個主要是用來判斷執行緒是否滿過。
  • getActiveCount:獲取正在執行任務的執行緒資料
  • getPoolSize:獲取當前執行緒池中執行緒數量的大小

除了執行緒池提供的上述已經實現的方法,同時執行緒池也預留了很對擴充套件方法。比如在runWorker方法裡面,在執行任務之前會回撥beforeExecute方法,執行任務之後會回撥afterExecute方法,而這些方法預設都是空實現,你可以自己繼承ThreadPoolExecutor來擴充套件重寫這些方法,來實現自己想要的功能。

九、Executors構建執行緒池以及問題分析

JDK內部提供了Executors這個工具類,來快速的建立執行緒池。

1)固定執行緒數量的執行緒池:核心執行緒數與最大執行緒數相等

7000字+24張圖帶你徹底弄懂執行緒池


2)單個執行緒數量的執行緒池

7000字+24張圖帶你徹底弄懂執行緒池

3)接近無限大執行緒數量的執行緒池

7000字+24張圖帶你徹底弄懂執行緒池

4)帶定時排程功能的執行緒池

7000字+24張圖帶你徹底弄懂執行緒池

雖然JDK提供了快速建立執行緒池的方法,但是其實不推薦使用Executors來建立執行緒池,因為從上面構造執行緒池可以看出,newFixedThreadPool執行緒池,由於使用了LinkedBlockingQueue,佇列的容量預設是無限大,實際使用中出現任務過多時會導致記憶體溢位;newCachedThreadPool執行緒池由於核心執行緒數無限大,當任務過多的時候,會導致建立大量的執行緒,可能機器負載過高,可能會導致服務當機。

十、執行緒池的使用場景

在java程式中,其實經常需要用到多執行緒來處理一些業務,但是不建議單純使用繼承Thread或者實現Runnable介面的方式來建立執行緒,那樣就會導致頻繁建立及銷燬執行緒,同時建立過多的執行緒也可能引發資源耗盡的風險。所以在這種情況下,使用執行緒池是一種更合理的選擇,方便管理任務,實現了執行緒的重複利用。所以執行緒池一般適合那種需要非同步或者多執行緒處理任務的場景。

十一、實際專案中如何合理的自定義執行緒池

通過上面分析提到,通過Executors這個工具類來建立的執行緒池其實都無法滿足實際的使用場景,那麼在實際的專案中,到底該如何構造執行緒池呢,該如何合理的設定引數?

1)執行緒數

執行緒數的設定主要取決於業務是IO密集型還是CPU密集型。

CPU密集型指的是任務主要使用來進行大量的計算,沒有什麼導致執行緒阻塞。一般這種場景的執行緒數設定為CPU核心數+1。

IO密集型:當執行任務需要大量的io,比如磁碟io,網路io,可能會存在大量的阻塞,所以在IO密集型任務中使用多執行緒可以大大地加速任務的處理。一般執行緒數設定為 2*CPU核心數

java中用來獲取CPU核心數的方法是:Runtime.getRuntime().availableProcessors();


2)執行緒工廠

一般建議自定義執行緒工廠,構建執行緒的時候設定執行緒的名稱,這樣就在查日誌的時候就方便知道是哪個執行緒執行的程式碼。

3)有界佇列

一般需要設定有界佇列的大小,比如LinkedBlockingQueue在構造的時候就可以傳入引數,來限制佇列中任務資料的大小,這樣就不會因為無限往佇列中扔任務導致系統的oom。

以上就是本篇文章的全部內容,如果你有什麼不懂或者想要交流的地方,可以關注我的個人的微信公眾號 三友的java日記 聯絡我,我們下篇文章再見。

如果覺得這篇文章對你有所幫助,還請幫忙點贊、在看、轉發一下,碼字不易,非常感謝!

相關文章