王者併發課-鑽石01:明心見性-如何由表及裡精通執行緒池設計與原理

秦二爺發表於2021-07-26

歡迎來到《王者併發課》,本文是該系列文章中的第24篇,磚石中的第1篇

在鑽石系列中,我們將學習執行緒池相關的框架和工具類。作為鉑金系列的第一篇,我們將在這篇文章中深入講解執行緒池的應用及原理。

關於執行緒池,無論是在實際的專案開發還是面試,它都是併發程式設計中當之無愧的重中之重。因此,掌握執行緒池是每個Java開發者的必備技能。

本文將從執行緒池的應用場景和設計原理出發,先帶大家手擼一個執行緒池,在理解執行緒池的內部構造後,再深入剖析Java中的執行緒池。全文大約2.5萬字,篇幅較長,在閱讀時建議先看目錄再看內容

一、為什麼要使用執行緒池

在前面系列文章的學習中,你已然知道多執行緒可以加速任務的處理、提高系統的吞吐量。那麼,是否我們因此就可以頻繁地建立新的執行緒呢?答案是否定的。頻繁地繁建立和啟用新的執行緒不僅代價昂貴,而且無限增加的執行緒勢必也會造成管理成本的急劇上升。因此,為了平衡多執行緒的收益和成本,執行緒池誕生了

1. 執行緒池的使用場景

生產者與消費者問題是執行緒池的典型應用場景。當你有源源不斷的任務需要處理時,為了提高任務的處理速度,你需要建立多個執行緒。那麼,問題來了,如何管理這些任務和多執行緒呢?答案是:執行緒池

執行緒池的池化(Pooling)原理的應用並不侷限於Java中,在MySQL和諸多的分散式中介軟體系統中都有著廣泛的應用。當我們連結資料庫的時候,對連結的管理用的是執行緒池;當我們使用Tomcat時,對請求連結的管理用的也是執行緒池。所以,當你有批量的任務需要多執行緒處理時,那麼基本上你就需要使用執行緒池

2. 執行緒池的使用好處

執行緒池的好處主要體現在三個方面:系統資源任務處理速度相關的複雜度管理,主要表現在:

  • 降低系統的資源開銷:通過複用執行緒池中的工作執行緒,避免頻繁建立新的執行緒,可以有效降低系統資源的開銷;
  • 提高任務的執行速度:新任務達到時,無需建立新的執行緒,直接將任務交由已經存在的執行緒進行處理,可以有效提高任務的執行速度;
  • 有效管理任務和工作執行緒:執行緒池內提供了任務管理和工作執行緒管理的機制。

為什麼說建立執行緒是昂貴的

現在你已經知道,頻繁地建立新執行緒需要付出額外的代價,所以我們使用了執行緒池。那麼,建立一個新的執行緒的代價究竟是怎樣的呢?可以參考以下幾點:

  • 建立執行緒時,JVM必須為執行緒堆疊分配和初始化一大塊記憶體。每個執行緒方法的呼叫棧幀都會儲存到這裡,包括區域性變數、返回值和常量池等;
  • 在建立和註冊本機執行緒時,需要和宿主機發生系統呼叫;
  • 需要建立、初始化描述符,並將其新增到 JVM 內部資料結構中。

另外,從某種意義上說,只要執行緒還活著,它就會佔用資源,這不僅昂貴,而且浪費。 例如 ,執行緒堆疊、訪問堆疊的可達物件、JVM 執行緒描述符、作業系統本機執行緒描述符等等,線上程活著的時候,這些資源都會持續佔據。

雖然不同的Java平臺在建立執行緒時的代價可能有所差異,但總體來說,都不便宜。

3. 執行緒池的核心組成

一個完整的執行緒池,應該包含以下幾個核心部分:

  • 任務提交:提供介面接收任務的提交;
  • 任務管理:選擇合適的佇列對提交的任務進行管理,包括對拒絕策略的設定;
  • 任務執行:由工作執行緒來執行提交的任務;
  • 執行緒池管理:包括基本引數設定、任務監控、工作執行緒管理等。

二、如何手工製作執行緒池

通過第一部分的閱讀,現在你已經瞭解了執行緒池的作用及它的核心組成。為了更深刻地理解執行緒池的組成,在這一部分我們通過簡單的四步來手工製作一個簡單的執行緒池。當然,麻雀雖小,五臟俱全。如果你能手工自制執行緒池之後,那麼在理解後續的Java中的執行緒池時,將會易如反掌。

1. 執行緒池設計和製作

