前言
平時開發過程中,我們會經常和線程池打交道,有時還會根據不同的業務進行線程池隔離,那麼瞭解線程池的工作原理和參數設置就是非常必要的,所以今天的主題就是探究線程池的那些事兒。
爲什麼使用線程池
在使用一項技術之前,瞭解 「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 有幾個核心參數,需要在手動創建時進行設置:
-
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工具類來創建線程池,原因如下:
所以,生產環境下還是通過手動創建 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失敗,則說明隊列已經滿了,則創建新的線程來執行任務
- 如果加入 BlockingzQueue 成功, 再次檢查(recheck)線程池的狀態,主要是爲了判斷加入到阻塞隊列中的線程是否可以被執行
- 如果線程池處於非運行狀態,嘗試創建線程,如果創建失敗,說明當前線程數已經達到最大線程數 maximunPoolSize,然後執行拒絕策略
上面就是 execute 方法的整個工作原理,同時我也畫了一張不算標準的流程圖來幫助理解,如圖所示:
線程池的監控
如果在系統中大量使用線程池,則有必要對線程池的運行狀況進行監控,這樣當發生問題時,才便於排查。
一般有以下幾種方法來監控線程池:
- 利用繼承的思想。通過繼承線程池來自定義線程池,重寫線程池的 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、詳細的工作原理等,當然還有很多細節地方沒有深入下去,待後續有機會繼續分享。