執行緒池是怎樣工作的?

劉成LC發表於2019-03-30

我們在工作中或多或少都使用過執行緒池,但是為什麼要使用執行緒池呢?從他的名字中我們就應該知道,執行緒池使用了一種池化技術,和很多其他池化技術一樣,都是為了更高效的利用資源,例如連結池,記憶體池等等。

資料庫連結是一種很昂貴的資源,建立和銷燬都需要付出高昂的代價,為了避免頻繁的建立資料庫連結,所以產生了連結池技術。優先在池子中建立一批資料庫連結,有需要訪問資料庫時,直接到池子中去獲取一個可用的連結,使用完了之後再歸還到連結池中去。

同樣的,執行緒也是一種寶貴的資源,並且也是一種有限的資源,建立和銷燬執行緒也同樣需要付出不菲的代價。我們所有的程式碼都是由一個一個的執行緒支撐起來的,如今的晶片架構也決定了我們必須編寫多執行緒執行的程式,以獲取最高的程式效能。

那麼怎樣高效的管理多執行緒之間的分工與協作就成了一個關鍵問題,Doug Lea 大神為我們設計並實現了一款執行緒池工具,通過該工具就可以實現多執行緒的能力,並實現任務的高效執行與排程。

為了正確合理的使用執行緒池工具,我們有必要對執行緒池的原理進行了解。

本篇文章主要從三個方面來對執行緒池進行分析:執行緒池狀態、重要屬性、工作流程。

執行緒池狀態

首先執行緒池是有狀態的,這些狀態標識這執行緒池內部的一些執行情況,執行緒池的開啟到關閉的過程就是執行緒池狀態的一個流轉的過程。

執行緒池共有五種狀態:

執行緒池是怎樣工作的?
狀態含義
RUNNING執行狀態,該狀態下執行緒池可以接受新的任務,也可以處理阻塞佇列中的任務
執行 shutdown 方法可進入 SHUTDOWN 狀態
執行 shutdownNow 方法可進入 STOP 狀態
SHUTDOWN待關閉狀態,不再接受新的任務,繼續處理阻塞佇列中的任務
當阻塞佇列中的任務為空,並且工作執行緒數為0時,進入 TIDYING 狀態
STOP停止狀態,不接收新任務,也不處理阻塞佇列中的任務,並且會嘗試結束執行中的任務
當工作執行緒數為0時,進入 TIDYING 狀態
TIDYING整理狀態,此時任務都已經執行完畢,並且也沒有工作執行緒
執行 terminated 方法後進入 TERMINATED 狀態
TERMINATED終止狀態,此時執行緒池完全終止了,並完成了所有資源的釋放

重要屬性

一個執行緒池的核心引數有很多,每個引數都有著特殊的作用,各個引數聚合在一起後將完成整個執行緒池的完整工作。

1、執行緒狀態和工作執行緒數量

首先執行緒池是有狀態的,不同狀態下執行緒池的行為是不一樣的,5種狀態已經在上面說過了。

另外執行緒池肯定是需要執行緒去執行具體的任務的,所以線上程池中就封裝了一個內部類 Worker 作為工作執行緒,每個 Worker 中都維持著一個 Thread。

執行緒池的重點之一就是控制執行緒資源合理高效的使用,所以必須控制工作執行緒的個數,所以需要儲存當前執行緒池中工作執行緒的個數。

看到這裡,你是否覺得需要用兩個變數來儲存執行緒池的狀態和執行緒池中工作執行緒的個數呢?但是在 ThreadPoolExecutor 中只用了一個 AtomicInteger 型的變數就儲存了這兩個屬性的值,那就是 ctl。

執行緒池是怎樣工作的?

ctl 的高3位用來表示執行緒池的狀態(runState),低29位用來表示工作執行緒的個數(workerCnt),為什麼要用3位來表示執行緒池的狀態呢,原因是執行緒池一共有5種狀態,而2位只能表示出4種情況,所以至少需要3位才能表示得了5種狀態。

2、核心執行緒數和最大執行緒數

現在有了標誌工作執行緒的個數的變數了,那到底該有多少個執行緒才合適呢?執行緒多了浪費執行緒資源,少了又不能發揮執行緒池的效能。

為了解決這個問題,執行緒池設計了兩個變數來協作,分別是:

  • 核心執行緒數:corePoolSize 用來表示執行緒池中的核心執行緒的數量,也可以稱為可閒置的執行緒數量

  • 最大執行緒數:maximumPoolSize 用來表示執行緒池中最多能夠建立的執行緒數量

現在我們有一個疑問,既然已經有了標識工作執行緒的個數的變數了,為什麼還要有核心執行緒數、最大執行緒數呢?

其實你這樣想就能夠理解了,建立執行緒是有代價的,不能每次要執行一個任務時就建立一個執行緒,但是也不能在任務非常多的時候,只有少量的執行緒在執行,這樣任務是來不及處理的,而是應該建立合適的足夠多的執行緒來及時的處理任務。隨著任務數量的變化,當任務數明顯很小時,原本建立的多餘的執行緒就沒有必要再存活著了,因為這時使用少量的執行緒就能夠處理的過來了,所以說真正工作的執行緒的數量,是隨著任務的變化而變化的。

