Java 五種線程池詳解、更加優雅的管理線程

在應用開發中,通常有這樣的需求,就是併發下載文件操作,比如百度網盤下載文件、騰訊視頻下載視頻等,都可以同時下載好幾個文件,這就是併發下載。併發下載處理肯定是多線程操作,而大量的創建線程,勢必會影響程序的性能,導致卡頓等問題。所以呢,Java 中給我們提供了線程池來管理線程。

    首先,我們來看看線程池是什麼?顧名思義,好比一個存放線程的池子,我們可以聯想水池。線程池意味着可以儲存線程,並讓池內的線程得以複用,如果池內的某一個線程執行完了,並不會直接摧毀,它有生命,可以存活一些時間,待到下一個任務來時,它會複用這個在等待中線程,避免了再去創建線程的額外開銷。

 百度對線程池的簡介:

【線程池(英語:thread pool):一種線程使用模式。線程過多會帶來調度開銷,進而影響緩存局部性和整體性能。而線程池維護着多個線程,等待着監督管理者分配可併發執行的任務。這避免了在處理短時間任務時創建與銷燬線程的代價。線程池不僅能夠保證內核的充分利用,還能防止過分調度。可用線程數量應該取決於可用的併發處理器、處理器內核、內存、網絡sockets等的數量。 例如,線程數一般取cpu數量+2比較合適,線程數過多會導致額外的線程切換開銷。】

    線程池的概念與作用就介紹完了,下面就是線程池的運用了,我們來看這樣的一個例子,模擬網絡下載的功能,開啓多任務下載操作,其中每條下載都開闢新線程來執行。

效果圖:

    可以看到就是這樣效果,這裏的每次點擊 下載 按鈕,都會開啓一個子線程來更新進度條操作。注意了:這裏我們看到的 name 就是線程的名字。可以觀察到,5個下載任務所用的線程都是不同的,所以它們的線程名都不一樣。

    也就是說,我們每個任務開闢的都是一個新的線程,假如我們下載任務量非常龐大時,那開闢的線程將不可控制,先不說性能問題,如果出現了線程安全問題或者是線程的調度,處理起來都是非常困難的。所以這種情況下,非常的有必要引入我們的線程池來管理這些線程,剛剛我們介紹了線程池的優點,現在讓我們具體的實現一下,才能體會它到底有那些優勢。

    首先,我們的線程池類型一共有 4 種,分別是 newSingleThreadPool、newFixedThreadPool、newCachedThreadPool、newScheduledThreadPool 四種,這是在 JDK1.8 版本以前了,在 JDK1.8 版本又加入了一種:newWorkStealingPool,所以現在一共是 5 種。

1、線程池的創建過程

    通過這幾種線程池的命名,我們大致可以猜測出來它的用意,當然,還是必須要實踐一下。對 線程池 的創建一般都是這樣的步驟:

    //創建單核心的線程池
    ExecutorService executorService = Executors.newSingleThreadExecutor();
    //創建固定核心數的線程池,這裏核心數 = 2
    ExecutorService executorService = Executors.newFixedThreadPool(2);
    //創建一個按照計劃規定執行的線程池,這裏核心數 = 2
    ExecutorService executorService = Executors.newScheduledThreadPool(2);
    //創建一個自動增長的線程池
    ExecutorService executorService = Executors.newCachedThreadPool();
    //創建一個具有搶佔式操作的線程池
    ExecutorService executorService = Executors.newWorkStealingPool();

    我們只需要這樣調用就可成功的創建適用於我們的線程池,不過從上面看不出上面東西來,我們要進入線程池創建的構造器,代碼如下:

