手把手教你手動建立線程池

1、爲何要手動建立線程池?

咱們之因此要手動建立線程池,是由於 JDK 自帶的工具類所建立的線程池存在必定的弊端,那究竟存在怎麼樣的弊端呢?首先來回顧一下 JDK 中線程池框架的繼承關係: java

Java線程池框架繼承結構.png

JDK 線程池框架繼承關係圖

咱們最經常使用的線程池實現類是ThreadPoolExecutor(紅框裏的那個),首先咱們來看一下它最通用的構造方法:數據庫

/**
 * 各參數含義
 * corePoolSize    : 線程池中常駐的線程數量。核心線程數,默認狀況下核心線程會一直存活,即便處於閒置狀態也不會 
 *                   受存活時間 keepAliveTime 的限制,除非將 allowCoreThreadTimeOut 設置爲 true。
 * maximumPoolSize : 線程池所能容納的最大線程數。超過這個數的線程將被阻塞。當任務隊列爲沒有設置大小的
 *                           LinkedBlockingQueue時,這個值無效。
 * keepAliveTime   : 當線程數量多於 corePoolSize 時,空閒線程的存活時長,超過這個時間就會被回收
 * unit            : keepAliveTime 的時間單位
 * workQueue       : 存放待處理任務的隊列
 * threadFactory   : 線程工廠
 * handler         : 拒絕策略,拒絕沒法接收添加的任務
 */
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) { ... ... }

使用 JDK 自帶的 Executors工具類 (圖中藍色框中的那個,這是獨立於線程池繼承關係圖的工具類,相似於 Collections 和 Arrays) 能夠直接建立如下種類的線程池:編程

  1. 線程數量固定的線程池,此方法返回 ThreadPoolExecutor緩存

    public static ExecutorService newFixedThreadPool(int nThreads) {... ...}
  2. 單線程線程池,此方法返回 ThreadPoolExecutor網絡

    public static ExecutorService newSingleThreadExecutor() {... ...}
  3. 可緩存線程的線程池,此方法返回 ThreadPoolExecutor多線程

    public static ExecutorService newCachedThreadPool() {... ...}
  4. 執行定時任務的線程池,此方法返回 ScheduledThreadPoolExecutor併發

    public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {... ...}
  5. 能夠拆分執行子任務的線程池,此方法返回 ForkJoinPool框架

    public static ExecutorService newWorkStealingPool() {... ...}

JDK 自帶工具類建立的線程池存在的問題

直接使用這些線程池雖然很方便,可是存在兩個比較大的問題:ide

  1. 有的線程池能夠無限添加任務或線程,容易致使 OOM;工具

    就拿咱們最經常使用FixedThreadPoolCachedThreadPool來講,前者的詳細建立方法以下:

    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>());
    }

    可見其任務隊列用的是LinkedBlockingQueue,且沒有指定容量,至關於無界隊列,這種狀況下就能夠添加大量的任務,甚至達到Integer.MAX_VALUE的數量級,若是任務大量堆積,可能會致使 OOM。

    CachedThreadPool的建立方法以下:

    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                    60L, TimeUnit.SECONDS,
                                    new SynchronousQueue<Runnable>());
    }

    這個雖然使用了有界隊列SynchronousQueue,可是最大線程數設置成了Integer.MAX_VALUE,這就意味着能夠建立大量的線程,也有可能致使 OOM。

  2. 還有一個問題就是這些線程池的線程都是使用 JDK 自帶的線程工廠 (ThreadFactory)建立的,線程名稱都是相似pool-1-thread-1的形式,第一個數字是線程池編號,第二個數字是線程編號,這樣很不利於系統異常時排查問題。

若是你安裝了「阿里編碼規約」的插件,在使用Executors建立線程池時會出現如下警告信息:

Alibaba Java Coding Guidelines.png

阿里編碼規約的警告信息

爲避免這些問題,咱們最好仍是手動建立線程池。

2、 如何手動建立線程池

2.1 定製線程數量

首先要說明一點,定製線程池的線程數並非多麼高深的學問,也不是說一旦線程數設定不合理,你的程序就沒法運行,而是要儘可能避免如下兩種極端條件:

  1. 線程數量過大

    這會致使過多的線程競爭稀缺的 CPU 和內存資源。CPU 核心的數量和計算能力是有限的,在分配不到 CPU 執行時間的狀況下,線程只能處於空閒狀態。而在JVM 中,線程自己也是對象,也會佔用內存,過多的空閒線程天然會浪費寶貴的內存空間。

  2. 線程數量太小

    線程池存在的意義,或者說併發編程的意義就是爲了「壓榨」計算機的運算能力,說白了就是別讓 CPU 閒着。若是線程數量比 CPU 核心數量還小的話,那麼一定有 CPU 核心將處於空閒狀態,這是極大的浪費。

