深入學習Java線程池

本文由 ImportNew - 一杯哈希不加鹽 翻譯自 stackify。歡迎加入翻譯小組。轉載請見要求。

原文鏈接: stackify 翻譯: ImportNew.com - 一杯哈希不加鹽
譯文鏈接: http://www.importnew.com/29212.html
[ 轉載請保留原文出處、譯者和譯文鏈接。]

線程池是多線程編程中的核心概念,簡單來說就是一組可以執行任務的空閒線程。

首先,我們瞭解一下多線程框架模型,明白爲什麼需要線程池。

線程是在一個進程中可以執行一系列指令的執行環境,或稱運行程序。多線程編程指的是用多個線程並行執行多個任務。當然,JVM 對多線程有良好的支持。

儘管這帶來了諸多優勢,首當其衝的就是程序性能提高,但多線程編程也有缺點 —— 增加了代碼複雜度、同步問題、非預期結果和增加創建線程的開銷。

在這篇文章中,我們來了解一下如何使用 Java 線程池來緩解這些問題。

爲什麼使用線程池?

創建並開啓一個線程開銷很大。如果我們每次需要執行任務時重複這個步驟,那將會是一筆巨大的性能開銷,這也是我們希望通過多線程解決的問題。

爲了更好理解創建和開啓一個線程的開銷,讓我們來看一看 JVM 在後臺做了哪些事:

  • 爲線程棧分配內存,保存每個線程方法調用的棧幀。
  • 每個棧幀包括本地變量數組、返回值、操作棧和常量池
  • 一些 JVM 支持本地方法,也將分配本地方法棧
  • 每個線程獲得一個程序計數器,標識處理器正在執行哪條指令
  • 系統創建本地線程,與 Java 線程對應
  • 和線程相關的描述符被添加到 JVM 內部數據結構
  • 線程共享堆和方法區

當然,這些步驟的具體細節取決於 JVM 和操作系統。

另外,更多的線程意味着更多工作量,系統需要調度和決定哪個線程接下來可以訪問資源。

線程池通過減少需要的線程數量並管理線程生命週期,來幫助我們緩解性能問題。

本質上,線程在我們使用前一直保存在線程池中,在執行完任務之後,線程會返回線程池等待下次使用。這種機制在執行很多小任務的系統中十分有用。

Java 線程池

Java 通過 executor 對象來實現自己的線程池模型。可以使用 executor 接口或其他線程池的實現,它們都允許細粒度的控制。

java.util.concurrent 包中有以下接口:

  • Executor —— 執行任務的簡單接口
  • ExecutorService —— 一個較複雜的接口,包含額外方法來管理任務和 executor 本身
  • ScheduledExecutorService —— 擴展自 ExecutorService,增加了執行任務的調度方法

除了這些接口,這個包中也提供了 Executors 類直接獲取實現了這些接口的 executor 實例

一般來說,一個 Java 線程池包含以下部分:

  • 工作線程的池子,負責管理線程
  • 線程工廠,負責創建新線程
  • 等待執行的任務隊列

在下面的章節,讓我們仔細看一看 Java 類和接口如何爲線程池提供支持。

Executors 類和 Executor 接口

Executors 類包含工廠方法創建不同類型的線程池,Executor 是個簡單的線程池接口,只有一個 execute() 方法。

我們通過一個例子來結合使用這兩個類(接口),首先創建一個單線程的線程池,然後用它執行一個簡單的語句:

1

2

Executor executor = Executors.newSingleThreadExecutor();

executor.execute(() -> System.out.println("Single thread pool test"));

注意語句寫成了 lambda 表達式,會被自動推斷成 Runnable 類型。

如果有工作線程可用,execute() 方法將執行語句,否則就把 Runnable 任務放進隊列,等待線程可用。

基本上,executor 代替了顯式創建和管理線程。

Executors 類裏的工廠方法可以創建很多類型的線程池:

  • newSingleThreadExecutor():包含單個線程和無界隊列的線程池,同一時間只能執行一個任務
  • newFixedThreadPool():包含固定數量線程並共享無界隊列的線程池;當所有線程處於工作狀態,有新任務提交時,任務在隊列中等待,直到一個線程變爲可用狀態
  • newCachedThreadPool():只有需要時創建新線程的線程池
  • newWorkStealingThreadPool():基於工作竊取(work-stealing)算法的線程池,後面章節詳細說明