/**
 * Creates a new {@code ThreadPoolExecutor} with the given initial
 * parameters and default thread factory and rejected execution handler.
 * It may be more convenient to use one of the {@link Executors} factory
 * methods instead of this general purpose constructor.
 *
 * @param corePoolSize the number of threads to keep in the pool, even
 *        if they are idle, unless {@code allowCoreThreadTimeOut} is set
 * @param maximumPoolSize the maximum number of threads to allow in the
 *        pool
 * @param keepAliveTime when the number of threads is greater than
 *        the core, this is the maximum time that excess idle threads
 *        will wait for new tasks before terminating.
 * @param unit the time unit for the {@code keepAliveTime} argument
 * @param workQueue the queue to use for holding tasks before they are
 *        executed.  This queue will hold only the {@code Runnable}
 *        tasks submitted by the {@code execute} method.
 * @throws IllegalArgumentException if one of the following holds:<br>
 *         {@code corePoolSize < 0}<br>
 *         {@code keepAliveTime < 0}<br>
 *         {@code maximumPoolSize <= 0}<br>
 *         {@code maximumPoolSize < corePoolSize}
 * @throws NullPointerException if {@code workQueue} is null
 */
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue) {
    this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
         Executors.defaultThreadFactory(), defaultHandler);
}

    當然,上面的註釋都對參數進行了介紹,我們用自己的語言進行歸納一下:

corePoolSize : 表示線程池核心線程數,當初始化線程池時,會創建核心線程進入等待狀態,即使它是空閒的,核心線程也不會被摧毀,從而降低了任務一來時要創建新線程的時間和性能開銷。
maximumPoolSize : 表示最大線程數,意味着核心線程數都被用完了,那隻能重新創建新的線程來執行任務,但是前提是不能超過最大線程數量,否則該任務只能進入阻塞隊列進行排隊等候,直到有線程空閒了,才能繼續執行任務。
keepAliveTime : 表示線程存活時間,除了核心線程外,那些被新創建出來的線程可以存活多久。意味着,這些新的線程一但完成任務,而後面都是空閒狀態時,就會在一定時間後被摧毀。
unit : 存活時間單位,沒什麼好解釋的,一看就懂。
workQueue : 表示任務的阻塞隊列,由於任務可能會有很多,而線程就那麼幾個,所以那麼還未被執行的任務就進入隊列中排隊,隊列我們知道是 FIFO 的,等到線程空閒了,就以這種方式取出任務。這個一般不需要我們去實現。
還有一個注意點就是它這裏的規定,可能會拋出這樣的異常情況。這下面寫的很明白了,就不要再介紹了:

  • @throws IllegalArgumentException if one of the following holds:<br>
  • {@code corePoolSize < 0 }
  • {@code keepAliveTime < 0 }
  • {@code maximumPoolSize <= 0 }
  • {@code maximumPoolSize < corePoolSize }
  • @throws NullPointerException if {@code workQueue} is null
        好了,以上重點幾個參數內容我們介紹完了,現在來看看幾種線程池的比較和表現吧!

2、線程池的比較

(1)newSingleThreadPool,爲單核心線程池,最大線程也只有一個,這裏的時間爲 0 意味着無限的生命,就不會被摧毀了。它的創建方式源碼如下:

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

    最形象的就是拿我們下載那個例子,爲了便於測試,我當然添加了一個 全部下載的功能, newSingleThreadPool 測試結果如下:

    由於我們的線程池中使用的從始至終都是單個線程,所以這裏的線程名字都是相同的,而且下載任務都是一個一個的來,直到有空閒線程時,纔會繼續執行任務,否則都是等待狀態。

(2)newFixedThreadPool,我們需要傳入一個固定的核心線程數,並且核心線程數等於最大線程數,而且它們的線程數存活時間都是無限的,看它的創建方式:

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

    對比 newSingleThreadPool,其實改變的也就是可以根據我們來自定義線程數的操作,比較相似。我們通過newFixedThreadPool(2)給它傳入了 2 個核心線程數,看看下載效果如何:

    顯然,它就可以做到併發的下載,我們兩個下載任務可以同時進行,並且所用的線程始終都只有兩個,因爲它的最大線程數等於核心線程數,不會再去創建新的線程了,所以這個方式也可以,但最好還是運用下面一種線程池。

(3)newCachedThreadPool,可以進行緩存的線程池,意味着它的線程數是最大的,無限的。但是核心線程數爲 0,這沒關係。這裏要考慮線程的摧毀,因爲不能夠無限的創建新的線程,所以在一定時間內要摧毀空閒的線程。看看創建的源碼:

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

    沒有核心線程數,但是我們的最大線程數沒有限制,所以一點全部開始下載,就會創建出 5 條新的線程同時執行任務,從上圖的例子看出,每天線程都不一樣。看不出這個線程池的效果,下面我們通過修改這個邏輯。

    首先,我們點開始下載,只會下載前面三個,爲了證明線程的複用效果,我這裏又添加了一個按鈕,在這個按鈕中繼續添加後面兩個下載任務。