第一步:定義一個王者執行緒池:TheKingThreadPool,它是這次手工製作中名副其實的主角兒。在這個執行緒池中,包含了任務佇列管理、工作執行緒管理,並提供了可以指定佇列型別的構造引數,以及任務提交入口和執行緒池關閉介面。你看,雖然它看起來似乎很迷你,但是執行緒池的核心元件都已經具備了,甚至在它的基礎上,你完全可以把它擴充套件成更為成熟的執行緒池。

/**
 * 王者執行緒池
 */
public class TheKingThreadPool {
    private final BlockingQueue<Task> taskQueue;
    private final List<Worker> workers = new ArrayList<>();
    private ThreadPoolStatus status;

    /**
     * 初始化構建執行緒池
     *
     * @param worksNumber 執行緒池中的工作執行緒數量
     * @param taskQueue   任務佇列
     */
    public TheKingThreadPool(int worksNumber, BlockingQueue<Task> taskQueue) {
        this.taskQueue = taskQueue;
        status = ThreadPoolStatus.RUNNING;
        for (int i = 0; i < worksNumber; i++) {
            workers.add(new Worker("Worker" + i, taskQueue));
        }
        for (Worker worker : workers) {
            Thread workThread = new Thread(worker);
            workThread.setName(worker.getName());
            workThread.start();
        }
    }

    /**
     * 提交任務
     *
     * @param task 待執行的任務
     */
    public synchronized void execute(Task task) {
        if (!this.status.isRunning()) {
            throw new IllegalStateException("執行緒池非執行狀態,停止接單啦~");
        }
        this.taskQueue.offer(task);
    }

    /**
     * 等待所有任務執行結束
     */
    public synchronized void waitUntilAllTasksFinished() {
        while (this.taskQueue.size() > 0) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 關閉執行緒池
     */
    public synchronized void shutdown() {
        this.status = ThreadPoolStatus.SHUTDOWN;
    }

    /**
     * 停止執行緒池
     */
    public synchronized void stop() {
        this.status = ThreadPoolStatus.SHUTDOWN;
        for (Worker worker : workers) {
            worker.doStop();
        }
    }
}

第二步:設計並製作工作執行緒。工作執行緒是幹活的執行緒,將負責處理提交到執行緒池中的任務,我們把它叫做Worker。其實,這裡的Worker的定義和Java執行緒池中的Worker已經很像了,它繼承了Runnable介面並封裝了Thread. 在構造Worker時,可以設定它的名字,並傳入任務佇列。當Worker啟動後,它將會從任務佇列中獲取任務並執行。此外,它還提供了Stop方法,用以響應執行緒池的狀態變化。


/**
 * 執行緒池中用於執行任務的執行緒
 */
public class Worker implements Runnable {
    private final String name;
    private Thread thread = null;
    private final BlockingQueue<Task> taskQueue;
    private boolean isStopped = false;
    private AtomicInteger counter = new AtomicInteger();

    public Worker(String name, BlockingQueue<Task> queue) {
        this.name = name;
        taskQueue = queue;
    }

    public void run() {
        this.thread = Thread.currentThread();
        while (!isStopped()) {
            try {
                Task task = taskQueue.poll(5L, TimeUnit.SECONDS);
                if (task != null) {
                    note(this.thread.getName(), ":獲取到新的任務->", task.getTaskDesc());
                    task.run();
                    counter.getAndIncrement();
                }
            } catch (Exception ignored) {
            }
        }
        note(this.thread.getName(), ":已結束工作,執行任務數量:" + counter.get());
    }

    public synchronized void doStop() {
        isStopped = true;
        if (thread != null) {
            this.thread.interrupt();
        }
    }

    public synchronized boolean isStopped() {
        return isStopped;
    }

    public String getName() {
        return name;
    }
}

第三步:設計並製作任務。任務是可以可執行的物件,因此我們直接繼承Runnable介面就行。其實,直接使用Runnable介面也是可以的,只不過為了讓示例更加清楚,我們給Task加了任務描述的方法。

/**
 * 任務
 */
public interface Task extends Runnable {
    String getTaskDesc();
}

第四步:設計執行緒池的狀態。執行緒池作為一個執行框架,它必然會有一系列的狀態,比如執行中、停止、關閉等。

public enum ThreadPoolStatus {
    RUNNING(),
    SHUTDOWN(),
    STOP(),
    TIDYING(),
    TERMINATED();

    ThreadPoolStatus() {
    }

