Java同步之執行緒池詳解

拉夫德魯發表於2021-09-02

帶著問題閱讀

1、什麼是池化,池化能帶來什麼好處

2、如何設計一個資源池

3、Java的執行緒池如何使用,Java提供了哪些內建執行緒池

4、執行緒池使用有哪些注意事項

池化技術

池化思想介紹

池化思想是將重量級資源預先準備好,在使用時可重複使用這些預先準備好的資源。

池化思想的核心概念有:

  • 資源建立/銷燬開銷大
  • 提前建立,集中管理
  • 重複利用,資源可回收

例如大街上的共享單車,使用者掃碼開鎖,使用完後歸還到停放點,下一個使用者可以繼續使用,共享單車由廠商統一管理,為使用者節省了購買單車的開銷。

池化技術的應用

常見的池化技術應用有:資源池、連線池、執行緒池等。

  • 資源池

    在各種電商平臺大促活動時,平臺需要支撐平時幾十倍的流量,因此各大平臺在需要提前準備大量伺服器進行擴容,在活動完畢以後,擴容的伺服器資源又白白浪費。將計算資源池化,在業務高峰前進行分配,高峰結束後提供給其他業務或使用者使用,即可節省大量消耗,資源池化也是雲端計算的核心技術之一。

  • 連線池

    網路連線的建立和釋放也是一個開銷較大的過程,提前在伺服器之間建立好連線,在需要使用的時候從連線池中獲取,使用完畢後歸還連線池,以供其他請求使用,以此可節省掉大量的網路連線時間,如資料庫連線池、HttpClient連線池。

  • 執行緒池

    執行緒的建立銷燬都涉及到核心態切換,提前建立若干數量的執行緒提供給客戶端複用,可節約大量的CPU消耗以便處理業務邏輯。執行緒池也是接下來重點要講的內容。

如何設計一個執行緒池

設計一個執行緒池,至少需要提供的核心能力有:

  • 執行緒池容器:用於容納初始化時預先建立的執行緒。
  • 執行緒狀態管理:管理池內執行緒的生命週期,記錄每個執行緒當前的可服務狀態。
  • 執行緒請求管理:對呼叫端提供獲取和歸還執行緒的介面。
  • 執行緒耗盡策略:提供策略以處理執行緒耗盡問題,如拒絕服務、擴容執行緒池、排隊等待等。

基於以上角度,我們來分析Java是如何設計執行緒池功能的。

Java執行緒池解析

ThreadPoolExecutor使用介紹

大象裝冰箱總共分幾步

// 1.建立執行緒池
ThreadPoolExecutor threadPool = 
    new ThreadPoolExecutor(1, 1, 1L, TimeUnit.MINUTES, new LinkedBlockingQueue<>());
// 2.提交任務
threadPool.execute(new Runnable() {
    @Override
    public void run() {
        System.out.println("task running");
    }
}});
// 3.關閉執行緒池
threadPool.shutDown();

Java通過ThreadPoolExecutor提供執行緒池的實現,如示例程式碼,初始化一個容量為1的執行緒池、然後提交任務、最後關閉執行緒池。

