深入原始碼,深度解析Java 執行緒池的實現原理

HollisChuang發表於2021-05-26

java 系統的執行歸根到底是程式的執行,程式的執行歸根到底是程式碼的執行,程式碼的執行歸根到底是虛擬機器的執行,虛擬機器的執行其實就是作業系統的執行緒在執行,並且會佔用一定的系統資源,如CPU、記憶體、磁碟、網路等等。所以,如何高效的使用這些資源就是程式設計師在平時寫程式碼時候的一個努力的方向。本文要說的執行緒池就是一種對 CPU 利用的優化手段。

執行緒池,百度百科是這麼解釋的:

執行緒池是一種多執行緒處理形式,處理過程中將任務新增到佇列,然後在建立執行緒後自動啟動這些任務。執行緒池執行緒都是後臺執行緒。每個執行緒都使用預設的堆疊大小,以預設的優先順序執行,並處於多執行緒單元中。如果某個執行緒在託管程式碼中空閒(如正在等待某個事件),則執行緒池將插入另一個輔助執行緒來使所有處理器保持繁忙。如果所有執行緒池執行緒都始終保持繁忙,但佇列中包含掛起的工作,則執行緒池將在一段時間後建立另一個輔助執行緒但執行緒的數目永遠不會超過最大值。超過最大值的執行緒可以排隊,但他們要等到其他執行緒完成後才啟動。

執行緒池,其實就是維護了很多執行緒的池子,類似這樣的技術還有很多的,例如:HttpClient 連線池、資料庫連線池、記憶體池等等。

執行緒池的優點

在 Java 併發程式設計框架中的執行緒池是運用場景最多的技術,幾乎所有需要非同步或併發執行任務的程式都可以使用執行緒池。在開發過程中,合理地使用執行緒池能夠帶來至少以下4個好處。

第一:降低資源消耗。通過重複利用已建立的執行緒降低執行緒建立和銷燬造成的消耗;

第二:提高響應速度。當任務到達時,任務可以不需要等到執行緒建立就能立即執行;

第三:提高執行緒的可管理性。執行緒是稀缺資源,如果無限制地建立,不僅會消耗系統資源,還會降低系統的穩定性,使用執行緒池可以進行統一分配、調優和監控。

第四:提供更強大的功能,比如延時定時執行緒池;

執行緒池的實現原理

當向執行緒池提交一個任務之後,執行緒池是如何處理這個任務的呢?下面就先來看一下它的主要處理流程。先來看下面的這張圖,然後我們一步一步的來解釋。

image-20210322132334799

當使用者將一個任務提交到執行緒池以後,執行緒池是這麼執行的:

①首先判斷核心的執行緒數是否已滿,如果沒有滿,那麼就去建立一個執行緒去執行該任務;否則請看下一步

②如果執行緒池的核心執行緒數已滿,那麼就繼續判斷任務佇列是否已滿,如果沒滿,那麼就將任務放到任務佇列中;否則請看下一步

③如果任務佇列已滿,那麼就判斷執行緒池是否已滿,如果沒滿,那麼就建立執行緒去執行該任務;否則請看下一步;

④如果執行緒池已滿,那麼就根據拒絕策略來做出相應的處理;

上面的四步其實就已經將執行緒池的執行原理描述結束了。如果不明白沒有關係,先一步一步往下看,上面涉及到的執行緒池的專有名詞都會詳細的介紹到。

我們在平時的開發中,執行緒池的使用基本都是基於ThreadPoolExexutor類,他的繼承體系是這樣子的:

image-20210322133058425

那既然說在使用中都是基於ThreadPoolExecutor的那麼我們就重點分析這個類。

至於他構造體系中的其他的類或者是介面中的屬性,這裡就不去截圖了,完全沒有必要。小夥伴如果實在想看就自己去開啟程式碼看一下就行了。

ThreadPoolExecutor

在《阿里巴巴 java 開發手冊》中指出了執行緒資源必須通過執行緒池提供,不允許在應用中自行顯示的建立執行緒,這樣一方面是執行緒的建立更加規範,可以合理控制開闢執行緒的數量;另一方面執行緒的細節管理交給執行緒池處理,優化了資源的開銷。

