不聊原理,拿來即用的線程池聊聊
本文轉載自微信公眾號「JavaGuide」 作者:Guide哥,轉載本文請聯系JavaGuide公眾號。
大家好,我是 Guide 哥,一個三觀比主角還正的技術人。今天再來繼續聊聊線程池~
這篇文章篇幅在5000字左右,絕對是干貨。標題稍微有點夸張,嘿嘿,實際都是自己使用線程池的時候總結的一些個人感覺比較重要的點。
線程池知識回顧
開始這篇文章之前還是簡單介紹一嘴線程池,之前寫的《新手也能看懂的線程池學習總結》這篇文章介紹的很詳細了。
為什么要使用線程池?
“池化技術相比大家已經屢見不鮮了,線程池、數據庫連接池、Http 連接池等等都是對這個思想的應用。池化技術的思想主要是為了減少每次獲取資源的消耗,提高對資源的利用率。
線程池提供了一種限制和管理資源(包括執行一個任務)。每個線程池還維護一些基本統計信息,例如已完成任務的數量。
這里借用《Java 并發編程的藝術》提到的來說一下使用線程池的好處:
- 降低資源消耗。通過重復利用已創建的線程降低線程創建和銷毀造成的消耗。
- 提高響應速度。當任務到達時,任務可以不需要的等到線程創建就能立即執行。
- 提高線程的可管理性。線程是稀缺資源,如果無限制的創建,不僅會消耗系統資源,還會降低系統的穩定性,使用線程池可以進行統一的分配,調優和監控。
線程池在實際項目的使用場景
線程池一般用于執行多個不相關聯的耗時任務,沒有多線程的情況下,任務順序執行,使用了線程池的話可讓多個不相關聯的任務同時執行。
假設我們要執行三個不相關的耗時任務,Guide 畫圖給大家展示了使用線程池前后的區別。
注意:下面三個任務可能做的是同一件事情,也可能是不一樣的事情。
使用線程池前后對比
如何使用線程池?
一般是通過 ThreadPoolExecutor 的構造函數來創建線程池,然后提交任務給線程池執行就可以了。
ThreadPoolExecutor構造函數如下:
- /**
- * 用給定的初始參數創建一個新的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.corePoolSize = corePoolSize;
- this.maximumPoolSize = maximumPoolSize;
- this.workQueue = workQueue;
- this.keepAliveTime = unit.toNanos(keepAliveTime);
- this.threadFactory = threadFactory;
- this.handler = handler;
- }
簡單演示一下如何使用線程池,更詳細的介紹,請看:《新手也能看懂的線程池學習總結》。
- private static final int CORE_POOL_SIZE = 5;
- private static final int MAX_POOL_SIZE = 10;
- private static final int QUEUE_CAPACITY = 100;
- private static final Long KEEP_ALIVE_TIME = 1L;
- public static void main(String[] args) {
- //使用阿里巴巴推薦的創建線程池的方式
- //通過ThreadPoolExecutor構造函數自定義參數創建
- ThreadPoolExecutor executor = new ThreadPoolExecutor(
- CORE_POOL_SIZE,
- MAX_POOL_SIZE,
- KEEP_ALIVE_TIME,
- TimeUnit.SECONDS,
- new ArrayBlockingQueue<>(QUEUE_CAPACITY),
- new ThreadPoolExecutor.CallerRunsPolicy());
- for (int i = 0; i < 10; i++) {
- executor.execute(() -> {
- try {
- Thread.sleep(2000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- System.out.println("CurrentThread name:" + Thread.currentThread().getName() + "date:" + Instant.now());
- });
- }
- //終止線程池
- executor.shutdown();
- try {
- executor.awaitTermination(5, TimeUnit.SECONDS);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- System.out.println("Finished all threads");
- }
控制臺輸出:
- CurrentThread name:pool-1-thread-5date:2020-06-06T11:45:31.639Z
- CurrentThread name:pool-1-thread-3date:2020-06-06T11:45:31.639Z
- CurrentThread name:pool-1-thread-1date:2020-06-06T11:45:31.636Z
- CurrentThread name:pool-1-thread-4date:2020-06-06T11:45:31.639Z
- CurrentThread name:pool-1-thread-2date:2020-06-06T11:45:31.639Z
- CurrentThread name:pool-1-thread-2date:2020-06-06T11:45:33.656Z
- CurrentThread name:pool-1-thread-4date:2020-06-06T11:45:33.656Z
- CurrentThread name:pool-1-thread-1date:2020-06-06T11:45:33.656Z
- CurrentThread name:pool-1-thread-3date:2020-06-06T11:45:33.656Z
- CurrentThread name:pool-1-thread-5date:2020-06-06T11:45:33.656Z
- Finished all threads
線程池最佳實踐
簡單總結一下我了解的使用線程池的時候應該注意的東西,網上似乎還沒有專門寫這方面的文章。
因為 Guide 還比較菜,有補充\完善\錯誤的地方,可以在評論區告知或者在微信上與我交流。
1. 使用 ThreadPoolExecutor 的構造函數聲明線程池
線程池必須手動通過 ThreadPoolExecutor 的構造函數來聲明,避免使用Executors 類的newFixedThreadPool 和 newCachedThreadPool ,因為可能會有 OOM 的風險。
“Executors 返回線程池對象的弊端如下:
- FixedThreadPool 和 SingleThreadExecutor :允許請求的隊列長度為Integer.MAX_VALUE,可能堆積大量的請求,從而導致 OOM。
- CachedThreadPool 和 ScheduledThreadPool :允許創建的線程數量為 Integer.MAX_VALUE,可能會創建大量線程,從而導致 OOM。
說白了就是:使用有界隊列,控制線程創建數量。
除了避免 OOM 的原因之外,不推薦使用 Executors提供的兩種快捷的線程池的原因還有:
- 實際使用中需要根據自己機器的性能、業務場景來手動配置線程池的參數比如核心線程數、使用的任務隊列、飽和策略等等。
- 我們應該顯示地給我們的線程池命名,這樣有助于我們定位問題。
2.監測線程池運行狀態
你可以通過一些手段來檢測線程池的運行狀態比如 SpringBoot 中的 Actuator 組件。
除此之外,我們還可以利用 ThreadPoolExecutor 的相關 API 做一個簡陋的監控。從下圖可以看出, ThreadPoolExecutor提供了獲取線程池當前的線程數和活躍線程數、已經執行完成的任務數、正在排隊中的任務數等等。
下面是一個簡單的 Demo。printThreadPoolStatus()會每隔一秒打印出線程池的線程數、活躍線程數、完成的任務數、以及隊列中的任務數。
- /**
- * 打印線程池的狀態
- *
- * @param threadPool 線程池對象
- */
- public static void printThreadPoolStatus(ThreadPoolExecutor threadPool) {
- ScheduledExecutorService scheduledExecutorService = new ScheduledThreadPoolExecutor(1, createThreadFactory("print-thread-pool-status", false));
- scheduledExecutorService.scheduleAtFixedRate(() -> {
- log.info("=========================");
- log.info("ThreadPool Size: [{}]", threadPool.getPoolSize());
- log.info("Active Threads: {}", threadPool.getActiveCount());
- log.info("Number of Tasks : {}", threadPool.getCompletedTaskCount());
- log.info("Number of Tasks in Queue: {}", threadPool.getQueue().size());
- log.info("=========================");
- }, 0, 1, TimeUnit.SECONDS);
- }
3.建議不同類別的業務用不同的線程池
很多人在實際項目中都會有類似這樣的問題:我的項目中多個業務需要用到線程池,是為每個線程池都定義一個還是說定義一個公共的線程池呢?
一般建議是不同的業務使用不同的線程池,配置線程池的時候根據當前業務的情況對當前線程池進行配置,因為不同的業務的并發以及對資源的使用情況都不同,重心優化系統性能瓶頸相關的業務。
我們再來看一個真實的事故案例! (本案例來源自:《線程池運用不當的一次線上事故》@https://club.perfma.com/article/646639 ,很精彩的一個案例)
案例代碼概覽
上面的代碼可能會存在死鎖的情況,為什么呢?畫個圖給大家捋一捋。
試想這樣一種極端情況:
假如我們線程池的核心線程數為 n,父任務(扣費任務)數量為 n,父任務下面有兩個子任務(扣費任務下的子任務),其中一個已經執行完成,另外一個被放在了任務隊列中。由于父任務把線程池核心線程資源用完,所以子任務因為無法獲取到線程資源無法正常執行,一直被阻塞在隊列中。父任務等待子任務執行完成,而子任務等待父任務釋放線程池資源,這也就造成了 "死鎖"。
解決方法也很簡單,就是新增加一個用于執行子任務的線程池專門為其服務。
4.別忘記給線程池命名
初始化線程池的時候需要顯示命名(設置線程池名稱前綴),有利于定位問題。
默認情況下創建的線程名字類似 pool-1-thread-n 這樣的,沒有業務含義,不利于我們定位問題。
給線程池里的線程命名通常有下面兩種方式:
1).利用 guava 的 ThreadFactoryBuilder
- ThreadFactory threadFactory = new ThreadFactoryBuilder()
- .setNameFormat(threadNamePrefix + "-%d")
- .setDaemon(true).build();
- ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory)
2).自己實現 ThreadFactor。
- import java.util.concurrent.Executors;
- import java.util.concurrent.ThreadFactory;
- import java.util.concurrent.atomic.AtomicInteger;
- /**
- * 線程工廠,它設置線程名稱,有利于我們定位問題。
- */
- public final class NamingThreadFactory implements ThreadFactory {
- private final AtomicInteger threadNum = new AtomicInteger();
- private final ThreadFactory delegate;
- private final String name;
- /**
- * 創建一個帶名字的線程池生產工廠
- */
- public NamingThreadFactory(ThreadFactory delegate, String name) {
- this.delegate = delegate;
- this.name = name; // TODO consider uniquifying this
- }
- @Override
- public Thread newThread(Runnable r) {
- Thread t = delegate.newThread(r);
- t.setName(name + " [#" + threadNum.incrementAndGet() + "]");
- return t;
- }
- }
5.正確配置線程池參數
說到如何給線程池配置參數,美團的騷操作至今讓我難忘(后面會提到)!
我們先來看一下各種書籍和博客上一般推薦的配置線程池參數的方式,可以作為參考!
常規操作
很多人甚至可能都會覺得把線程池配置過大一點比較好!我覺得這明顯是有問題的。就拿我們生活中非常常見的一例子來說:并不是人多就能把事情做好,增加了溝通交流成本。你本來一件事情只需要 3 個人做,你硬是拉來了 6 個人,會提升做事效率嘛?我想并不會。 線程數量過多的影響也是和我們分配多少人做事情一樣,對于多線程這個場景來說主要是增加了上下文切換成本。不清楚什么是上下文切換的話,可以看我下面的介紹。
“上下文切換:多線程編程中一般線程的個數都大于 CPU 核心的個數,而一個 CPU 核心在任意時刻只能被一個線程使用,為了讓這些線程都能得到有效執行,CPU 采取的策略是為每個線程分配時間片并輪轉的形式。當一個線程的時間片用完的時候就會重新處于就緒狀態讓給其他線程使用,這個過程就屬于一次上下文切換。概括來說就是:當前任務在執行完 CPU 時間片切換到另一個任務之前會先保存自己的狀態,以便下次再切換回這個任務時,可以再加載這個任務的狀態。任務從保存到再加載的過程就是一次上下文切換。上下文切換通常是計算密集型的。也就是說,它需要相當可觀的處理器時間,在每秒幾十上百次的切換中,每次切換都需要納秒量級的時間。所以,上下文切換對系統來說意味著消耗大量的 CPU 時間,事實上,可能是操作系統中時間消耗最大的操作。Linux 相比與其他操作系統(包括其他類 Unix 系統)有很多的優點,其中有一項就是,其上下文切換和模式切換的時間消耗非常少。
類比于實現世界中的人類通過合作做某件事情,我們可以肯定的一點是線程池大小設置過大或者過小都會有問題,合適的才是最好。
如果我們設置的線程池數量太小的話,如果同一時間有大量任務/請求需要處理,可能會導致大量的請求/任務在任務隊列中排隊等待執行,甚至會出現任務隊列滿了之后任務/請求無法處理的情況,或者大量任務堆積在任務隊列導致 OOM。這樣很明顯是有問題的!CPU 根本沒有得到充分利用。
但是,如果我們設置線程數量太大,大量線程可能會同時在爭取 CPU 資源,這樣會導致大量的上下文切換,從而增加線程的執行時間,影響了整體執行效率。
有一個簡單并且適用面比較廣的公式:
- CPU 密集型任務(N+1): 這種任務消耗的主要是 CPU 資源,可以將線程數設置為 N(CPU 核心數)+1,比 CPU 核心數多出來的一個線程是為了防止線程偶發的缺頁中斷,或者其它原因導致的任務暫停而帶來的影響。一旦任務暫停,CPU 就會處于空閑狀態,而在這種情況下多出來的一個線程就可以充分利用 CPU 的空閑時間。
- I/O 密集型任務(2N): 這種任務應用起來,系統會用大部分的時間來處理 I/O 交互,而線程在處理 I/O 的時間段內不會占用 CPU 來處理,這時就可以將 CPU 交出給其它線程使用。因此在 I/O 密集型任務的應用中,我們可以多配置一些線程,具體的計算方法是 2N。
如何判斷是 CPU 密集任務還是 IO 密集任務?
CPU 密集型簡單理解就是利用 CPU 計算能力的任務比如你在內存中對大量數據進行排序。但凡涉及到網絡讀取,文件讀取這類都是 IO 密集型,這類任務的特點是 CPU 計算耗費時間相比于等待 IO 操作完成的時間來說很少,大部分時間都花在了等待 IO 操作完成上。
美團的騷操作
美團技術團隊在《Java 線程池實現原理及其在美團業務中的實踐》這篇文章中介紹到對線程池參數實現可自定義配置的思路和方法。
美團技術團隊的思路是主要對線程池的核心參數實現自定義可配置。這三個核心參數是:
- corePoolSize : 核心線程數線程數定義了最小可以同時運行的線程數量。
- maximumPoolSize : 當隊列中存放的任務達到隊列容量的時候,當前可以同時運行的線程數量變為最大線程數。
- workQueue: 當新任務來的時候會先判斷當前運行的線程數量是否達到核心線程數,如果達到的話,信任就會被存放在隊列中。
為什么是這三個參數?
我在這篇《新手也能看懂的線程池學習總結》 中就說過這三個參數是 ThreadPoolExecutor最重要的參數,它們基本決定了線程池對于任務的處理策略。
如何支持參數動態配置? 且看 ThreadPoolExecutor 提供的下面這些方法。
格外需要注意的是corePoolSize, 程序運行期間的時候,我們調用 setCorePoolSize()這個方法的話,線程池會首先判斷當前工作線程數是否大于corePoolSize,如果大于的話就會回收工作線程。
另外,你也看到了上面并沒有動態指定隊列長度的方法,美團的方式是自定義了一個叫做 ResizableCapacityLinkedBlockIngQueue 的隊列(主要就是把LinkedBlockingQueue的 capacity 字段的 final 關鍵字修飾給去掉了,讓它變為可變的)。
最終實現的可動態修改線程池參數效果如下。👏👏👏
動態配置線程池參數最終效果