因此在實際開發中咱們須要根據實際的業務場景合理設定線程池的線程數量,那又如何分析業務場景呢?咱們的業務場景大體能夠分爲如下兩大類:

  1. CPU (計算)密集型

    這種場景須要大量的 CPU 計算,好比加密、計算 hash 等,最佳線程數爲 (CPU 核心數 + 1)。好比8核 CPU,能夠把線程數設置爲 9,這樣就足夠了,由於在 CPU 密集型的場景中,每一個線程都會在比較大的負荷下工做,不多出現空閒的狀況,正好每一個線程對應一個 CPU 核心,而後不停地工做,這樣就實現了最優利用率。多出的一個線程起到了備胎的做用,在其餘線程意外中斷時頂替上去,確保 CPU 不中斷工做。其實也大可沒必要這麼死板,線程數量設置爲 CPU 核心數的 1 到 2 倍都是能夠接受的。

  2. I/O 密集型

    好比讀寫數據庫,讀寫文件或者網絡讀寫等場景。各類 I/O 設備 (好比磁盤)的速度是遠低於 CPU 執行速度的,因此在 I/O 密集型的場景下,線程大部分時間都在等待資源而非 CPU 時間片,這樣的話一個 CPU 核心就能夠應付不少線程了,也就能夠把線程數量設置大一點。線程具體數量的計算方法能夠參考 Brain Goetz 的建議:

    假設有如下變量:

    • Nthreads = 線程數量
    • Ncpu = CPU 核心數
    • Ucpu = 指望的CPU 的使用率 ,由於 CPU 可能還要執行其餘任務
    • W = 線程的平均等待資源時間
    • C = 線程平均使用 CPU 的計算時間
    • W / C = 線程等待時間與計算時間的比率

這樣爲了讓 CPU 達到指望的使用率,最優的線程數量計算公式以下:

Nthreads = Ncpu Ucpu ( 1 + W / C )

CPU 核心數能夠經過如下方法獲取:

int N_CPUS = Runtime.getRuntime().availableProcessors();

固然,除了 CPU,線程數量還會受到不少其餘因素的影響,好比內存和數據庫鏈接等,須要具體問題具體分析。

2.2 使用可自定義線程名稱的線程工廠

這個就簡單多了,能夠藉助大名鼎鼎的谷歌開源工具庫 Guava,首先引入以下依賴:

<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
  <groupId>com.google.guava</groupId>
  <artifactId>guava</artifactId>
  <version>28.2-jre</version>
</dependency>

而後我就可使用其提供的ThreadFactoryBuilder類來建立線程工廠了,Demo 以下:

public class ThreadPoolDemo {

    // 線程數
    public static final int THREAD_POOL_SIZE = 16;

    public static void main(String[] args) throws InterruptedException {
        // 使用 ThreadFactoryBuilder 建立自定義線程名稱的 ThreadFactory
        ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
                .setNameFormat("hyn-demo-pool-%d").build();
        
        // 建立線程池,其中任務隊列須要結合實際狀況設置合理的容量
        ThreadPoolExecutor executor = new ThreadPoolExecutor(THREAD_POOL_SIZE,
                THREAD_POOL_SIZE,
                0L,
                TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<>(1024),
                namedThreadFactory,
                new ThreadPoolExecutor.AbortPolicy());
        
        // 新建 1000 個任務,每一個任務是打印當前線程名稱
        for (int i = 0; i < 1000; i++) {
            executor.execute(() -> System.out.println(Thread.currentThread().getName()));
        }
        // 優雅關閉線程池
        executor.shutdown();
        executor.awaitTermination(1000L, TimeUnit.SECONDS);
        // 任務執行完畢後打印"Done"
        System.out.println("Done");
    }
}

控制檯打印結果以下:

... ...
hyn-demo-pool-2
hyn-demo-pool-6
hyn-demo-pool-13
hyn-demo-pool-12
hyn-demo-pool-15
Done

可見這樣的線程名稱相比pool-1-thread-1更有辨識度,能夠爲不一樣用途的線程池設定不一樣的名稱,便於系統出故障時排查問題。

3、總結

本文爲你們介紹了手動建立線程池的詳細方法,不過這些都是理論性的內容,而多線程編程是很是注重實踐的一門學問,在實際生產環境中要綜合考慮各類因素並不斷嘗試,才能實現最佳實踐。

相關文章
相關標籤/搜索