    public boolean isRunning() {
        return ThreadPoolStatus.RUNNING.equals(this);
    }
}

以上四個步驟完成後,一個簡易的執行緒池就已經制作完畢。你看,如果你從以上幾點入手來理解執行緒池的原始碼的話,是不是要簡單多了?Java中的執行緒池的核心組成也是如此,只不過在細節處理等方面更多全面且豐富。

2. 執行執行緒池

現在,我們的王者執行緒池已經制作好。接下來,我們通過一個場景來執行它,看看它的效果如何。

試驗場景:峽谷森林中,鎧、蘭陵王和典韋等負責打野,而安其拉、貂蟬和大喬等美女負責對狩獵到的野怪進行燒烤,一場歡快的峽谷燒烤節正在進行中

在這個場景中,鎧和蘭陵王他們負責提交任務,而貂蟬和大喬她們則負責處理任務。

在下面的實現程式碼中,我們通過上述設計的TheKingThreadPool來定義個執行緒池,wildMonsters中的野怪表示待提交的任務,並安排3個工作執行緒來執行任務。在示例程式碼的末尾,當所有任務執行結束後,關閉執行緒池。

 public static void main(String[] args) {
        TheKingThreadPool theKingThreadPool = new TheKingThreadPool(3, new ArrayBlockingQueue<>(10));

        String[] wildMonsters = {"棕熊", "野雞", "灰狼", "野兔", "狐狸", "小鹿", "小花豹", "野豬"};
        for (String wildMonsterName : wildMonsters) {
            theKingThreadPool.execute(new Task() {
                public String getTaskDesc() {
                    return wildMonsterName;
                }

                public void run() {
                    System.out.println(Thread.currentThread().getName() + ":" + wildMonsterName + "已經烤好");
                }
            });
        }

        theKingThreadPool.waitUntilAllTasksFinished();
        theKingThreadPool.stop();
    }

王者執行緒池執行結果如下:

Worker0:獲取到新的任務->灰狼
Worker1:獲取到新的任務->野雞
Worker1:野雞已經烤好
Worker2:獲取到新的任務->棕熊
Worker2:棕熊已經烤好
Worker1:獲取到新的任務->野兔
Worker1:野兔已經烤好
Worker0:灰狼已經烤好
Worker1:獲取到新的任務->小鹿
Worker1:小鹿已經烤好
Worker2:獲取到新的任務->狐狸
Worker2:狐狸已經烤好
Worker1:獲取到新的任務->野豬
Worker1:野豬已經烤好
Worker0:獲取到新的任務->小花豹
Worker0:小花豹已經烤好
Worker0:已結束工作,執行任務數量:2
Worker2:已結束工作,執行任務數量:2
Worker1:已結束工作,執行任務數量:4

Process finished with exit code 0

從結果中可以看到,效果完全符合預期。所有的任務都已經提交完畢,並且都被正確執行。此外,通過執行緒池的任務統計,可以看到任務並不是均勻分配,Worker1執行了4個任務,而Worker0和Worker2均只執行了2個任務,這也是執行緒池中的正常現象。

三、透徹理解Java中的執行緒池

在手工製作執行緒執行緒池之後,再來理解Java中的執行緒池就相對要容易很多。當然,相比於王者執行緒池,Java中的執行緒池(ThreadPoolExecutor)的實現要複雜很多。所以,理解時應當遵循一定的結構和脈絡,把握住執行緒池的核心要點,眉毛鬍子一把抓、理不清層次會導致你無法有效理解它的設計內涵,進而導致你無法正確掌握它。

總體來說,Java中的執行緒池的設計核心都是圍繞“任務”進行,可以通過一個框架兩大核心三大過程概括。理解了這三個重要概念,基本上你已經能從相對抽象的層面理解了執行緒池。

  • 一個框架:即執行緒池的整體設計存在一個框架,而不是雜亂無章的組成。所以,在學習執行緒池時,首先要能從立體上感知到這個框架的存在,而不要陷於凌亂的細節中;
  • 兩大核心:線上程池的整個框架中,圍繞任務執行這件事,存在兩大核心:任務的管理任務的執行,對應的也就是任務佇列和用於執行任務的工作執行緒任務佇列工作執行緒是框架得以有效運轉的關鍵部件;
  • 三大過程:前面說過,執行緒池的整體設計都是圍繞任務展開,所以框架內可以分為任務提交任務管理任務執行三大過程。

從類比的角度講,你可以把框架看作是一個生產車間。在這個車間裡,有一條流水線,任務佇列工作執行緒是這條流水線的兩大關鍵組成。而在流水線運作的過程中,就會涉及任務提交任務管理任務執行等不同的過程。

下面這幅圖,將幫助你立體地感知執行緒池的整體設計,建議你收藏。在這幅圖中,清楚地展示了執行緒池整個框架的工作流程和核心部件,接下來的文章也將圍繞這幅圖展開。

1. 執行緒池框架設計概覽

從原始碼層面看,理解Java中的執行緒池,要從下面這四兄弟的概念和關係入手,這四個概念務必瞭然於心。

