Java多執行緒學習筆記(六) 長樂未央篇

北冥有隻魚發表於2022-03-28

突然發現我多執行緒系列的題目快用光了: 初遇、相識、甚歡、久處不厭、長樂無極、長樂未央。算算自己多執行緒相關的文章有:

  • 《當我們說起多執行緒與高併發時》
  • 《Java多執行緒學習筆記(一) 初遇篇》
  • 《Java多執行緒學習筆記(二) 相識篇》
  • 《Java多執行緒學習筆記(三) 甚歡篇》
  • 《Java多執行緒學習筆記(四) 久處不厭》
  • 《Java多執行緒學習筆記(五) 長樂無極》
  • 《 ThreadLocal學習筆記》

今天應該是多執行緒學習筆記的收官篇,到本篇JDK內多執行緒的基本概念,使用應該大致都過了一遍,其實仔細算算還有併發集合、並行流還沒介紹。

併發集合我著重會放在其實現上,也就是上面文章介紹的一些基本類的原理,因為併發集合和普通集合使用起來沒有多大區別,這也是下個系列的文章了,也就是原始碼系列的文章,去年十月份開了個頭《當我們說起看原始碼時,我們是在看什麼》,是時候該去填這個坑了,並行流還是放在Stream系列的文章中。這些文章目前大概都在掘金和思否,不大統一,有時間會將三個平臺文章統一以下。如果你在公眾號已經發現上面這些文章,那已經大致遷移完成了。

Fork Join模式簡介

本篇的主角是ForkJoin,作者是Doug Lea的作品,在看ForkJoinPool的時候心血來潮想看一下其他併發類的作者,然後發現以下這些不包括未發現的

  • CountDownLatch
  • CyclicBarrier
  • Semaphore
  • ThreadPoolExecutor
  • Future
  • Callable
  • ConcurrentHashMap
  • CopyOnWriteArrayList

似乎JDK裡面併發的類庫就由這個老爺子一手打造,在我翻閱這位老爺子的作品的時候,還發現自己又漏掉的類:Phaser, 所以還會有一個拾遺篇,下一篇多執行緒的系列會將Phaser的補充回來。話說回來我們接著來介紹ForkJoin。在IDEA中用ForkJoin來全域性搜尋, 結果如下:

ForkJoin

我們首先來看ForkJoinPool的繼承類圖:

ForkJoinPool繼承類圖

我們這裡可以看到ForkJoinPool和ThreadPoolExecutor在同一個級別,ThreadPoolExecutor是執行緒池,這個我們熟,所以我們可以推測一下ForkJoinPool是另一種型別的執行緒池。那這種ForkJoinPool和ThreadPoolExecutor有什麼區別呢? 帶著這個疑問,我們接著來看ForkJoinPool的註釋, 注意由於ForkJoinPool和ThreadPoolExecutor都屬於ExecutorService的子類,所以ForkJoinPool的註釋不會說ThreadPoolExecutor是其他型別的執行緒池(Thread Pool, 而是說另一種形式的ExecutorService)。

An ExecutorService for running ForkJoinTasks. A ForkJoinPool provides the entry point for submissions from non-ForkJoinTask clients, as well as management and monitoring operations.
A ForkJoinPool differs from other kinds of ExecutorService mainly by virtue of employing work-stealing: all threads in the pool attempt to find and execute tasks submitted to the pool and/or created by other active tasks (eventually blocking waiting for work if none exist). This enables efficient processing when most tasks spawn other subtasks (as do most ForkJoinTasks), as well as when many small tasks are submitted to the pool from external clients. Especially when setting asyncMode to true in constructors, ForkJoinPools may also be appropriate for use with event-style tasks that are never joined. All worker threads are initialized with Thread.isDaemon set true.

ForkJoinPool這個非同步執行服務(或譯為這個ExecutorService執行一些ForkJoinTasks)執行的是ForkJoinTask(Fork: 分叉、岔開兩條分支, Join是合併),所以ForkJoinTask可以理解為分割合併任務,也能執行一些不屬於這種型別的任務、管理監控操作。

ForkJoinPool主要不同於其他型別的ExecutorService的在於採取了工作-竊取演算法: 該執行緒池中的所有執行緒都會嘗試去尋找和執行被提交給該執行緒的任務和其他還未完成的任務(如果沒有任務,所有的執行緒將會處於阻塞等待狀態)。這種處理方式對於一些可以將大任務切割成子任務處理和和其他客戶端向該執行緒池提交小任務的任務型別來說效率會很高(也就是ForkJoin任務),ForkJoinPool也可能比較適合於事件驅動型任務,這些任務永遠不需要合併結果。所有的工作執行緒在初始化的時候會被設定為守護執行緒。