其原文描述如下:

ThreadPoolExecutor類中提供了四個構造方法,但是他的四個構造器中,實際上最終都會呼叫同一個構造器,只不過是在另外三個構造器中,如果有些引數不傳ThreadPoolExecutor會幫你使用預設的引數。所以,我們直接來看這個完整引數的構造器,來徹底剖析裡面的引數。

public  class  ThreadPoolExecutor  extends  AbstractExecutorService {
    ......

    public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize,long keepAliveTime,TimeUnit unit,
                                  BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,
                                  RejectedExecutionHandler handler) {
            if (corePoolSize < 0 || maximumPoolSize <= 0 || maximumPoolSize < corePoolSize || keepAliveTime < 0){
                throw new IllegalArgumentException();
            }
            if (workQueue == null || threadFactory == null || handler == null){
                throw new NullPointerException();
            }
            this.acc = System.getSecurityManager() == null ? null : AccessController.getContext();
            this.corePoolSize = corePoolSize;
            this.maximumPoolSize = maximumPoolSize;
            this.workQueue = workQueue;
            this.keepAliveTime = unit.toNanos(keepAliveTime);
            this.threadFactory = threadFactory;
            this.handler = handler;
        }
}

主要引數就是下面這幾個:

  • corePoolSize:執行緒池中的核心執行緒數,包括空閒執行緒,也就是核心執行緒數的大小;
  • maximumPoolSize:執行緒池中允許的最多的執行緒數,也就是說執行緒池中的執行緒數是不可能超過該值的;
  • keepAliveTime:當執行緒池中的執行緒數大於 corePoolSize 的時候,在超過指定的時間之後就會將多出 corePoolSize 的的空閒的執行緒從執行緒池中刪除;
  • unit:keepAliveTime 引數的單位(常用的秒為單位);
  • workQueue:用於儲存任務的佇列,此佇列僅保持由 executor 方法提交的任務 Runnable 任務;
  • threadFactory:執行緒池工廠,他主要是為了給執行緒起一個標識。也就是為執行緒起一個具有意義的名稱;
  • handler:拒絕策略

阻塞佇列

workQueue 有多種選擇,在 JDK 中一共提供了 7 中阻塞對列,分別為:

  1. ArrayBlockingQueue : 一個由陣列結構組成的有界阻塞佇列。 此佇列按照先進先出(FIFO)的原則對元素進行排序。預設情況下不保證訪問者公平地訪問佇列 ,所謂公平訪問佇列是指阻塞的執行緒,可按照阻塞的先後順序訪問佇列。非公平性是對先等待的執行緒是不公平的,當佇列可用時,阻塞的執行緒都可以競爭訪問佇列的資格。

  2. LinkedBlockingQueue : 一個由連結串列結構組成的有界阻塞佇列。 此佇列的預設和最大長度為Integer.MAX_VALUE。 此佇列按照先進先出的原則對元素進行排序。

  3. PriorityBlockingQueue : 一個支援優先順序排序的無界阻塞佇列。 (雖然此佇列邏輯上是無界的,但是資源被耗盡時試圖執行 add 操作也將失敗,導致 OutOfMemoryError)

  4. DelayQueue: 一個使用優先順序佇列實現的無界阻塞佇列。 元素的一個無界阻塞佇列,只有在延遲期滿時才能從中提取元素

  5. SynchronousQueue: 一個不儲存元素的阻塞佇列。 一種阻塞佇列,其中每個插入操作必須等待另一個執行緒的對應移除操作 ,反之亦然。(SynchronousQueue 該佇列不儲存元素)

  6. LinkedTransferQueue: 一個由連結串列結構組成的無界阻塞佇列。 相對於其他阻塞佇列LinkedTransferQueue多了tryTransfer和transfer方法。

  7. LinkedBlockingDeque: 一個由連結串列結構組成的雙向阻塞佇列。 是一個由連結串列結構組成的雙向阻塞佇列

在以上的7個佇列中,執行緒池中常用的是ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue

佇列中的常用的方法如下:

型別 方法 含義 特點
拋異常 add 新增一個元素 如果佇列滿,丟擲異常 IllegalStateException
拋異常 remove 返回並刪除佇列的頭節點 如果佇列空,丟擲異常 NoSuchElementException
拋異常 element 返回佇列頭節點 如果佇列空,丟擲異常 NoSuchElementException
不拋異常,但是不阻塞 offer 新增一個元素 新增成功,返回 true,新增失敗,返回 false
不拋異常,但是不阻塞 poll 返回並刪除佇列的頭節點 如果佇列空,返回 null
不拋異常,但是不阻塞 peek 返回佇列頭節點 如果佇列空,返回 null
阻塞 put 新增一個元素 如果佇列滿,阻塞
阻塞 take 返回並刪除佇列的頭節點 如果佇列空,阻塞

關於阻塞佇列,介紹到這裡也就基本差不多了。

執行緒池工廠

執行緒池工廠,就像上面已經介紹的,目的是為了給執行緒起一個有意義的名字。用起來也非常的簡單,只需要實現ThreadFactory介面即可

public class CustomThreadFactory implements ThreadFactory {
    @Override
    public Thread newThread(Runnable r) {
        Thread thread = new Thread(r);
        thread.setName("我是你們自己定義的執行緒名稱");
        return thread;
    }
}

具體的使用就不去廢話了。

拒絕策略

執行緒池有四種預設的拒絕策略,分別為:

  1. AbortPolicy:這是執行緒池預設的拒絕策略,在任務不能再提交的時候,丟擲異常,及時反饋程式執行狀態。如果是比較關鍵的業務,推薦使用此拒絕策略,這樣子在系統不能承載更大的併發量的時候,能夠及時的通過異常發現;

  2. DiscardPolicy:丟棄任務,但是不丟擲異常。如果執行緒佇列已滿,則後續提交的任務都會被丟棄,且是靜默丟棄。這玩意不建議使用;

  3. DiscardOldestPolicy:丟棄佇列最前面的任務,然後重新提交被拒絕的任務。這玩意不建議使用;

  4. CallerRunsPolicy:如果任務新增失敗,那麼主執行緒就會自己呼叫執行器中的 executor 方法來執行該任務。這玩意不建議使用;

也就是說關於執行緒池的拒絕策略,最好使用預設的。這樣能夠及時發現異常。如果上面的都不能滿足你的需求,你也可以自定義拒絕策略,只需要實現 RejectedExecutionHandler 介面即可

public class CustomRejection implements RejectedExecutionHandler {
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        System.out.println("你自己想怎麼處理就怎麼處理");
    }
}

看到這裡,我們再來畫一張圖來總結和概括下執行緒池的執行示意圖:

image-20210322145600064

詳細的執行過程全部在圖中說明了。

提交任務到執行緒池

在 java 中,有兩個方法可以將任務提交到執行緒池,分別是submitexecute

execute 方法

execute()方法用於提交不需要返回值的任務,所以無法判斷任務是否被執行緒池執行成功。

void execute(Runnable command);

通過以下程式碼可知 execute() 方法輸入的任務是一個Runnable類的例項。

executorService.execute(()->{
            System.out.println("ThreadPoolDemo.execute");
        });

submit 方法

submit()方法用於提交需要返回值的任務。

Future<?> submit(Runnable task);

執行緒池會返回一個future型別的物件,通過這個 future 物件可以判斷任務是否執行成功,並且可以通過future的get()方法來獲取返回值,get() 方法會阻塞當前執行緒直到任務完成,而使用get(long timeout,TimeUnit unit)方法則會阻塞當前執行緒一段時間後立即返回,這時候有可能任務沒有執行完。

Future<?> submit = executorService.submit(() -> {
            System.out.println("ThreadPoolDemo.submit");
        });

關閉執行緒池

其實,如果優雅的關閉執行緒池是一個令人頭疼的問題,執行緒開啟是簡單的,但是想要停止卻不是那麼容易的。通常而言, 大部分程式設計師都是使用 jdk 提供的兩個方法來關閉執行緒池,他們分別是:shutdownshutdownNow

