聊聊線程池那些事

前言

平時開發過程中,我們會經常和線程池打交道,有時還會根據不同的業務進行線程池隔離,那麼瞭解線程池的工作原理和參數設置就是非常必要的,所以今天的主題就是探究線程池的那些事兒。

爲什麼使用線程池

在使用一項技術之前,瞭解 「why」 是至關重要的,即我們爲什麼要使用線程池?線程池有什麼好處?

線程池是一種池化技術,使用線程池可以減少線程創建時的資源消耗,同時也可以提高響應速度,即當有任務到達時,如果線程池中有空閒可用的線程,那麼直接拿線程去執行任務,節省了重新創建線程的時間開銷。

線程池 API

這部分闡述如何使用線程池,也就是介紹 Executor,ExecutorService,ThreadPoolExecutor 和 Executors 之間的區別和聯繫,它們都是 Executor 框架的核心組成部分。

Executor

Executor 是 抽象層面的核心接口:

public interface Executor {
    void execute(Runnable command);
}
  • 定義單一的 execute 方法,用於提交 Runnable 任務,即將任務和執行分離開來

ExecutorService

ExecutorService 接口對 Executor 進行擴展,提供了異步執行和關閉線程池等方法,下面是部分代碼:

public interface ExecutorService extends Executor {
    void shutdown();
    boolean isTerminated();
    boolean awaitTermination(long timeout, TimeUnit unit);
    <T> Future<T> submit(Callable<T> task);
}
  • 使用 submit 方法,不需要等待任務完成,它會直接返回 Future 對象,調用 Future 的 get() 方法可以查詢任務執行結果,如果任務還沒有完成,調用 get() 方法會被阻塞

ThreadPoolExecutor

ThreadPoolExecutor 是 ExecutorService 接口的實現類,即線程池的實現類,具體繼承結構如下:

ThreadPoolExecutor

ThreadPoolExecutor 有幾個核心參數,需要在手動創建時進行設置:

  • corePoolSize : 核心線程數

  • maximumPoolSize: 線程池中允許的最大線程數

  • keepAliveTime: 非核心線程存活時間。即當線程數大於corePoolSize,多餘的空閒線程在終止之前,等待新任務的最長存活時間。

  • unit: keepAliveTime 的單位

  • workQueue: 用來暫時保存任務的阻塞隊列

  • handler: 線程的拒絕策略。當線程池已經飽和,即達到了最大線程池大小,且阻塞隊列也已經滿了,線程池選擇一種拒絕策略來處理新來的任務。ThreadPoolExecutor 內部已經提供了以下 4 種策略:

    • CallerRunsPolicy : 提交任務的線程自己去執行任務
    • AbortPolicy: 默認的拒絕策略,拋出 RejectedExecutionException 異常
    • DiscardPolicy: 直接丟棄任務,沒有任何異常拋出
    • DiscardOldestPolicy:丟棄最老的任務,其實就是把最早進入工作隊列的任務丟棄,然後把新任務加入到工作隊列。
  • threadFactory: 創建線程的工廠,可以爲線程池中的線程設置名字,便於以後定位問題。常見的設置線程名稱的做法有:

    • 自定義實現 ThreadFactory 設置線程名稱
    • 使用 Google Guava 的 ThreadFactoryBuilder 設置線程名稱
    • 使用 Executors 工具類提供的默認線程池工廠 DefaultThreadFactory

Executors

Executors 實際上是一個工具類,提供一系列工廠方法創建不同類型的線程池,部分代碼如下:

public class Executors {
    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());
    }
    
    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));
    }
    
    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
    }
}

可以看出,使用 Executors 工具類創建線程池是非常簡便的,比如它創建了4種類型的 ThreadPoolExecutor :

  • FixThreadPool: 固定線程數大小的線程池
  • SingleThreadExecutor: 創建一個單線程的線程池,用單線程來執行任務
  • CachedThreadPool: 可緩存的線程池,corePoolSize 爲0,maximumPoolSize 是Integer.MAX_VALUE,對線程個數不做限制,可無限創建臨時線程。
  • ScheduledThreadPoolExecutor: 定長的線程池,支持定時及週期性的任務

但是在 《阿里巴巴Java開發手冊》中指出不要使用 Executors工具類來創建線程池,原因如下:

Executors_Java

所以,生產環境下還是通過手動創建 ThreadPoolExecutor ,結合實際場景來設置線程池的核心參數,同時爲線程池的線程設置有意義的名稱,便於定位問題。而 Executors 工具類可以用於平時寫一些簡單的 Demo 代碼,這還是很方便的。

線程池的工作原理

在熟悉了線程池的相關 API 以後,我們來看一下線程池的核心工作原理。

往簡單的講,就是:

  • 如果當前線程數 < corePoolSize 時,直接創建線程執行任務。
  • 後面再來任務,就把任務放到阻塞隊列中,如果阻塞隊列滿了就創建臨時線程。
  • 如果總線程數達到 maximumPoolSize,就執行拒絕策略。

往復雜的講,那就得從 ThreadPoolExecutor 的 execute 方法入手,源碼如下:

public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
    // 1. 首先獲取線程池的狀態控制變量
    int c = ctl.get(); 
    //2. workerCountOf(c) 獲取當前工作的線程數,如果工作線程數小於核心線程數
    if (workerCountOf(c) < corePoolSize) {
        //3. 創建核心線程
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    //4. 檢查線程池狀態是否正在運行,並嘗試將任務放入阻塞隊列中
    if (isRunning(c) && workQueue.offer(command)) {
        
        int recheck = ctl.get();
        //5. recheck再次檢查線程池的狀態,主要是爲了判斷加入到阻塞隊列中的線程是否可以被執行
        if (! isRunning(recheck) && remove(command))
            reject(command);
        //6. 驗證當前線程池中的工作線程的個數,如果爲0,創建一個空任務的線程來執行剛纔添加的任務
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    //7. 如果加入阻塞隊列失敗,說明隊列已經滿了,則創建新線程,如果創建失敗,說明已達到最大線程數,則執行拒絕策略
    else if (!addWorker(command, false))
        reject(command);
}

根據 execute 的源碼,向線程池提交任務的主要步驟如下:

  • 首先獲取線程池的狀態控制變量 ctl,說明一下:線程池的 ctl 是一個原子的 AtomicInteger,包含兩部分
    • workCount 表示工作的線程數,由 ctl 的低29位保存
    • runState 表示當前線程池的狀態,由 ctl 的高3位保存
  • 然後利用 workerCountOf© 獲取當前工作的線程數,如果當前工作線程數小於 corePoolSize,則創建線程。
  • 如果當前工作線程數大於等於 corePoolSize,則首先檢查線程池狀態是否正在運行。
  • 如果線程池處於 Running 狀態,那麼嘗試將任務加入 BlockingQueue
    • 如果加入 BlockingzQueue 成功, 再次檢查(recheck)線程池的狀態,主要是爲了判斷加入到阻塞隊列中的線程是否可以被執行
      • 如果線程池沒有 Running,則移除之前添加的任務,然後拒絕該任務
      • 如果線程池處於 Running 狀態,再次檢查(recheck)當前線程池中的工作線程的個數,如果爲0,則主動創建一個空任務的線程來執行任務
    • 如果加入 BlockingQueue失敗,則說明隊列已經滿了,則創建新的線程來執行任務
  • 如果線程池處於非運行狀態,嘗試創建線程,如果創建失敗,說明當前線程數已經達到最大線程數 maximunPoolSize,然後執行拒絕策略

上面就是 execute 方法的整個工作原理,同時我也畫了一張不算標準的流程圖來幫助理解,如圖所示:

ThreadPoolExecutor_Executor_Working

線程池的監控

如果在系統中大量使用線程池,則有必要對線程池的運行狀況進行監控,這樣當發生問題時,才便於排查。

一般有以下幾種方法來監控線程池:

  • 利用繼承的思想。通過繼承線程池來自定義線程池,重寫線程池的 beforeExecute,afterExecute 和 terminated 方法收集數據,想要可視化就依靠 JMX 來做。
  • 利用 SheduledExecutorService 執行定時任務去監控線程池的運行狀況
  • 利用 Metrics + JMX 的方式對線程池進行監控
  • 在 SpringBoot 項目中利用 actuator 組件來做線程池的監控
  • 在一些大型系統中,利用 Micrometer + Prometheus 等監控組件去監控線程池的各項指標

就如同探討 「回」有幾種寫法一樣,方法有很多。對於線程池監控,我們需要根據自己的實際場景,選擇恰當的方法。下面打個樣,寫了一個利用 SheduledExecutorService 執行定時任務去監控線程池的例子 :

@Slf4j
public class MonitorThreadPoolStats {
    public static void main(String[] args) {
        ThreadPoolExecutor threadPool = (ThreadPoolExecutor) Executors.newFixedThreadPool(5);
        printThreadPoolStats(threadPool);
        for (int i = 0; i <10 ; i++) {
            threadPool.execute(()->{
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
    }

    private static void printThreadPoolStats(ThreadPoolExecutor threadPool) {
        Executors.newSingleThreadScheduledExecutor()
                .scheduleAtFixedRate(
                        () -> log.info("Thread pool monitors metrics : poolSize:{}, activeThreads:{}, completedTasks:{}, QueueTasks: {}",
                        threadPool.getPoolSize(), threadPool.getActiveCount(), threadPool.getCompletedTaskCount(), threadPool.getQueue().size()),
      0, 1, TimeUnit.SECONDS);
    }
}

如何設置線程池的大小

我們在一開始使用線程池,就要面對如何設置線程池大小的問題。線程池不宜設置得過大或過小:

  • 如果設置過大,會導致大量線程在相對較少的CPU和內存資源上發生競爭。
  • 如果設置過小,會導致系統無法充分利用系統資源。

參考網上的資料以及 《Java併發編程實戰》的說法,可根據任務類型來確定:

  • CPU 密集型任務: 這種任務主要消耗 CPU 資源,可分配少量的線程,比如一般設置爲 ( CPU 核心數 + 1 )
  • IO 密集型任務:這種任務主要處理I/O交互,可配置些線程,比如 CPU 核心數 * 2

當然在實際場景中,我們需要先評估業務併發量、機器配置等因素,再去設置一個合理的大小。

小結

這篇文章總結了一些線程池的知識點,比如線程池的核心 API、詳細的工作原理等,當然還有很多細節地方沒有深入下去,待後續有機會繼續分享。


pjmike

參考資料 & 鳴謝

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章