  • Executor:作為執行緒池的最頂層介面,Executor的介面在設計上,實現了任務提交任務執行之間的解耦,這是它存在的意義。在Executor中,只定義了一個方法void execute(Runnable command),用於執行提交的可執行的任務。注意,你看它這個方法的引數乾脆就叫command,也就是“命令”,意在表明所提交的不是一個靜止的物件,而是可執行的命令。並且,這個命令將在未來的某一時刻執行,具體由哪個執行緒來執行也是不確定的;
  • ExecutorService:繼承了Executor的介面,並在此基礎上提供可以管理服務執行結果(Futrue) 的能力。ExecutorService所提供的submit方法可以返回任務的執行結果,而shutdown方法則可以用於關閉服務。相比起來,Executor只具備單一的執行能力,而ExecutorService則不僅具有執行能力,還提供了簡單的服務管理能力
  • AbstractExecutorService:作為ExecutorService的簡單實現,該類通過RunnableFuture和newTaskFor實現了submitinvokeAnyinvokeAll等方法;
  • ThreadPoolExecutor:該類是執行緒池的最終實現類,實現了Executor和ExecutorService中定義的能力,並豐富了AbstractExecutorService中的實現。在ThreadPoolExecutor中,定義了任務管理策略和執行緒池管理能力,相關能力的實現細節將是我們下文所要講解的核心所在。

如果你覺得還是不太能直觀地感受四兄弟的差異,那麼你可以放大檢視下面這幅高清圖示。看的時候,要格外注意它們各自方法的不同,方法的不同意味著它們的能力不同

而對於執行緒池總體的執行過程,下面這幅圖也建議你收藏。這幅圖雖然簡明,但完整展示了從任務提交到任務執行的整個過程。這個執行過程往往也是面試中的高頻面試題,務必掌握。

(1)執行緒池的核心屬性

執行緒池中的一些核心屬性選取如下,對於其中個別屬性會做特別說明。

// 執行緒池控制相關的主要變數
// 這個變數很神奇,下文後專門陳述,請特別留意
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

// 待處理的任務佇列
private final BlockingQueue < Runnable > workQueue;
// 工作執行緒集合
private final HashSet < Worker > workers = new HashSet < Worker > ();
// 建立執行緒所用到的執行緒工廠
private volatile ThreadFactory threadFactory;
// 拒絕策略
private volatile RejectedExecutionHandler handler;
// 核心執行緒數
private volatile int corePoolSize;
// 最大執行緒數
private volatile int maximumPoolSize;
// 空閒執行緒的保活時長
private volatile long keepAliveTime;
// 執行緒池變更的主要控制鎖,在工作執行緒數、變更執行緒池狀態等場景下都會用到
private final ReentrantLock mainLock = new ReentrantLock();

關於ctl欄位的特別說明

在ThreadPoolExecutor的多個核心欄位中,其他欄位可能都比較好理解,但是ctl要單獨拎出來做些解釋。

顧名思義,ctl這個欄位用於對執行緒池的控制。它的設計比較有趣,用一個欄位卻表示了兩層含義,也就是這個欄位實際是兩個欄位的合體:

  • runState:執行緒池的執行狀態(高3位);
  • workerCount:工作執行緒數量(低29位)。

這兩個欄位的值相互獨立,互不影響。那為何要用這種設計呢?這是因為,線上程池中這兩個欄位幾乎總是如影相隨,如果不用一個欄位來表示的話,那麼就需要通過鎖的機制來控制兩個欄位的一致性。不得不說,這個欄位設計上還是比較巧妙的。

線上程池中,也提供了一些方法可以方便地獲取執行緒池的狀態和工作執行緒數量,它們都是通過對ctl進行位運算得來。

/**
    計算當前執行緒池的狀態
*/
private static int runStateOf(int c) {
    return c & ~CAPACITY;
}
/**
    計算當前工作執行緒數
*/
private static int workerCountOf(int c) {
    return c & CAPACITY;
}
/**
    初始化ctl變數
*/
private static int ctlOf(int rs, int wc) {
    return rs | wc;
}

關於位運算,這裡補充一點說明,如果你對位運算有點迷糊的話可以看看,如果你對它比較熟悉則可以直接跳過。

假設A=15,二進位制是1111;B=6,二進位制是110.

運算子 名稱 描述 示例
& 按位與 如果相對應位都是1,則結果為1,否則為0 (A&B),得到6,即110
~ 按位非 按位取反運算子翻轉運算元的每一位,即0變成1,1變成0。 (〜A)得到-16,即11111111111111111111111111110000
| 按位或 如果相對應位都是 0,則結果為 0,否則為 1 (A | B)得到15,即 1111

(2)執行緒池的核心構造器

ThreadPoolExecutor有四個構造器,其中一個是核心構造器。你可以根據需要,按需使用這些構造器。