通過呼叫執行緒池的 shutdownshutdownNow 方法來關閉執行緒池。它們的原理是遍歷執行緒池中的工作執行緒,然後逐個呼叫執行緒的 interrupt 方法來中斷執行緒(PS:中斷,僅僅是給執行緒打上一個標記,並不是代表這個執行緒停止了,如果執行緒不響應中斷,那麼這個標記將毫無作用),所以無法響應中斷的任務可能永遠無法終止。

但是它們存在一定的區別,shutdownNow首先將執行緒池的狀態設定成 STOP,然後嘗試停止所有的正在執行或暫停任務的執行緒,並返回等待執行任務的列表,而 shutdown 只是將執行緒池的狀態設定成SHUTDOWN狀態,然後中斷所有沒有正在執行任務的執行緒。

只要呼叫了這兩個關閉方法中的任意一個,isShutdown 方法就會返回 true。當所有的任務都已關閉後,才表示執行緒池關閉成功,這時呼叫isTerminaed方法會返回 true。至於應該呼叫哪一種方法來關閉執行緒池,應該由提交到執行緒池的任務特性決定,通常呼叫 shutdown方法來關閉執行緒池,如果任務不一定要執行完,則可以呼叫 shutdownNow 方法。

這裡推薦使用穩妥的 shutdownNow 來關閉執行緒池,至於更優雅的方式我會在以後的併發程式設計設計模式中的兩階段終止模式中會再次詳細介紹。

合理的引數

為什麼叫合理的引數,那不合理的引數是什麼樣子的?在我們建立執行緒池的時候,裡面的引數該如何設定才能稱之為合理呢?其實這是有一定的依據的,我們先來看一下以下的建立的方式:

ExecutorService executorService = new ThreadPoolExecutor(5,
                5,
                5,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(5),
                r -> {
                    Thread thread = new Thread(r);
                    thread.setName("執行緒池原理講解");
                    return thread;
                });

你說他合理不合理?我也不知道,因為我們沒有參考的依據,在實際的開發中,我們需要根據任務的性質(IO是否頻繁?)來決定我們建立的核心的執行緒數的大小,實際上可以從以下的一個角度來分析:

  • 任務的性質:CPU密集型任務、IO密集型任務和混合型任務;
  • 任務的優先順序:高、中和低;
  • 任務的執行時間:長、中和短;
  • 任務的依賴性:是否依賴其他系統資源,如資料庫連線;

性質不同的任務可以用不同規模的執行緒池分開處理。分為CPU密集型和IO密集型

CPU密集型任務應配置儘可能小的執行緒,如配置 Ncpu+1個執行緒的執行緒池。(可以通過Runtime.getRuntime().availableProcessors()來獲取CPU物理核數)

IO密集型任務執行緒並不是一直在執行任務,則應配置儘可能多的執行緒,如 2*Ncpu

混合型的任務,如果可以拆分,將其拆分成一個CPU密集型任務一個IO密集型任務,只要這兩個任務執行的時間相差不是太大,那麼分解後執行的吞吐量將高於序列執行的吞吐量。

如果這兩個任務執行時間相差太大,則沒必要進行分解。可以通過 Runtime.getRuntime().availableProcessors() 方法獲得當前裝置的CPU個數。

優先順序不同的任務可以使用優先順序佇列 PriorityBlockingQueue來處理。它可以讓優先順序高的任務先執行(注意:如果一直有優先順序高的任務提交到佇列裡,那麼優先順序低的任務可能永遠不能執行

執行時間不同的任務可以交給不同規模的執行緒池來處理,或者可以使用優先順序佇列,讓執行時間短的任務先執行。依賴資料庫連線池的任務,因為執行緒提交SQL後需要等待資料庫返回結果,等待的時間越長,則 CPU 空閒時間就越長,那麼執行緒數應該設定得越大,這樣才能更好地利用CPU。

建議使用有界佇列。有界佇列能增加系統的穩定性和預警能力,可以根據需要設大一點。方式因為提交的任務過多而導致 OOM;

7、本文小結

本文主要介紹的是執行緒池的實現原理以及一些使用技巧,在實際開發中,執行緒池可以說是稍微高階一點的程式設計師的必備技能。所以掌握好執行緒池這門技術也是重中之重!

相關文章