那核心執行緒數和最大執行緒數與工作執行緒個數的關係是什麼呢?

執行緒池是怎樣工作的?

工作執行緒的個數可能從0到最大執行緒數之間變化,當執行一段時間之後可能維持在 corePoolSize,但也不是絕對的,取決於核心執行緒是否允許被超時回收。

3、建立執行緒的工廠

既然是執行緒池,那自然少不了執行緒,執行緒該如何來建立呢?這個任務就交給了執行緒工廠 ThreadFactory 來完成。

4、快取任務的阻塞佇列

上面我們說了核心執行緒數和最大執行緒數,並且也介紹了工作執行緒的個數是在0和最大執行緒數之間變化的。但是不可能一下子就建立了所有執行緒,把執行緒池裝滿,而是有一個過程,這個過程是這樣的:

當執行緒池接收到一個任務時,如果工作執行緒數沒有達到corePoolSize,那麼就會新建一個執行緒,並繫結該任務,直到工作執行緒的數量達到 corePoolSize 前都不會重用之前的執行緒。

當工作執行緒數達到 corePoolSize 了,這時又接收到新任務時,會將任務存放在一個阻塞佇列中等待核心執行緒去執行。為什麼不直接建立更多的執行緒來執行新任務呢,原因是核心執行緒中很可能已經有執行緒執行完自己的任務了,或者有其他執行緒馬上就能處理完當前的任務,並且接下來就能投入到新的任務中去,所以阻塞佇列是一種緩衝的機制,給核心執行緒一個機會讓他們充分發揮自己的能力。另外一個值得考慮的原因是,建立執行緒畢竟是比較昂貴的,不可能一有任務要執行就去建立一個新的執行緒。

所以我們需要為執行緒池配備一個阻塞佇列,用來臨時快取任務,這些任務將等待工作執行緒來執行。

執行緒池是怎樣工作的?

5、非核心執行緒存活時間

上面我們說了當工作執行緒數達到 corePoolSize 時,執行緒池會將新接收到的任務存放在阻塞佇列中,而阻塞佇列又兩種情況:一種是有界的佇列,一種是無界的佇列。

如果是無界佇列,那麼當核心執行緒都在忙的時候,所有新提交的任務都會被存放在該無界佇列中,這時最大執行緒數將變得沒有意義,因為阻塞佇列不會存在被裝滿的情況。

如果是有界佇列,那麼當阻塞佇列中裝滿了等待執行的任務,這時再有新任務提交時,執行緒池就需要建立新的“臨時”執行緒來處理,相當於增派人手來處理任務。

但是建立的“臨時”執行緒是有存活時間的,不可能讓他們一直都存活著,當阻塞佇列中的任務被執行完畢,並且又沒有那麼多新任務被提交時,“臨時”執行緒就需要被回收銷燬,在被回收銷燬之前等待的這段時間,就是非核心執行緒的存活時間,也就是 keepAliveTime 屬性。

那麼什麼是“非核心執行緒”呢?是不是先建立的執行緒就是核心執行緒,後建立的就是非核心執行緒呢?

其實核心執行緒跟建立的先後沒有關係,而是跟工作執行緒的個數有關,如果當前工作執行緒的個數大於核心執行緒數,那麼所有的執行緒都可能是“非核心執行緒”,都有被回收的可能。

一個執行緒執行完了一個任務後,會去阻塞佇列裡面取新的任務,在取到任務之前它就是一個閒置的執行緒。

取任務的方法有兩種,一種是通過 take() 方法一直阻塞直到取出任務,另一種是通過 poll(keepAliveTime,timeUnit) 方法在一定時間內取出任務或者超時,如果超時這個執行緒就會被回收,請注意核心執行緒一般不會被回收。

那麼怎麼保證核心執行緒不會被回收呢?還是跟工作執行緒的個數有關,每一個執行緒在取任務的時候,執行緒池會比較當前的工作執行緒個數與核心執行緒數:

  • 如果工作執行緒數小於當前的核心執行緒數,則使用第一種方法取任務,也就是沒有超時回收,這時所有的工作執行緒都是“核心執行緒”,他們不會被回收;

  • 如果大於核心執行緒數,則使用第二種方法取任務,一旦超時就回收,所以並沒有絕對的核心執行緒,只要這個執行緒沒有在存活時間內取到任務去執行就會被回收。

所以每個執行緒想要保住自己“核心執行緒”的身份,必須充分努力,儘可能快的獲取到任務去執行,這樣才能逃避被回收的命運。

核心執行緒一般不會被回收,但是也不是絕對的,如果我們設定了允許核心執行緒超時被回收的話,那麼就沒有核心執行緒這種說法了,所有的執行緒都會通過 poll(keepAliveTime, timeUnit) 來獲取任務,一旦超時獲取不到任務,就會被回收,一般很少會這樣來使用,除非該執行緒池需要處理的任務非常少,並且頻率也不高,不需要將核心執行緒一直維持著。