  • 核心構造器之一:相對較為常用的一個構造器,你可以指定核心執行緒數、最大執行緒數、執行緒保活時間和任務佇列型別。
public ThreadPoolExecutor(int corePoolSize,
    int maximumPoolSize,
    long keepAliveTime,
    TimeUnit unit,
    BlockingQueue < Runnable > workQueue) {
    this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
        Executors.defaultThreadFactory(), defaultHandler);
}
  • 核心構造器之二:相比於第一個構造器,你可以在這個構造器中指定ThreadFactory. 通過ThreadFactory,你可以指定執行緒名稱、分組等個性化資訊。
  public ThreadPoolExecutor(int corePoolSize,
    int maximumPoolSize,
    long keepAliveTime,
    TimeUnit unit,
    BlockingQueue < Runnable > workQueue,
    ThreadFactory threadFactory) {
    this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
        threadFactory, defaultHandler);
}
  • 核心構造器之三:這個構造器的要點在於,你可以指定拒絕策略。關於任務佇列的拒絕策略,下文有詳細介紹。
public ThreadPoolExecutor(int corePoolSize,
      int maximumPoolSize,
      long keepAliveTime,
      TimeUnit unit,
      BlockingQueue < Runnable > workQueue,
      RejectedExecutionHandler handler) {
      this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
          Executors.defaultThreadFactory(), handler);
}
  • 核心構造器之四:這個構造器是ThreadPoolExecutor的核心構造器,提供了較為全面的引數設定,上述的三個構造器都是基於它實現。
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;
}

(3)執行緒池中的核心方法

/**
* 提交Runnable型別的任務並執行,但不返回結果
*/
public void execute(Runnable command){...}
/**
* 提交Runnable型別的任務,並返回結果
*/
public Future<?> submit(Runnable task){...}
/**
* 提交Runnable型別的任務,並返回結果,支援指定預設結果
*/
public <T> Future<T> submit(Runnable task, T result){...}
/**
* 提交Callable型別的任務並執行
*/
public <T> Future<T> submit(Callable<T> task) {...}
/**
* 關閉執行緒池,繼續執行佇列中未完成的任務,但不會接收新的任務
*/
public void shutdown() {...}
/**
* 立即關閉執行緒池,同時放棄未執行的任務,並不再接收新的任務
*/
public List<Runnable> shutdownNow(){...}

(4)執行緒池的狀態與生命週期管理

前文說過,執行緒池恰似一個生產車間,而從生產車間的角度看,生產車間有執行、停產等不同狀態,所以執行緒池也是有一定的狀態和使用週期的。

  • Running:執行中,該狀態下可以繼續向執行緒池中增加任務,並正常處理佇列中的任務;
  • Shutdown:關閉中,該狀態下執行緒池不會立即停止,但不能繼續向執行緒池中增加任務,直到任務執行結束;
  • Stop:停止,該狀態下將不再接收新的任務,同時不再處理佇列中的任務,並中斷工作中的執行緒
  • Tidying:相對短暫的中間狀態,所有任務都已經結束,並且所有的工作執行緒都不再存在(workerCount==0),並執行terminated()鉤子方法;
  • Terminatedterminated()執行結束。

2. 如何向執行緒池中提交任務

向執行緒池提交任務有兩種比較常見的方式,一種是需要返回執行結果的一種則是不需要返回結果的

(1)不關注任務執行結果:execute

通過execute()提交任務到執行緒池後,任務將在未來某個時刻執行,執行的任務的執行緒可能是當前執行緒池中的執行緒,也可能是新建立的執行緒。當然,如果此時執行緒池應關閉,或者任務佇列已滿,那麼該任務將交由RejectedExecutionHandler處理。

(2)關注任務執行結果:submit

通過submit()提交任務到執行緒池後,執行機制和execute類似,其核心不同在於,由submit()提交任務時將等待任務執行結束並返回結果。

3. 如何管理提交的任務

