天天看點

線程池ThreadPoolExecutor底層原理源碼分析

作者:悠閑觀自在

線程池執行任務的具體流程是怎樣的?

ThreadPoolExecutor中提供了兩種執行任務的方法:

  1. void execute(Runnable command)
  2. Future submit(Runnable task)

實際上submit中最終還是調用的execute()方法,隻不過會傳回一個Future對象,用來擷取任務執行結果:

線程池ThreadPoolExecutor底層原理源碼分析

execute(Runnable command)方法執行時會分為三步:

線程池ThreadPoolExecutor底層原理源碼分析

注意:送出一個Runnable時,不管目前線程池中的線程是否空閑,隻要數量小于核心線程數就會建立新線程。

注意:ThreadPoolExecutor相當于是非公平的,比如隊列滿了之後送出的Runnable可能會比正在排隊的Runnable先執行。

線程池的五種狀态是如何流轉的?

線程池有五種狀态:

  • RUNNING:會接收新任務并且會處理隊列中的任務
  • SHUTDOWN:不會接收新任務并且會處理隊列中的任務
  • STOP:不會接收新任務并且不會處理隊列中的任務,并且會中斷在處理的任務(注意:一個任務能不能被中斷得看任務本身)
  • TIDYING:所有任務都終止了,線程池中也沒有線程了,這樣線程池的狀态就會轉為TIDYING,一旦達到此狀态,就會調用線程池的terminated()
  • TERMINATED:terminated()執行完之後就會轉變為TERMINATED

這五種狀态并不能任意轉換,隻會有以下幾種轉換情況:

  1. RUNNING -> SHUTDOWN:手動調用shutdown()觸發,或者線程池對象GC時會調用finalize()進而調用shutdown()
  2. (RUNNING or SHUTDOWN) -> STOP:調用shutdownNow()觸發,如果先調shutdown()緊着調shutdownNow(),就會發生SHUTDOWN -> STOP
  3. SHUTDOWN -> TIDYING:隊列為空并且線程池中沒有線程時自動轉換
  4. STOP -> TIDYING:線程池中沒有線程時自動轉換(隊列中可能還有任務)
  5. TIDYING -> TERMINATED:terminated()執行完後就會自動轉換

線程池中的線程是如何關閉的?

我們一般會使用thread.start()方法來開啟一個線程,那如何停掉一個線程呢?

Thread類提供了一個stop(),但是标記了@Deprecated,為什麼不推薦用stop()方法來停掉線程呢?

因為stop()方法太粗暴了,一旦調用了stop(),就會直接停掉線程,但是調用的時候根本不知道線程剛剛在做什麼,任務做到哪一步了,這是很危險的。

這裡強調一點,stop()會釋放線程占用的synchronized鎖(不會自動釋放ReentrantLock鎖,這也是不建議用stop()的一個因素)。

線程池ThreadPoolExecutor底層原理源碼分析

是以,我們建議通過自定義一個變量,或者通過中斷來停掉一個線程,比如:

線程池ThreadPoolExecutor底層原理源碼分析

不同點在于,當我們把stop設定為true時,線程自身可以控制到底要不要停止,何時停止,同樣,我們可以調用thread的interrupt()來中斷線程:

線程池ThreadPoolExecutor底層原理源碼分析

不同的地方在于,線程sleep過程中如果被中斷了會接收到異常。

講了這麼多,其實線程池中就是通過interrupt()來停止線程的,比如shutdownNow()方法中會調用:

線程池ThreadPoolExecutor底層原理源碼分析

線程池為什麼一定得是阻塞隊列?

線程池中的線程在運作過程中,執行完建立線程時綁定的第一個任務後,就會不斷的從隊列中擷取任務并執行,那麼如果隊列中沒有任務了,線程為了不自然消亡,就會阻塞在擷取隊列任務時,等着隊列中有任務過來就會拿到任務進而去執行任務。

通過這種方法能最終確定,線程池中能保留指定個數的核心線程數,關鍵代碼為:

線程池ThreadPoolExecutor底層原理源碼分析

某個線程在從隊列擷取任務時,會判斷是否使用逾時阻塞擷取,我們可以認為非核心線程會poll(),核心線程會take(),非核心線程超過時間還沒擷取到任務後面就會自然消亡了。

線程發生異常,會被移出線程池嗎?

答案是會的,那有沒有可能核心線程數在執行任務時都出錯了,導緻所有核心線程都被移出了線程池?

線程池ThreadPoolExecutor底層原理源碼分析

在源碼中,當執行任務時出現異常時,最終會執行processWorkerExit(),執行完這個方法後,目前線程也就自然消亡了,但是!processWorkerExit()方法中會額外再新增一個線程,這樣就能維持住固定的核心線程數。

Tomcat是如何自定義線程池的?

Tomcat中用的線程池為org.apache.tomcat.util.threads.ThreadPoolExecutor,注意類名和JUC下的一樣,但是包名不一樣。

Tomcat會建立這個線程池:

線程池ThreadPoolExecutor底層原理源碼分析

注入傳入的隊列為TaskQueue,它的入隊邏輯為:

線程池ThreadPoolExecutor底層原理源碼分析