那麼,當線程下載完畢時,空閒線程就會複用,結果顯示如下,複用線程池的空閒線程:

另一種情況,當線程池中沒有空閒線程時,這時又加了新的任務,它就會創建出新的線程來執行任務,結果如下:

    這下算是搞清楚這種線程池的作用了吧,但是由於這種線程池創建時初始化的都是***的值,一個是最大線程數,一個是任務的阻塞隊列,都沒有設置它的界限,這可能會出現問題。

這裏可以參考我的一篇文章: AsyncTask 源碼 分析,或者這個 單利模式 解讀的文章,裏面有提到如何創建自定義的線程池,參考的是 AsyncTask 的源碼線程池創建代碼。

(4)newScheduledThreadPool,這個表示的是有計劃性的線程池,就是在給定的延遲之後運行,或週期性地執行。很好理解,大家應該用過 Timer 定時器類吧,這兩個差不多的意思。它的構造函數如下:

public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE,
          DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS,
          new DelayedWorkQueue());
}

內部有一個延時的阻塞隊列來維護任務的進行,延時也就是在這裏進行的。我們把創建 newScheduledThreadPool 的代碼放出來,這樣對比效果圖的話,顯得更加直觀。

    //參數2:延時的時長
    scheduledExecutorService.schedule(th_all_1, 3000, TimeUnit.MILLISECONDS);
    scheduledExecutorService.schedule(th_all_2, 2000, TimeUnit.MILLISECONDS);
    scheduledExecutorService.schedule(th_all_3, 1000, TimeUnit.MILLISECONDS);
    scheduledExecutorService.schedule(th_all_4, 1500, TimeUnit.MILLISECONDS);
    scheduledExecutorService.schedule(th_all_5, 500, TimeUnit.MILLISECONDS);

這個線程池好像不是很常用,做個瞭解就好了。

(5)newWorkStealingPool,這個是 JDK1.8 版本加入的一種線程池,stealing 翻譯爲搶斷、竊取的意思,它實現的一個線程池和上面4種都不一樣,用的是 ForkJoinPool 類,構造函數代碼如下:

/**
 * Creates a thread pool that maintains enough threads to support
 * the given parallelism level, and may use multiple queues to
 * reduce contention. The parallelism level corresponds to the
 * maximum number of threads actively engaged in, or available to
 * engage in, task processing. The actual number of threads may
 * grow and shrink dynamically. A work-stealing pool makes no
 * guarantees about the order in which submitted tasks are
 * executed.
 *
 * @param parallelism the targeted parallelism level
 * @return the newly created thread pool
 * @throws IllegalArgumentException if {@code parallelism <= 0}
 * @since 1.8
 */
public static ExecutorService newWorkStealingPool(int parallelism) {
    return new ForkJoinPool
        (parallelism,
         ForkJoinPool.defaultForkJoinWorkerThreadFactory,
         null, true);
}

    從上面代碼的介紹,最明顯的用意就是它是一個並行的線程池,參數中傳入的是一個線程併發的數量,這裏和之前就有很明顯的區別,前面4種線程池都有核心線程數、最大線程數等等,而這就使用了一個併發線程數解決問題。從介紹中,還說明這個線程池不會保證任務的順序執行,也就是 WorkStealing 的意思,搶佔式的工作。

如下圖,任務的執行是無序的,哪個線程搶到任務,就由它執行:

    對比了以上 5 種線程池,我們看到每個線程池都有自己的特點,這也是爲我們封裝好的一些比較常用的線程池。當然,我建議你在使用(3)可緩存的線程池時,儘量的不要用默認的那個來創建,因爲默認值都是***的,可能會出現一些問題,這時我們可以參考源碼中的線程池初始化參數的設置,可以儘可能的避免錯誤發生。

    通過這個案例,我們把線程池學習了一遍,總結一下線程池在哪些地方用到,比如網絡請求、下載、I/O操作等多線程場景,我們可以引入線程池,一個對性能有提升,另一個就是可以讓管理線程變得更簡單。
www.lekaowang.com.cn/qga/ykq/hcp/

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