(1)任務佇列選型策略

  • SynchronousQueue:無縫傳遞(Direct handoffs)。當新的任務到達時,將直接交由執行緒處理,而不是放入快取佇列。因此,如果任務達到時卻沒有可用執行緒,那麼將會建立新的執行緒。所以,為了避免任務丟失,在使用SynchronousQueue時,將會需要建立無數的執行緒,在使用時需要謹慎評估。
  • LinkedBlockingQueue:無界佇列,新提交的任務都會快取到該佇列中。使用無界佇列時,只有corePoolSize中的執行緒來處理佇列中的任務,這時候和maximumPoolSize是沒有關係的,它不會建立新的執行緒。當然,你需要注意的是,如果任務的處理速度遠低於任務的產生速度,那麼LinkedBlockingQueue的無限增長可能會導致記憶體容量等問題。
  • ArrayBlockingQueue:有界佇列,可能會觸發建立新的工作執行緒,maximumPoolSize引數設定在有界佇列中將發揮作用。在使用有界佇列時,要特別注意任務佇列大小和工作執行緒數量之間的權衡。如果任務佇列大但是執行緒數量少,那麼結果會是系統資源(主要是CPU)佔用率較低,但同時系統的吞吐量也會降低。反之,如果縮小任務佇列並擴大工作執行緒數量,那麼結果則是系統吞吐量增大,但同時系統資源佔用也會增加。所以,使用有界佇列時,要考慮到平衡的藝術,並配置相應的拒絕策略。

(2)如何選擇合適的拒絕策略

在使用執行緒池時,拒絕策略是必須要確認的地方,因為它可能會造成任務丟失

執行緒池已經關閉任務佇列已滿且無法再建立新的工作執行緒時,那麼新提交的任務將會被拒絕,拒絕時將呼叫RejectedExecutionHandler中的rejectedExecution(Runnable r, ThreadPoolExecutor executor)來執行具體的拒絕動作。

final void reject(Runnable command) {
    handler.rejectedExecution(command, this);
}

以execute方法為例,當執行緒池狀態異常或無法新增工作執行緒時,將會執行任務拒絕策略。

public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
               int c = ctl.get();
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            if (! isRunning(recheck) && remove(command))
                reject(command);
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
        else if (!addWorker(command, false))
            reject(command);
}

ThreadPoolExecutor的預設拒絕策略是AbortPolicy,這一點在屬性定義中已經確定。在大部分場景中,直接拒絕任務都是不合適的。

private static final RejectedExecutionHandler defaultHandler = new AbortPolicy();
  • AbortPolicy:預設策略,直接丟擲RejectedExecutionException異常;
  • CallerRunsPolicy:交由當前執行緒自己來執行。這種策略這提供了一個簡單的反饋控制機制,可以減慢提交新任務的速度;
  • DiscardPolicy:直接丟棄任務,不會丟擲異常;
  • DiscardOldestPolicy:如果此時執行緒池沒有關閉,將從佇列的頭部取出第一個任務並丟棄,並再次嘗試執行。如果執行失敗,那麼將重複這個過程。

如果上述四種策略均不滿足,你也可以通過RejectedExecutionHandler介面定製個性化的拒絕策略。事實上,為了兼顧任務不丟失和系統負載,建議你自己實現拒絕策略

(3)佇列維護

對於任務佇列的維護,執行緒池也提供了一些方法。

  • 獲取當前任務佇列
public BlockingQueue<Runnable> getQueue() {
    return workQueue;
}
  • 從佇列中移除任務
public boolean remove(Runnable task) {
    boolean removed = workQueue.remove(task);
    tryTerminate(); // In case SHUTDOWN and now empty
    return removed;
}

4. 如何管理執行任務的工作執行緒

(1)核心工作執行緒

核心執行緒(corePoolSize)是指最小數量的工作執行緒,此類執行緒不允許超時回收。當然,如果你設定了allowCoreThreadTimeOut,那麼核心執行緒也是會超時的,這可能會導致核心執行緒數為零。核心執行緒的數量可以通過執行緒池的構造引數指定。

(2)最大工作執行緒

最大工作執行緒指的是執行緒池為了處理現有任務,所能建立的最大工作執行緒數量。

最大工作執行緒可以通過建構函式的maximumPoolSize變數設定。當然,如果你所使用的任務佇列是無界佇列,那麼這個引數將形同虛設。

(3)如何建立新的工作執行緒

線上程池中,新執行緒的建立是通過ThreadFactory完成。你可以通過執行緒池的建構函式指定特定的ThreadFactory,如未指定將使用預設的Executors.defaultThreadFactory(),該工廠所建立的執行緒具有相同的ThreadGroup和優先順序(NORM_PRIORITY),並且都不是守護( Non-Daemon)執行緒。

通過設定ThreadFactory,你可以自定義執行緒的名字、執行緒組以及守護狀態等。

在Java的執行緒池ThreadPoolExecutor中,addWorker方法負責新執行緒的具體建立工作。

  private boolean addWorker(Runnable firstTask, boolean core) {...}