工作竊取演算法簡介

在介紹工作竊取演算法之前,我們先來回憶ThreadPoolExecutor的工作模式, 客戶端在向執行緒池提交任務的時候,執行緒池會首先判斷當前的工作執行緒是否小於核心執行緒數,如果小於核心執行緒數,會繼續向執行緒中新增工作執行緒,如果不小於核心執行緒數,則會將任務放置於任務佇列中,如果佇列已經滿了,則判斷當前的工作執行緒是否大於最大執行緒數,如果大於等於最大執行緒數,就會觸發拒絕策略。

// ThreadPoolExecutor的execute方法 
public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
        int c = ctl.get(); //ctl可以簡單的理解為執行緒的狀態量
        // workerCountOf 用於計算工作執行緒的數量
        if (workerCountOf(c) < corePoolSize) {
            // addWorker 用於的第二個引數用於指定新增的是否是核心執行緒
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        // 如果執行緒處於執行狀態且向任務佇列新增任務失敗,則嘗試新增非核心執行緒
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            if (! isRunning(recheck) && remove(command))
                reject(command);
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
        // 新增失敗則觸發拒絕策略
        else if (!addWorker(command, false))
            reject(command);
    }

在ThreadPoolExecutor中獲得任務佇列中的任務通過直接呼叫阻塞佇列的poll和take方法來獲取,但是為了避免多執行緒消費問題,阻塞佇列在獲取的時候是通過加鎖來處理的:

 public E poll(long timeout, TimeUnit unit) throws InterruptedException {
        long nanos = unit.toNanos(timeout);
         // 我們的老朋友ReentrantLock
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == 0) {
                if (nanos <= 0L)
                    return null;
                nanos = notEmpty.awaitNanos(nanos);
            }
            return dequeue();
        } finally {
            lock.unlock();
        }
    }

如果可以的話,我們希望是執行緒在從任務佇列中獲取任務的時候的等待時間儘可能的短的,那我們就需要準備多個佇列,為每個執行緒準備一個佇列,這樣一個消費者處理完自己佇列的任務的時候可以從其他工作執行緒對應的佇列“竊取”任務進行處理,這樣就不會導致工作執行緒閒置,並能減輕其他消費者執行緒的負擔。這也就是工作竊取演算法的思想。那工作竊取演算法就一點毛病都沒有?如果是這樣的話,ThreadPoolExecutor似乎在ForkJoinPool出來之後就應該早早的被打上@Deprecated。 到現在也沒有,那麼ForkJoinPool就必然有他的適應場景。應該不只是我一個人有這個疑問,我在StackOverFlow找到了我想要的答案:

  • ThreadPoolExecutor vs ForkJoinPool: stealing subtasks

Assume that I have created ThreadPoolExecutor with 10 threads and 3000 Callable tasks have been submitted. How these threads share the load of execution of sub tasks?

And How ForkJoin pool behaves differently for same use case?

假設ThreadPoolExecutor有10個執行緒,向ThreadPoolExecutor提交了3000個任務,這些執行緒將怎樣協同執行這些任務?

ForkJoinPool在同樣的情況下有什麼不同?

答案1:如果你向執行緒池提交了3000個任務,這些任務也不能再拆分成子任務,ForkJoinPool和ThreadPoolExecutor的行為沒有什麼明顯不同: 10個執行緒一次執行10個任務,直到這些任務被執行完成。ForkJoinPool的適用場景為你有一些任務,但是這些任務可以分解為一些小任務。另一個回答也是從消費任務的時候造成執行緒飢餓的現象出發的。

我這裡的體會是切割任務的粒度,當我們需要將一些計算任務並行化的時候,我們就會求助於執行緒池,轉而向執行緒池中提交任務:

執行緒池的工作圖

通常情況下我們希望任務的粒度儘可能的小,這樣我們就可以提交給執行緒池的時候就可以加大併發粒度,假設一個任務可以明顯是沒有劃分好粒度過大,而我們的執行緒池的核心執行緒是10個,提交給執行緒池的時候就只有這一個任務,那麼執行緒池的工作執行緒就只有一個,又假設這個任務又可以被拆分成五個獨立的子任務, 我們姑且命名為A、B、C、D、E、F。F的執行時間最長,那麼該任務的最終時間可能就是A+B+C+D+E+F的執行時間,那如果我們將其切割成五個獨立的子任務,那麼該任務的最終執行時間就是最長的那個任務的執行時間。這種情況是開發者可以明確的知道任務的最小粒度,向ThreadPoolExecutor執行。但是有的時候對於某些任務我們可能無法手動切割粒度,我們希望讓給一個粒度讓程式按照此粒度去分割,然後去執行, 最後合併結果,這也就是ForkJoinPool的優勢計算場景。