ThreadPoolExecutor的核心方法主要有

  • 建構函式:ThreadPoolExecutor提供了多個建構函式,以下對基礎建構函式進行說明。

    public ThreadPoolExecutor(int corePoolSize,
                                  int maximumPoolSize,
                                  long keepAliveTime,
                                  TimeUnit unit,
                                  BlockingQueue<Runnable> workQueue,
                                  ThreadFactory threadFactory,
                                  RejectedExecutionHandler handler)
    
    • corePoolSize:執行緒池的核心執行緒數。池內執行緒數小於corePoolSize時,執行緒池會建立新執行緒執行任務。

    • maximumPoolSize:執行緒池的最大執行緒數。池內執行緒數大於corePoolSizeworkQueue任務等待佇列已滿時,執行緒池會建立新執行緒執行佇列中的任務,直到執行緒數達到maximumPoolSize為止。

    • keepAliveTime:非核心執行緒的存活時長。池內超過corePoolSize數量的執行緒可存活的時長。

    • unit:非核心執行緒存活時長單位。與keepAliveTime取值配合,如示例程式碼表示1分鐘。

    • workQueue:任務提交佇列。當無空閒核心執行緒時,儲存待執行任務。

      型別 作用
      ArrayBlockingQueue 陣列結構的有界阻塞佇列
      LinkedBlockingQueue 連結串列結構的阻塞佇列,可設定是否有界
      SynchronousQueue 不儲存元素的阻塞佇列,直接將任務提交給執行緒池執行
      PriorityBlockingQueue 支援優先順序的無界阻塞佇列
      DelayQueue 支援延時執行的無界阻塞佇列
    • threadFactory:執行緒工廠。用於建立執行緒物件。

    • handler:拒絕策略。執行緒池執行緒數量達到maximumPoolSizeworkQueue已滿時的處理策略。

      型別 作用
      AbortPolicy 拒絕並丟擲異常。預設
      CallerRunsPolicy 由提交任務的執行緒執行任務
      DiscardOldestPolicy 拋棄佇列頭部任務
      DiscardPolicy 拋棄該任務
  • 執行函式:executesubmit,主要分別用於執行RunnableCallable

    // 提交Runnable
    void execute(Runnable command);
    
    // 提交Callable並返回Future
    <T> Future<T> submit(Callable<T> task);
    
    // 提交Runnable,執行結束後Future.get會返回result
    <T> Future<T> submit(Runnable task, T result);
    
    // 提交Runnable,執行結束後Future.get會返回null
    Future<?> submit(Runnable task);
    
  • 停止函式:shutDownshutDownNow

    // 不再接收新任務,等待剩餘任務執行完畢後停止執行緒池
    void shutdown();
    
    // 不再接收新任務,並嘗試中斷執行中的任務,返回還在等待佇列中的任務列表
    List<Runnable> shutdownNow();
    

內建執行緒池使用

To be useful across a wide range of contexts, this class provieds many adjustable parameters and extensibility hooks. However, programmers are urged to use the more convenient {@link Executors} factory methods {@link Executors#newCachedThreadPool} (unbounded thread poll, with automatic thread reclamation), {@link Executors#newFixedThreadPool} (fixed size thread pool) and {@link Executors#newSingleThreadExecutor}(single background thread), that preconfigure settings for the most common usage scenarios.

由於ThreadPoolExecutor引數複雜,Java提供了三種內建執行緒池newCachedThreadPoolnewFixedThreadPoolnewSingleThreadExecutor應對大多數場景。

  • Executors.newCachedThreadPool()無界執行緒池,核心執行緒池大小為0,最大為Integer.MAX_VALUE,因此嚴格來講並不算無界。採用SynchronousQueueworkQueue,意味著任務不會被阻塞儲存在佇列,而是直接遞交到執行緒池,如執行緒池無可用執行緒,則建立新執行緒執行。

    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, 
                                      new SynchronousQueue<Runnable>());
    }
    
  • Executors.newFixedThreadPool(int nThreads)固定大小執行緒池,其中coreSizemaxSize相等,且過期時間為0,表示經過一定數量任務提交後,執行緒池將始終維持在nThreads數量大小,不會新增也不會回收執行緒。

    public static ExecutorService new FixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads nThreads, 0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }
    
  • Executors.newSingleThreadExecutor()單執行緒池,引數與fixedThreadPool類似,只是將數量限制在1,單執行緒池主要避免重複建立銷燬執行緒物件,也可用於序列化執行任務。不同與其他執行緒池,單執行緒池採用FinallizableDelegatedExecutorServiceThreadPoolExecutor物件進行包裝,感興趣的同學可以看下原始碼,其方法實現僅僅是對被包裝物件方法的直接呼叫。包裝物件主要用於避免使用者將執行緒池強制轉換為ThreadPoolExecutor來修改執行緒池大小

    public static ExecutorService newSingleThreadExecutor() {
        return new FinallizableDelegatedExecutorService(
          (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, 
                                    new LinkedBlockQueue<Runnable>()))
        );
    }
    