(4)保活時間

保活時間指的是非核心執行緒在空閒時所能存活的時間。

如果執行緒池中的執行緒數量超過了corePoolSize中的設定,那麼空閒執行緒的空閒時間在超過keepAliveTime中設定的時間後,執行緒將被回收終止。線上程被回收後,如果需要新的執行緒時,將繼續建立新的執行緒。

需要注意的是,keepAliveTime僅對非核心執行緒有效,如果需要設定核心執行緒的保活時間,需要使用allowCoreThreadTimeOut引數。

(5)鉤子方法

  • 設定任務執行前動作:beforeExecute

如果你希望提交的任務在執行前執行特定的動作,比如寫入日誌或設定ThreadLocal等。那麼,你可以通過重寫beforeExecute來實現這一目的。

protected void beforeExecute(Thread t, Runnable r) { }
  • 設定任務執行後動作:beforeExecute
    如果你希望提交的任務在執行後執行特定的動作,比如寫入日誌或捕獲異常等。那麼,你可以通過重寫afterExecute來實現這一目的。
protected void afterExecute(Runnable r, Throwable t) { }
  • 設定執行緒池終止動作:terminated
protected void terminated() { }

(6)執行緒池的預熱

預設情況下,在設定核心執行緒數之後,也不會立即建立相關執行緒,而是任務到達後再建立。

如果你需要預先就啟動核心執行緒,那麼你可以通過呼叫prestartCoreThreadprestartAllCoreThreads來提前啟動,以達到執行緒池預熱目的,並且可以通過ensurePrestart方法來驗證效果。

(7)執行緒回收機制

當執行緒池中的工作執行緒數量大於corePoolSize設定的數量時,並且存在空閒執行緒,並且這個空閒執行緒的空閒時長超過了keepAliveTime所設定的時長,那麼這樣的空閒執行緒將會被回收,以降低不必要的資源浪費。

final void runWorker(Worker w) {
        Thread wt = Thread.currentThread();
        Runnable task = w.firstTask;
        w.firstTask = null;
        w.unlock(); // allow interrupts
        boolean completedAbruptly = true;
        try {
            while (task != null || (task = getTask()) != null) {
           ...
        } finally {
            processWorkerExit(w, completedAbruptly); // 主動回收自己
        }
    }

(8)執行緒數調整策略

執行緒池的工作執行緒的設定是否合理,關係到系統負載和任務處理速度之間的平衡。這裡要明確的是,如何設定核心執行緒並沒有放之四海而皆準的公式。每個業務場景都有著它獨特的地方,CPU密集型和IO密集型任務存在較大差異。因此,在使用執行緒池的時候,要具體問題具體分析,但是你可以執行結果持續調整來優化執行緒池。

5. 執行緒池使用示例

我們仍以手工製作執行緒池部分的場景為例,通過ThreadPoolExecutor實現來展示執行緒池的使用示例。從程式碼中看,ThreadPoolExecutor的使用和王者執行緒池TheKingThreadPool的用法基本一致。

public static void main(String[] args) {
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(3, 20, 1000, TimeUnit.MILLISECONDS, new ArrayBlockingQueue < > (10));

    String[] wildMonsters = {"棕熊", "野雞", "灰狼", "野兔", "狐狸", "小鹿", "小花豹", "野豬"};
    for (String wildMonsterName: wildMonsters) {
        threadPoolExecutor.execute(new RunnableTask() {
            public String getTaskDesc() {
                return wildMonsterName;
            }

            public void run() {
                System.out.println(Thread.currentThread().getName() + ":" + wildMonsterName + "已經烤好");
            }
        });
    }

    threadPoolExecutor.shutdown();
}

6. Executors類

Executors是JUC中一個針對ThreadPoolExecutor和ThreadFactory等設計的一個工具類。通過Executors,可以方便地建立不同型別的執行緒池。當然,其內部主要是通過給ThreadPoolExecutor的構造傳遞特定的引數實現,並無玄機可言。常用的幾個工具如下所示:

  • 建立固定執行緒數的執行緒池
public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }
  • 建立只有1個執行緒的執行緒池
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>(),
                                    threadFactory));
    }
  • 建立快取執行緒池:這種執行緒池不設定核心執行緒數,根據任務的資料動態建立執行緒。當任務執行結束後,執行緒會被逐步回收,也就是所有的執行緒都是臨時的。
public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

7. 執行緒池監控