切割為任務為Fork:

ForkJoin的Fork

合併任務結果為Join:

ForkJoin-Join

這就是所謂的ForkJoin是也,其實我們也可以只用Fork。跟這個有點類似的就是歸併排序:

歸併排序的過程

歸併排序過程是不是有點類似於我們跟上面講的ForkJoin很是相像? 我們接下來通過例子來感受一下ForkJoin模式。

然後用起來

ForkJoinPool有四個建構函式:

1. ForkJoinPool() 
2. ForkJoinPool(int parallelism) 
3.ForkJoinPool(int parallelism,ForkJoinWorkerThreadFactory factory,UncaughtExceptionHandler handler, boolean asyncMode)
4.ForkJoinPool(int parallelism, ForkJoinWorkerThreadFactory factory, UncaughtExceptionHandler handler, int mode,  String workerNamePrefix)
5. ForkJoinPool(int parallelism, ForkJoinWorkerThreadFactory factory, UncaughtExceptionHandler handler,  boolean asyncMode,    int corePoolSize,
 int maximumPoolSize,
 int minimumRunnable,
 Predicate<? super ForkJoinPool> saturate,
 long keepAliveTime,
  TimeUnit unit) // 自JDK9 引入

1.2.3 本質上都是呼叫4.我們重點來講下4.

  • parallelism 並行粒度, 也就是設定該執行緒池的執行緒數。
使用無參構造, 為Runtime.getRuntime().availableProcessors()的返回值,此方法返回的是邏輯核心而非物理核心。我的電腦是八核十六核心,所以得到的數字為16
  • factory 執行緒工程, 在新增工作執行緒的時候呼叫此方法新增工作執行緒
  • handler 異常處理者, 工作執行緒遇到了問題該如何處理。
  • asyncMode 用於控制消費方式

if true, establishes local first-in-first-out scheduling mode for forked tasks that are never joined. This mode may be more appropriate than default locally stack-based mode in applications in which worker threads only process event-style asynchronous tasks. For default value, use false.

如果為真為Fork任務採取先進先出的排程方式,這些任何永遠不會合並。這種排程模式更適合一些基於本地棧的應用,這些工作執行緒處理的是事件驅動的非同步任務。預設為false。

  • workerNamePrefix: 用於控制工作執行緒的名稱

接下來我們來看怎麼向ForkJoinPool提交任務,我們看到了一個新的引數型別: ForkJoinTask。我們先一個例子來介紹ForkJoin.

public class FibonacciForkJoinTask extends RecursiveTask<Integer> {

    private final int n;

    public FibonacciForkJoinTask(int n) {
        this.n = n;
    }
    @Override
    protected Integer compute() {
        if (n <= 1){
            return n;
        }
        FibonacciForkJoinTask f1 = new FibonacciForkJoinTask(n - 1);
        f1.fork(); // 分解f1
        FibonacciForkJoinTask f2 = new FibonacciForkJoinTask(n - 2);
        f2.fork(); // 分解f2
        return  f1.join() + f2.join(); // 合併f1和f2的計算結果
    }
}
public class ForkJoinDemo01 {
    public static void main(String[] args) throws Exception {
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        ForkJoinTask<Integer> forkJoinTaskResult = forkJoinPool.submit(new FibonacciForkJoinTask(4));
        System.out.println(forkJoinTaskResult.get());
    }
}

ForkJoinTask及其子類概述

ForkJoinTask的子類

ForkJoinTask是提交給ForkJoinPool任務的基類,這是一個抽象類。RecursiveTask和RecursiveAction見名知義,用於遞迴切割任務粒度,區別在於RecursiveTask有返回值,RecursiveAction無返回值。RecursiveTask、RecursiveAction1.7推出,CountedCompleter有一個鉤子函式 onCompletion(CountedCompleter<?> caller),所有任務完成會觸發此方法。上面的用Fork/Join計算斐波那契數列只是為了演示用法,單執行緒跑的也很快,也有更快的演算法。並行流預設使用的也是ForkJoinPool, 推薦使用commonPool來使用ForkJoinPool.

總結一下

我們對事物的認知是一個逐步清晰的過程,本篇就當作ForkJoin的入門文章吧,還努力的掙扎了一下想看懂呼叫fork方法做了什麼,最後還是放棄了。不是所有的場景ForkJoin都能勝任,如果我們能比較好的控制任務粒度,那麼其實ForkJoinPool和ThreadPoolExecutor的執行速度沒有什麼區別,你也可以只fork不Join。

參考資料

相關文章