6、拒絕策略

雖然我們有了阻塞佇列來對任務進行快取,這從一定程度上為執行緒池的執行提供了緩衝期,但是如果是有界的阻塞佇列,那就存在佇列滿的情況,也存在工作執行緒的資料已經達到最大執行緒數的時候。如果這時候再有新的任務提交時,顯然執行緒池已經心有餘而力不足了,因為既沒有空餘的佇列空間來存放該任務,也無法建立新的執行緒來執行該任務了,所以這時我們就需要有一種拒絕策略,即 handler。

拒絕策略是一個 RejectedExecutionHandler 型別的變數,使用者可以自行指定拒絕的策略,如果不指定的話,執行緒池將使用預設的拒絕策略:丟擲異常。

線上程池中還為我們提供了很多其他可以選擇的拒絕策略:

  • 直接丟棄該任務

  • 使用呼叫者執行緒執行該任務

  • 丟棄任務佇列中的最老的一個任務,然後提交該任務

工作流程

瞭解了執行緒池中所有的重要屬性之後,現在我們需要來了解下執行緒池的工作流程了。

執行緒池是怎樣工作的?

上圖是一張執行緒池工作的精簡圖,實際的過程比這個要複雜的多,不過這些應該能夠完全覆蓋到執行緒池的整個工作流程了。

整個過程可以拆分成以下幾個部分:

1、提交任務

當向執行緒池提交一個新的任務時,執行緒池有三種處理情況,分別是:建立一個工作執行緒來執行該任務、將任務加入阻塞佇列、拒絕該任務。

提交任務的過程也可以拆分成以下幾個部分:

  • 當工作執行緒數小於核心執行緒數時,直接建立新的核心工作執行緒

  • 當工作執行緒數不小於核心執行緒數時,就需要嘗試將任務新增到阻塞佇列中去

  • 如果能夠加入成功,說明佇列還沒有滿,那麼需要做以下的二次驗證來保證新增進去的任務能夠成功被執行

  • 驗證當前執行緒池的執行狀態,如果是非RUNNING狀態,則需要將任務從阻塞佇列中移除,然後拒絕該任務

  • 驗證當前執行緒池中的工作執行緒的個數,如果為0,則需要主動新增一個空工作執行緒來執行剛剛新增到阻塞佇列中的任務

  • 如果加入失敗,則說明佇列已經滿了,那麼這時就需要建立新的“臨時”工作執行緒來執行任務

  • 如果建立成功,則直接執行該任務

  • 如果建立失敗,則說明工作執行緒數已經等於最大執行緒數了,則只能拒絕該任務了

整個過程可以用下面這張圖來表示:

執行緒池是怎樣工作的?

2、建立工作執行緒

建立工作執行緒需要做一系列的判斷,需要確保當前執行緒池可以建立新的執行緒之後,才能建立。

首先,當執行緒池的狀態是 SHUTDOWN 或者 STOP 時,則不能建立新的執行緒。

另外,當執行緒工廠建立執行緒失敗時,也不能建立新的執行緒。

還有就是當前工作執行緒的數量與核心執行緒數、最大執行緒數進行比較,如果前者大於後者的話,也不允許建立。

除此之外,會嘗試通過 CAS 來自增工作執行緒的個數,如果自增成功了,則會建立新的工作執行緒,即 Worker 物件。

然後加鎖進行二次驗證是否能夠建立工作執行緒,最後如果建立成功,則會啟動該工作執行緒。

3、啟動工作執行緒

當工作執行緒建立成功後,也就是 Worker 物件已經建立好了,這時就需要啟動該工作執行緒,讓執行緒開始幹活了,Worker 物件中關聯著一個 Thread,所以要啟動工作執行緒的話,只要通過 worker.thread.start() 來啟動該執行緒即可。

啟動完了之後,就會執行 Worker 物件的 run 方法,因為 Worker 實現了 Runnable 介面,所以本質上 Worker 也是一個執行緒。

通過執行緒 start 開啟之後就會呼叫到 Runnable 的 run 方法,在 worker 物件的 run 方法中,呼叫了 runWorker(this) 方法,也就是把當前物件傳遞給了 runWorker 方法,讓他來執行。

4、獲取任務並執行

在 runWorker 方法被呼叫之後,就是執行具體的任務了,首先需要拿到一個可以執行的任務,而 Worker 物件中預設繫結了一個任務,如果該任務不為空的話,那麼就是直接執行。

執行完了之後,就會去阻塞佇列中獲取任務來執行,而獲取任務的過程,需要考慮當前工作執行緒的個數。

  • 如果工作執行緒數大於核心執行緒數,那麼就需要通過 poll 來獲取,因為這時需要對閒置的執行緒進行回收;

  • 如果工作執行緒數小於等於核心執行緒數,那麼就可以通過 take 來獲取了,因此這時所有的執行緒都是核心執行緒,不需要進行回收,前提是沒有設定 allowCoreThreadTimeOut


相關文章