接下來,讓我們看一下 ExecutorService 接口提供了哪些新功能

ExecutorService

創建 ExecutorService 方式之一便是通過 Excutors 類的工廠方法。

1

ExecutorService executor = Executors.newFixedThreadPool(10);

Besides the execute() method, this interface also defines a similar submit() method that can return a Future object:

除了 execute() 方法,接口也定義了相似的 submit() 方法,這個方法可以返回一個 Future 對象。

1

2

3

4

5

6

7

8

9

10

11

12

Callable<Double> callableTask = () -> {

    return employeeService.calculateBonus(employee);

};

Future<Double> future = executor.submit(callableTask);

// execute other operations

try {

    if (future.isDone()) {

        double result = future.get();

    }

} catch (InterruptedException | ExecutionException e) {

    e.printStackTrace();

}

從上面的例子可以看到,Future 接口可以返回 Callable 類型任務的結果,而且能顯示任務的執行狀態。

當沒有任務等待執行時,ExecutorService 並不會自動銷燬,所以你可以使用 shutdown()shutdownNow() 來顯式關閉它。

1

executor.shutdown();

ScheduledExecutorService

這是 ExecutorService 的一個子接口,增加了調度任務的方法。

1

ScheduledExecutorService executor = Executors.newScheduledThreadPool(10);

schedule() 方法的參數指定執行的方法、延時和 TimeUnit

1

Future<Double> future = executor.schedule(callableTask, 2, TimeUnit.MILLISECONDS);

另外,這個接口定義了其他兩個方法:

1

2

3

4

5

executor.scheduleAtFixedRate(

  () -> System.out.println("Fixed Rate Scheduled"), 2, 2000, TimeUnit.MILLISECONDS);

 

executor.scheduleWithFixedDelay(

  () -> System.out.println("Fixed Delay Scheduled"), 2, 2000, TimeUnit.MILLISECONDS);

scheduleAtFixedRate() 方法延時 2 毫秒執行任務,然後每 2 秒重複一次。相似的,scheduleWithFixedDelay() 方法延時 2 毫秒後執行第一次,然後在上一次執行完成 2 秒後再次重複執行。

在下面的章節,我們來看一下 ExecutorService 接口的兩個實現:ThreadPoolExecutorForkJoinPool。

ThreadPoolExecutor

這個線程池的實現增加了配置參數的能力。創建 ThreadPoolExecutor 對象最方便的方式就是通過 Executors 工廠方法:

1

ThreadPoolExecutor executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(10);

這種情況下,線程池按照默認值預配置了參數。線程數量由以下參數控制:

  • corePoolSizemaximumPoolSize:表示線程數量的範圍
  • keepAliveTime:決定了額外線程存活時間

我們深入瞭解一下這些參數如何使用。

當一個任務被提交時,如果執行中的線程數量小於 corePoolSize,一個新的線程被創建。如果運行的線程數量大於 corePoolSize,但小於 maximumPoolSize,並且任務隊列已滿時,依然會創建新的線程。如果多於 corePoolSize 的線程空閒時間超過 keepAliveTime,它們會被終止。

上面那個例子中,newFixedThreadPool() 方法創建的線程池,corePoolSize=maximumPoolSize=10 並且 keepAliveTime 爲 0 秒。

如果你使用 newCachedThreadPool() 方法,創建的線程池 maximumPoolSizeInteger.MAX_VALUE,並且 keepAliveTime 爲 60 秒。

1

2

ThreadPoolExecutor cachedPoolExecutor

  = (ThreadPoolExecutor) Executors.newCachedThreadPool();

The parameters can also be set through a constructor or through setter methods:

這些參數也可以通過構造函數或setter方法設置:

1

2

3

4

ThreadPoolExecutor executor = new ThreadPoolExecutor(

  4, 6, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>()

);

executor.setMaximumPoolSize(8);