作為一個執行框架,ThreadPoolExecutor既簡單也複雜。因此,對其內部的監控和管理是十分必要的。ThreadPoolExecutor也提供了一些方法,通過這些方法,我們可以獲取到執行緒池的一些重要狀態和資料。

  • 獲取執行緒池大小
 public int getPoolSize() {
     final ReentrantLock mainLock = this.mainLock;
     mainLock.lock();
     try {
         // Remove rare and surprising possibility of
         // isTerminated() && getPoolSize() > 0
         return runStateAtLeast(ctl.get(), TIDYING) ? 0 :
             workers.size();
     } finally {
         mainLock.unlock();
     }
 }
  • 獲取活躍工作執行緒數量
 public int getActiveCount() {
     final ReentrantLock mainLock = this.mainLock;
     mainLock.lock();
     try {
         int n = 0;
         for (Worker w: workers)
             if (w.isLocked())
                 ++n;
         return n;
     } finally {
         mainLock.unlock();
     }
 }
  • 獲取最大執行緒池
 public int getLargestPoolSize() {
     final ReentrantLock mainLock = this.mainLock;
     mainLock.lock();
     try {
         return largestPoolSize;
     } finally {
         mainLock.unlock();
     }
 }
  • 獲取執行緒池中的任務總數
 public long getTaskCount() {
     final ReentrantLock mainLock = this.mainLock;
     mainLock.lock();
     try {
         long n = completedTaskCount;
         for (Worker w: workers) {
             n += w.completedTasks;
             if (w.isLocked())
                 ++n;
         }
         return n + workQueue.size();
     } finally {
         mainLock.unlock();
     }
 }
  • 獲取執行緒池中已完成的任務總數
public long getCompletedTaskCount() {
    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try {
        long n = completedTaskCount;
        for (Worker w: workers)
            n += w.completedTasks;
        return n;
    } finally {
        mainLock.unlock();
    }
}

四、如何養成正確使用執行緒池的良好習慣

1. 執行緒池的使用風險提示

雖然執行緒池的使用有諸多的好處,然而天下沒有免費的午餐,執行緒池在給我們帶來便利的同時,也有一些避免踩坑的注意事項:

  • 執行緒池設定過大或過小都不合適。如果執行緒池的執行緒數量過多,雖然區域性處理速度增加,但將會影響應用系統的整體效能。而如果執行緒池的執行緒數量過少,執行緒池可能無法帶來預期的效能的提升;
  • 和其他多執行緒類似,執行緒池中也可能會發生死鎖。比如,某個任務等待另外一個任務結束,但卻沒有執行緒來執行等待的那個任務,這也是為什麼要避免任務間存在依賴;
  • 新增任務到佇列時耗時過長。如果任務佇列已滿,外部執行緒向佇列新增任務將會受阻。所以,為了避免外部執行緒阻塞時間過長,你可以設定最大等待時間;

為了降低這些風險的發生,你在設定執行緒池的型別和引數時,應當格外小心。在正式上線前,最好能做一次壓力測試。

2. 建立執行緒池的推薦姿勢

雖然通過Executors建立執行緒比較方便,但是Executors的封裝遮蔽了一些重要的引數細節,而這些引數對於執行緒池至關重要,所以為了避免因對Executors不瞭解而錯誤地使用執行緒池,建議還是通過ThreadPoolExecutor的構造引數直接建立

3. 儘量避免使用無界佇列

如果再認真點說的話,你應該在任何時候都避免使用無界佇列來管理任務。注意,Executors的newFixedThreadPool所使用的是LinkedBlockingQueue,上文有它的原始碼。

小結

以上就是關於Java執行緒池的全部內容。在這篇文章中,我們講解了執行緒池的應用場景、核心組成及原理,並手工製作了一個執行緒池,而且在此基礎上深入講解了Java中的執行緒池ThreadPoolExecutor的實現。雖然文章整體篇幅較大,但是由於執行緒池涉及的內容十分廣泛,難以在一篇文章中全部提及,仍有部分重要內容未能覆蓋,比如如何處理執行緒池中的異常、如何優雅關閉執行緒池等。

熟練掌握執行緒池並不是一件容易的事,建議按照本文開篇的建議,先理解其要解決的問題,再理解其核心組成原理,最後再深入到Java中的原始碼中。如此一來,帶著已知的概念去看原始碼,會更容易理解原始碼的設計之道。

正文到此結束,恭喜你又上了一顆星✨

夫子的試煉

  • 思考:如何確保執行緒池不丟失任務。

延伸閱讀與參考資料

關於作者

關注【技術八點半】,及時獲取文章更新。傳遞有品質的技術文章,記錄平凡人的成長故事,偶爾也聊聊生活和理想。早晨8:30推送作者品質原創,晚上20:30推送行業深度好文。

如果本文對你有幫助,歡迎點贊關注監督,我們一起從青銅到王者

相關文章