特殊在:

  • 入隊時,如果線程池的線程個數等于最大線程池數才入隊
  • 入隊時,如果線程池的線程個數小于最大線程池數,會傳回false,表示入隊失敗

這樣就控制了,Tomcat的這個線程池,在送出任務時:

  1. 仍然會先判斷線程個數是否小于核心線程數,如果小于則建立線程
  2. 如果等于核心線程數,會入隊,但是線程個數小于最大線程數會入隊失敗,進而會去建立線程

是以随着任務的送出,會優先建立線程,直到線程個數等于最大線程數才會入隊。

當然其中有一個比較細的邏輯是:在送出任務時,如果正在處理的任務數小于線程池中的線程個數,那麼也會直接入隊,而不會去建立線程,也就是上面源碼中getSubmittedCount的作用。

線程池的核心線程數、最大線程數該如何設定?

我們都知道,線程池中有兩個非常重要的參數:

  1. corePoolSize:核心線程數,表示線程池中的常駐線程的個數
  2. maximumPoolSize:最大線程數,表示線程池中能開辟的最大線程個數

那這兩個參數該如何設定呢?

我們對線程池負責執行的任務分為三種情況:

  1. CPU密集型任務,比如找出1-1000000中的素數
  2. IO密集型任務,比如檔案IO、網絡IO
  3. 混合型任務

CPU密集型任務的特點時,線程在執行任務時會一直利用CPU,是以對于這種情況,就盡可能避免發生線程上下文切換。

比如,現在我的電腦隻有一個CPU,如果有兩個線程在同時執行找素數的任務,那麼這個CPU就需要額外的進行線程上下文切換,進而達到線程并行的效果,此時執行這兩個任務的總時間為:

任務執行時間*2+線程上下文切換的時間

而如果隻有一個線程,這個線程來執行兩個任務,那麼時間為:

任務執行時間*2

是以對于CPU密集型任務,線程數最好就等于CPU核心數,可以通過以下API拿到你電腦的核心數:

Runtime.getRuntime().availableProcessors()

隻不過,為了應對線程執行過程發生缺頁中斷或其他異常導緻線程阻塞的請求,我們可以額外在多設定一個線程,這樣當某個線程暫時不需要CPU時,可以有替補線程來繼續利用CPU。

是以,對于CPU密集型任務,我們可以設定線程數為:CPU核心數+1

我們在來看IO型任務,線程在執行IO型任務時,可能大部分時間都阻塞在IO上,假如現在有10個CPU,如果我們隻設定了10個線程來執行IO型任務,那麼很有可能這10個線程都阻塞在了IO上,這樣這10個CPU就都沒活幹了,是以,對于IO型任務,我們通常會設定線程數為:2*CPU核心數

不過,就算是設定為了2*CPU核心數,也不一定是最佳的,比如,有10個CPU,線程數為20,那麼也有可能這20個線程同時阻塞在了IO上,是以可以再增加線程,進而去壓榨CPU的使用率。

通常,如果IO型任務執行的時間越長,那麼同時阻塞在IO上的線程就可能越多,我們就可以設定更多的線程,但是,線程肯定不是越多越好,我們可以通過以下這個公式來進行計算:

線程數 = CPU核心數 *( 1 + 線程等待時間 / 線程運作總時間 )

  • 線程等待時間:指的就是線程沒有使用CPU的時間,比如阻塞在了IO
  • 線程運作總時間:指的是線程執行完某個任務的總時間

我們可以利用jvisualvm抽樣來估計這兩個時間:

線程池ThreadPoolExecutor底層原理源碼分析

圖中表示,在剛剛這次抽樣過程中,run()總共的執行時間為538948ms,利用了CPU的時間為86873ms,是以沒有利用CPU的時間為538948ms-86873ms。

是以我們可以計算出:

線程等待時間 = 538948ms-86873ms

線程運作總時間 = 538948ms

是以:線程數 = 8 *( 1 + (538948ms-86873ms) / 538948ms )= 14.xxx

是以根據公式算出來的線程為14、15個線程左右。

按上述公式,如果我們執行的任務IO密集型任務,那麼:線程等待時間 = 線程運作總時間,是以:

線程數 = CPU核心數 *( 1 + 線程等待時間 / 線程運作總時間 )

= CPU核心數 *( 1 + 1 )

= CPU核心數 * 2

以上隻是理論,實際工作中情況會更複雜,比如一個應用中,可能有多個線程池,除開線程池中的線程可能還有很多其他線程,或者除開這個應用還是一些其他應用也在運作,是以實際工作中如果要确定線程數,最好是壓測。

總結,我們再工作中,對于:

  1. CPU密集型任務:CPU核心數+1,這樣既能充分利用CPU,也不至于有太多的上下文切換成本
  2. IO型任務:建議壓測,或者先用公式計算出一個理論值(理論值通常都比較小)
  3. 對于核心業務(通路頻率高),可以把核心線程數設定為我們壓測出來的結果,最大線程數可以等于核心線程數,或者大一點點,比如我們壓測時可能會發現500個線程最佳,但是600個線程時也還行,此時600就可以為最大線程數
  4. 對于非核心業務(通路頻率不高),核心線程數可以比較小,避免作業系統去維護不必要的線程,最大線程數可以設定為我們計算或壓測出來的結果。

繼續閱讀