ThreadPoolExecutor 的一個子類便是 ScheduledThreadPoolExecutor,它實現了 ScheduledExecutorService 接口。你可以通過 newScheduledThreadPool() 工廠方法來創建這種類型的線程池。

1

2

ScheduledThreadPoolExecutor executor

  = (ScheduledThreadPoolExecutor) Executors.newScheduledThreadPool(5);

上面語句創建了一個線程池,corePoolSize 爲 5,maximumPoolSize 無限制,keepAliveTime 爲 0 秒。

ForkJoinPool

另一個線程池的實現是 ForkJoinPool 類。它實現了 ExecutorService 接口,並且是 Java 7 中 fork/join 框架的重要組件。

fork/join 框架基於“工作竊取算法”。簡而言之,意思就是執行完任務的線程可以從其他運行中的線程“竊取”工作。

ForkJoinPool 適用於任務創建子任務的情況,或者外部客戶端創建大量小任務到線程池。

這種線程池的工作流程如下:

  • 創建 ForkJoinTask 子類
  • 根據某種條件將任務切分成子任務
  • 調用執行任務
  • 將任務結果合併
  • 實例化對象並添加到池中

創建一個 ForkJoinTask,你可以選擇 RecursiveActionRecursiveTask 這兩個子類,後者有返回值。

我們來實現一個繼承 RecursiveTask 的類,計算階乘,並把任務根據閾值劃分成子任務。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

public class FactorialTask extends RecursiveTask<BigInteger> {

    private int start = 1;

    private int n;

    private static final int THRESHOLD = 20;

 

    // standard constructors

 

    @Override

    protected BigInteger compute() {

        if ((n - start) >= THRESHOLD) {

            return ForkJoinTask.invokeAll(createSubtasks())

              .stream()

              .map(ForkJoinTask::join)

              .reduce(BigInteger.ONE, BigInteger::multiply);

        } else {

            return calculate(start, n);

        }

    }

}

這個類需要實現的主要方法就是重寫 compute() 方法,用於合併每個子任務的結果。

具體劃分任務邏輯在 createSubtasks() 方法中:

1

2

3

4

5

6

7

private Collection<FactorialTask> createSubtasks() {

    List<FactorialTask> dividedTasks = new ArrayList<>();

    int mid = (start + n) / 2;

    dividedTasks.add(new FactorialTask(start, mid));

    dividedTasks.add(new FactorialTask(mid + 1, n));

    return dividedTasks;

}

最後,calculate() 方法包含一定範圍內的乘數。

1

2

3

4

5

private BigInteger calculate(int start, int n) {

    return IntStream.rangeClosed(start, n)

      .mapToObj(BigInteger::valueOf)

      .reduce(BigInteger.ONE, BigInteger::multiply);

}

接下來,任務可以添加到線程池:

1

2

ForkJoinPool pool = ForkJoinPool.commonPool();

BigInteger result = pool.invoke(new FactorialTask(100));

ThreadPoolExecutor 與 ForkJoinPool 對比

初看上去,似乎 fork/join 框架帶來性能提升。但是這取決於你所解決問題的類型。

當選擇線程池時,非常重要的一點是牢記創建、管理線程以及線程間切換執行會帶來的開銷。

ThreadPoolExecutor 可以控制線程數量和每個線程執行的任務。這很適合你需要在不同的線程上執行少量巨大的任務。

相比較而言,ForkJoinPool 基於線程從其他線程“竊取”任務。正因如此,當任務可以分割成小任務時可以提高效率。

爲了實現工作竊取算法,fork/join 框架使用兩種隊列:

  • 包含所有任務的主要隊列
  • 每個線程的任務隊列

當線程執行完自己任務隊列中的任務,它們試圖從其他隊列獲取任務。爲了使這一過程更加高效,線程任務隊列使用雙端隊列(double ended queue)數據結構,一端與線程交互,另一端用於“竊取”任務。

來自The H Developer的圖很好的表現出了這一過程:

和這種模型相比,ThreadPoolExecutor 只使用一個主要隊列。

最後要注意的一點 ForkJoinPool 只適用於任務可以創建子任務。否則它和 ThreadPoolExecutor 沒區別,甚至開銷更大。

跟蹤線程池的執行