ThreadPoolExecutor解析

整體設計

執行緒池繼承關係

ThreadPoolExecutor基於ExecutorService介面實現提交任務,未採取常規資源池獲取/歸還資源的形式,整個執行緒池和執行緒的生命週期都由ThreadPoolExecutor進行管理,執行緒物件不對外暴露;ThreadPoolExecutor的任務管理機制類似於生產者消費者模型,其內部維護一個任務佇列和消費者,一般情況下,任務被提交到佇列中,消費執行緒從佇列中拉取任務並將其執行。

執行緒池生命週期

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY   = (1 << COUNT_BITS) - 1;

private static int runStateOf(int c)     { return c & ~CAPACITY; } //計算當前執行狀態
private static int workerCountOf(int c)  { return c & CAPACITY; }  //計算當前執行緒數量
private static int ctlOf(int rs, int wc) { return rs | wc; }   //通過狀態和執行緒數生成ctl

TreadPoolExecutor通過ctl維護執行緒池的狀態和執行緒數量,其中高3位儲存執行狀態,低29位儲存執行緒數量。

位運算操作推薦參考第三篇文章。

執行緒池設定了RUNNINGSHUTDOWNSTOPTIDYINGTERMINATED五種狀態,其轉移圖如下:

在這5種狀態中,只有RUNNING時執行緒池可接收新任務,其餘4種狀態在呼叫shutDownshutDownNow後觸發轉換,且在這4種狀態時,執行緒池均不再接收新任務。

任務管理解析

// 用於存放提交任務的佇列
private final BlockingQueue<Runnable> workQueue;

// 用於儲存池內的工作執行緒,Java將Thread包裝成Worker儲存
private final HashSet<Worder> workers = new HashSet<Worker>();

ThreadPoolExecutor主要通過workQueueworkers兩個欄位用於管理和執行任務。

Java同步之執行緒池詳解

執行緒池任務執行流程如圖,結合ThreadPoolExecutor.execute原始碼,對任務執行流程進行說明:

  • 當任務提交到執行緒池時,如果當前執行緒數量小於核心執行緒數,則會將為該任務直接建立一個worker並將任務交由worker執行。

    if (workerCountOf(c) < corePoolSize) {
        // 建立新worker執行任務,true表示核心執行緒
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    
  • 當已經達到核心執行緒數後,任務會提交到佇列儲存;

    // 放入workQueue佇列
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        // 這裡採用double check再次檢測執行緒池狀態
        if (! isRunning(recheck) && remove(command))
            reject(command);
        // 避免加入佇列後,所有worker都已被回收無可用執行緒
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    
  • 如果佇列已滿,則依據最大執行緒數量建立新worker執行。如果新增worker失敗,則依據設定策略拒絕任務。

    // 接上,放入佇列失敗
    // 新增新worker執行任務,false表示非核心執行緒
    else if (!addWorker(command, false))
        // 如新增失敗,執行拒絕策略
        reject(command);
    

woker物件

ThreadPoolExecutor沒有直接使用Thread記錄執行緒,而是定義了worker用於包裝執行緒物件。

private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
    ...
    final Thread thread;
    
    Runnable firstTask;
    
    Worker(Runnable firstTask) {
        setState(-1); // inhibit interrupts until runWorker
        this.firstTask = firstTask;
        this.thread = getThreadFactory().newThread(this);
    }
    
    // worker物件被建立後就會執行
    public void run() {
        runWorker(this);
    }
}