現在我們對 Java 線程池生態系統有了基本的瞭解,讓我們通過一個使用了線程池的應用,來看一看執行中到底發生了什麼。

通過在 FactorialTask 的構造函數和 calculate() 方法中加入日誌語句,你可以看到下面調用序列:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

13:07:33.123 [main] INFO ROOT - New FactorialTask Created

13:07:33.123 [main] INFO ROOT - New FactorialTask Created

13:07:33.123 [main] INFO ROOT - New FactorialTask Created

13:07:33.123 [main] INFO ROOT - New FactorialTask Created

13:07:33.123 [ForkJoinPool.commonPool-worker-1] INFO ROOT - New FactorialTask Created

13:07:33.123 [ForkJoinPool.commonPool-worker-1] INFO ROOT - New FactorialTask Created

13:07:33.123 [main] INFO ROOT - New FactorialTask Created

13:07:33.123 [main] INFO ROOT - New FactorialTask Created

13:07:33.123 [main] INFO ROOT - Calculate factorial from 1 to 13

13:07:33.123 [ForkJoinPool.commonPool-worker-1] INFO ROOT - New FactorialTask Created

13:07:33.123 [ForkJoinPool.commonPool-worker-2] INFO ROOT - New FactorialTask Created

13:07:33.123 [ForkJoinPool.commonPool-worker-1] INFO ROOT - New FactorialTask Created

13:07:33.123 [ForkJoinPool.commonPool-worker-2] INFO ROOT - New FactorialTask Created

13:07:33.123 [ForkJoinPool.commonPool-worker-1] INFO ROOT - Calculate factorial from 51 to 63

13:07:33.123 [ForkJoinPool.commonPool-worker-2] INFO ROOT - Calculate factorial from 76 to 88

13:07:33.123 [ForkJoinPool.commonPool-worker-3] INFO ROOT - Calculate factorial from 64 to 75

13:07:33.163 [ForkJoinPool.commonPool-worker-3] INFO ROOT - New FactorialTask Created

13:07:33.163 [main] INFO ROOT - Calculate factorial from 14 to 25

13:07:33.163 [ForkJoinPool.commonPool-worker-3] INFO ROOT - New FactorialTask Created

13:07:33.163 [ForkJoinPool.commonPool-worker-2] INFO ROOT - Calculate factorial from 89 to 100

13:07:33.163 [ForkJoinPool.commonPool-worker-3] INFO ROOT - Calculate factorial from 26 to 38

13:07:33.163 [ForkJoinPool.commonPool-worker-3] INFO ROOT - Calculate factorial from 39 to 50

你可以看到創建了很多任務,但只有 3 個工作線程 —— 所以任務通過線程池被可用線程處理。

也可以看到在放到執行池之前,主線程中對象如何被創建。

使用 Prefix 這一類可視化的日誌工具是一個很棒的方式來探索和理解運行時的線程池。

記錄線程池日誌的核心便是保證在日誌信息中方便辨識線程名字。Log4J2 通過使用佈局能夠很好完成這種工作。

使用線程池的潛在風險

儘管線程池有巨大優勢,你在使用中仍會遇到一些問題,比如:

  • 用的線程池過大或過小:如果線程池包含太多線程,會明顯的影響應用的性能;另一方面,線程池太小並不能帶來所期待的性能提升。
  • 正如其他多線程情形一樣,死鎖也會發生。舉個例子,一個任務可能等待另一個任務完成,而後者並沒有可用線程處理執行。所以說避免任務之間的依賴是個好習慣。
  • 等待執行時間很長的任務:爲了避免長時間阻塞線程,你可以指定最大等待時間,並決定過期任務是拒絕處理還是重新加入隊列。

爲了降低風險,你必須根據要處理的任務,來謹慎選擇線程池的類型和參數。對你的系統進行壓力測試也是值得的,它可以幫你獲取真實環境下的系統行爲數據。

結論

線程池有很大優勢,簡單來說就是可以將任務的執行從線程的創建和管理中分離。另外,如果使用得當,它們可以極大提高應用的性能。

如果你學會充分利用線程池,Java 生態系統好處便是其中有很多成熟穩定的線程池實現。

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