worker物件通過addWorker方法建立,一般會為其指定一個初始任務firstTask,當worker執行完畢以後,worker會從阻塞佇列中讀取任務,如果沒有任務,則該worker會陷入阻塞狀態給出worker的核心邏輯程式碼:

private boolean addWorker(Runnable firstTask, boolean core) {
    ...
    // 指定firstTask,可能為null
    w = new Worker(firstTask);
    ...
    if (rs < SHUTDOWN || (rs == SHUTDOWN && firstTask == null)) {
        if (t.isAlive()) // precheck that t is startable
            throw new IllegalThreadStateException();
        workers.add(w);
        workerAdded = true;
    }
    ...
    // 執行新新增的worker
    if (workerAdded) {
        t.start();
        workerStarted = true;
    }
}


final void runWorker(Worker w) {
    // 等待workQueue的任務
    while (task != null || (task = getTask()) != null) {
    	...
    }
}

private Runnable getTask() {
    ...
    for (;;) {
        ...
        // 如果是普通工作執行緒,則根據執行緒存活時間讀取阻塞佇列
        // 如果是核心工作執行緒,則直接陷入阻塞狀態,等待workQueue獲取任務
        Runnable r = timed ?
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take();
        ...
    }
}

如下圖,任務提交後觸發addWorker建立worker物件,該物件執行任務完畢後,則迴圈獲取佇列中任務等待執行。

Java執行緒池實踐建議

不建議使用Exectuors

執行緒池不允許使用Executors去建立,而是通過ThreadPoolExecutor的方式,這樣的處理方式讓寫的同學更加明確執行緒池的執行規則,規避資源耗盡的風險。《阿里巴巴開發手冊》

雖然Java推薦開發者直接使用Executors提供的執行緒池,但實際開發中通常不使用。主要考慮問題有:

  • 潛在的OOM問題

    CachedThreadPool將最大數量設定為Integer.MAX_VALUE,如果一直提交任務,可能造成Thread物件過多引起OOMFixedThreadPoolSingleThreadPoo的佇列LinkedBlockingQueue無容量限制,阻塞任務過多也可能造成OOM

  • 執行緒問題定位不便

    由於未指定ThreadFactory,執行緒名稱預設為pool-poolNumber-thread-thredNumber,執行緒出現問題後不便定位具體執行緒池。

  • 執行緒池分散

    通常在完善的專案中,由於執行緒是重量資源,因此執行緒池由統一模組管理,重複建立執行緒池容易造成資源分散,難以管理。

執行緒池大小設定

通常按照IO繁忙型和CPU繁忙型任務分別採用以下兩個普遍公式。

\[N_{thread} = N_{cpu} * 2 \\ N_{thread} = N_{cpu} + 1 \]

在理論場景中,如一個任務IO耗時40ms,CPU耗時10ms,那麼在IO處理期間,CPU是空閒的,此時還可以處理4個任務(40/10),因此理論上可以按照IO和CPU的時間消耗比設定執行緒池大小。

\[Ratio = (Time_{io} + Time_{cpu}) / Time_{cpu} \\ N_{thread} = (Ratio + 1) * N_{cpu} \]

《JAVA併發程式設計實踐》中還考慮數量乘以目標CPU的利用率

在實際場景中,我們通常無法準確測算IO和CPU的耗時佔比,並且隨著流量變化,任務的耗時佔比也不能固定。因此可根據業務需求,開設執行緒池運維介面,根據線上指標動態調整執行緒池引數。

推薦參考第二篇美團執行緒池應用

執行緒池監控

ThreadPoolExecutor提供以下方法監控執行緒池:

  • getTaskCount() 返回被排程過的任務數量

  • getCompletedTaskCount() 返回完成的任務數量

  • getPoolSize() 返回當前執行緒池執行緒數量

  • getActiveCount() 返回活躍執行緒數量

  • getQueue()獲取佇列,一般用於監控阻塞任務數量和佇列空間大小

參考

相關文章