Java線程-Fork/Join框架學習(九)

一、前言

Fork/Join框架是Java 1.7之后引入的基于分治算法的并行框架,官網文檔是這么介紹的:

  1. Fork/Join框架是ExecutorService接口的一種具體實現,可以更好的幫助您利用多個處理器;它是為那些可以遞歸地分割成小塊的工作而設計的,該框架的目標是使用所有可用的處理能力來提高應用程序的性能。
  2. 與任何ExecutorService實現一樣,Fork/Join框架也會將任務分發給線程池中的工作線程去執行,Fork/Join框架的獨特之處在于它使用了一種工作竊取算法(work-stealing),也就是說完成自己的工作而處于空閑的工作線程能夠從其他處于忙碌(busy)狀態的工作線程處竊取等待執行的任務。
  3. Fork/Join框架的核心類是ForkJoinPool,ForkJoinPool實現了工作偷取算法,并可以執行ForkJoinTask任務,得到計算結果。

本文所使用的JDK版本是 JDK 8.0

二、Fork/Join框架介紹

1. Fork/Join簡介

??Fork/Join的并行算法的原理是分治算法,通俗的來講就是,將大任務分割成足夠小的小任務,然后讓線程池中不同的線程來執行這些分割出來的小任務,小任務完成之后再將小任務的結果合并成大任務的結果。也就是fork子任務,join返回結果。典型的用法如下:

Result solve(Problem problem) {
    if (problem is small) {
        directly solve problem
    } else {
        split problem into independent parts
        fork new subtasks to solve each part
        join all subtasks
        compose result from subresults
    }
}
2. Fork/Join框架中的類介紹

Fork/Join框架的幾個核心的類如下:

最核心的類是ForkJoinPool,該類接受的任務對象是ForkJoinTask,ForkJoinTask是一個抽象類,它有兩個常用的子類:RecursiveTask(有返回值)和RecursiveAction(無返回值),一般情況下,我們不需要直接使用ForkJoinTask,而是通過繼承它的兩個子類,并實現對應的抽象方法 —— compute來定義我們的任務。

ForkJoinTask類相關.png
ForkJoinPool類相關.png
3. ForkJoinPool類

我們先來看下ForkJoinPool這個類,來看下這個類的構造方法及常用的方法。

3.1 ForkJoinPool構造方法

ForkJoinPool的構造方法共有3個,但最終都會調用同一個構造方法。

public ForkJoinPool() {
    this(Math.min(MAX_CAP, Runtime.getRuntime().availableProcessors()),
         defaultForkJoinWorkerThreadFactory, null, false);
}

public ForkJoinPool(int parallelism) {
    this(parallelism, defaultForkJoinWorkerThreadFactory, null, false);
}

public ForkJoinPool(int parallelism,
                    ForkJoinWorkerThreadFactory factory,
                    UncaughtExceptionHandler handler,
                    boolean asyncMode) {
    this(checkParallelism(parallelism),
         checkFactory(factory),
         handler,
         asyncMode ? FIFO_QUEUE : LIFO_QUEUE,
         "ForkJoinPool-" + nextPoolId() + "-worker-");
    checkPermission();
}

我們來看一下構造方法中涉及到的參數:

  1. parallelism,并行度,也可以說是工作線程數量,默認是系統可用處理器的數量,也就是邏輯CPU的個數,最小是1;
  2. ForkJoinWorkerThreadFactory,創建工作線程的工廠,工作線程的對象是ForkJoinWorkerThread
  3. UncaughtExceptionHandler,處理工作線程發生異常的異常處理類,默認是null;
  4. asyncMode,同步或異步模式,如果是true的話,那么在處理任務時工作線程的模式為FIFO 順序,這種模式下的ForkJoinPool更像是一個隊列的形式,并且任務不能被合并,默認是false;
3.2 ForkJoinPool公共池

??在很多情況下,如果沒有特殊的應用需求,我們一般可以直接使用ForkJoinPool中的common池。ForkJoinPool提供了一種公共池,可以用來處理那些沒有被顯式提交到任何線程池的任務,并且可以通過指定系統參數的方式定義“并行度、線程工廠和異常處理類”,并且它指定了mode模式為LIFO_QUEUE,也就是說可以支持任務合并(join),來看一下它的主要代碼:

private static ForkJoinPool makeCommonPool() {
    int parallelism = -1;
    ForkJoinWorkerThreadFactory factory = null;
    UncaughtExceptionHandler handler = null;
    try {  // ignore exceptions in accessing/parsing properties
        String pp = System.getProperty
            ("java.util.concurrent.ForkJoinPool.common.parallelism");
        String fp = System.getProperty
            ("java.util.concurrent.ForkJoinPool.common.threadFactory");
        String hp = System.getProperty
            ("java.util.concurrent.ForkJoinPool.common.exceptionHandler");
        if (pp != null)
            parallelism = Integer.parseInt(pp);
        if (fp != null)
            factory = ((ForkJoinWorkerThreadFactory)ClassLoader.
                       getSystemClassLoader().loadClass(fp).newInstance());
        if (hp != null)
            handler = ((UncaughtExceptionHandler)ClassLoader.
                       getSystemClassLoader().loadClass(hp).newInstance());
    } catch (Exception ignore) {
    }
    if (factory == null) {
        if (System.getSecurityManager() == null)
            factory = defaultForkJoinWorkerThreadFactory;
        else // use security-managed default
            factory = new InnocuousForkJoinWorkerThreadFactory();
    }
    if (parallelism < 0 && // default 1 less than #cores
        (parallelism = Runtime.getRuntime().availableProcessors() - 1) <= 0)
        parallelism = 1;
    if (parallelism > MAX_CAP)
        parallelism = MAX_CAP;
    return new ForkJoinPool(parallelism, factory, handler, LIFO_QUEUE,
                            "ForkJoinPool.commonPool-worker-");
}

首先,支持我們定義系統參數parallelismthreadFactoryexceptionHandler,其次,該方法最后也調用了ForkJoinPool的構造方法,并且指定了mode模式為LIFO_QUEUE

當然,ForkJoinPool提供了commonPool方法可以直接獲取公共池:

public static ForkJoinPool commonPool() {
    // assert common != null : "static init error";
    return common;
}
3.3 執行ForkJoinTask

使用ForkJoinPool ,我們有三個方法來執行ForkJoinTask任務:invoke方法submit方法execute方法

public <T> T invoke(ForkJoinTask<T> task)
public <T> ForkJoinTask<T> submit(ForkJoinTask<T> task)
public void execute(ForkJoinTask<?> task)
  1. invoke方法,用來執行有返回值的任務,并且該方法是阻塞的,直到任務執行完畢,該方法才會停止阻塞并返回任務的執行結果;
  2. execute方法,用來執行沒有返回值的任務,該方法同樣是阻塞的,并且除了從Executor接口中繼承的execute方法外,ForkJoinPool 也定義了用來執行ForkJoinTask 的 execute方法;
  3. submit方法,用來執行有返回值的任務,該方法是非阻塞的,調用之后將任務提交給 ForkJoinPool 去執行便立即返回,返回已經提交到ForkJoinPool去執行的task,同樣該方法除了從ExecutorService 接口繼承的submit方法外,也重載了用來執行ForkJoinTask的方法;
4. 工作竊取算法

我們這里來簡單介紹下work-stealing算法的基本調度策略:

  1. 線程池中的每一個工作線程維護自己的調度隊列中的可運行任務;
  2. 隊列是一個雙端隊列,既支持后進先出(LIFO的push和pop操作),還支持先進先出 (FIFO的take操作);
  3. 對于一個給定的工作線程來說,任務所產生的子任務將會被放入到工作者自己的雙端隊列中;
  4. 工作線程使用后進先出 (LIFO,最新的元素優先) 的順序,通過彈出任務來處理隊列中的任務;
  5. 當一個工作線程的本地沒有任務去運行的時候,它將使用先進先出(FIFO)的規則嘗試隨機的從別的工作線程中拿(『竊取』)一個任務去運行;
  6. 當一個工作線程觸及了join操作時,如果需要join的任務尚未完成,那會先處理其他任務,直到目標任務被告知已經結束(通過isDone方法);
  7. 當一個工作線程無任務可執行,并且無任務可竊取或者這中間發生了異常,獲取任務和失敗處理的時候,它就會退出(通過yield、sleep或者優先級調整)并經過一段時間之后再度嘗試直到所有的工作線程都被告知他們都處于空閑的狀態。在這種情況下,他們都會阻塞直到其他的任務再度被上層調用;

??使用后進先出 (LIFO) 用來處理每個工作線程的自己任務,但是使用先進先出 (FIFO) 規則用于獲取別的任務,這是一種被廣泛使用的進行遞歸Fork/Join設計的一種調優手段。讓竊取任務的線程從隊列擁有者相反的方向進行操作會減少線程競爭,同樣體現了遞歸分治算法的大任務優先策略。

5. ForkJoinTask類

??我們再來簡單說下ForkJoinTask類。前面我們也說過,該抽象類繼承自ForkJoinTask接口,所以它可以有返回值。Fork/Join框架最主要的兩個流程就是fork流程和join流程,所以我們主要來看下 ForkJoinTask 的fork方法join方法

5.1 fork方法

fork方法用于將大任務拆分為小任務,然后執行小任務,來簡單看下代碼:

public final ForkJoinTask<V> fork() {
    Thread t;
    if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
        ((ForkJoinWorkerThread)t).workQueue.push(this);
    else
        ForkJoinPool.common.externalPush(this);
    return this;
}
  • 首先取到當前線程,然后判斷線程類型是否是ForkJoinPool中的工作線程;
  • 如果是,說明是fork分割的子任務,然后將任務添加到這個線程對應的任務隊列中,等待被執行;
  • 如果當前線程不是ForkJoinWorkerThread類型的線程,那么就會將該任務提交到公共池的隨機的隊列中去;
5.2 join方法

join方法會獲取所有子任務的執行結果,然后遞歸的合并結果:

public final V join() {
    int s;
    if ((s = doJoin() & DONE_MASK) != NORMAL)
        reportException(s);
    return getRawResult();
}

這里源碼就部多說了,有點復雜,只簡單說下大致的流程:

  1. 獲取當前線程,然后判斷線程類型是否是ForkJoinPool中的工作線程;
  2. 如果不是,阻塞當前線程(awaitJoin),等待任務執行完成;
  3. 如果是,檢查任務的執行狀態,如果任務已經完成直接返回結果;如果沒有完成,并且在自己的任務隊列內,則執行該任務;
  4. 而如果任務被其他工作線程竊取,則竊取這個偷取者隊列內的任務(FIFO),然后幫助這個竊取者執行它的任務。基本思想是:偷取者幫助我執行任務,我去幫助偷取者執行它的任務;
  5. 在幫助偷取者執行任務后,如果調用者發現自己隊列已經有任務,則依次彈出自己的任務(LIFO)并執行;
  6. 如果竊取者已經把自己的任務做完,正在等待著需要join的任務時,則找到偷取者的偷取者,幫助它完成它的任務;
  7. 循環執行上面的操作;

子任務執行完的結果會統一放在一個隊列里,然后啟動一個線程從隊列里拿數據,最后合并這些數據。
至于源碼的解讀,可詳細參考地址:JUC源碼分析-線程池篇(五):ForkJoinPool - 2,博主分析的特別詳細。

6. 代碼示例

下面通過兩個簡單的例子來看一下ForkJoinPool和ForkJoinTask的使用。

6.1 RecursiveAction無返回值

第一個例子來看一下RecursiveAction的使用,無返回值,打印一些隨機數:

package task;

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveAction;
import java.util.concurrent.TimeUnit;

/**
 * 打印數值,無返回值
 */
class RecursiveActionTest extends RecursiveAction {
    /**
     * 閾值,每個"小任務"最多只打印5個數
     */
    private static final int MAX = 5;

    private int start;
    private int end;

    private RecursiveActionTest(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    protected void compute() {
        // 當end-start的值小于MAX時候,開始打印
        if ((end - start) < MAX) {
            for (int i = start; i < end; i++) {
                System.out.println(Thread.currentThread().getName() + "的i值:" + i);
            }
        } else {

            // 將大任務分解成兩個小任務
            int middle = (start + end) / 2;
            RecursiveActionTest left = new RecursiveActionTest(start, middle);
            RecursiveActionTest right = new RecursiveActionTest(middle, end);

            // 并行執行兩個小任務
            left.fork();
            right.fork();
        }
    }

    public static void main(String[] args) throws Exception {
        // 默認線程數 邏輯CPU的個數
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        // 提交可分解的PrintTask任務
        forkJoinPool.submit(new RecursiveActionTest(0, 20));
        // 阻塞當前線程直到 ForkJoinPool 中所有的任務都執行結束
        forkJoinPool.awaitTermination(2, TimeUnit.SECONDS);
        // 關閉線程池
        forkJoinPool.shutdown();
    }
}

打印結果:

ForkJoinPool-1-worker-2的i值:5
ForkJoinPool-1-worker-3的i值:13
ForkJoinPool-1-worker-1的i值:0
ForkJoinPool-1-worker-0的i值:3
ForkJoinPool-1-worker-1的i值:1
ForkJoinPool-1-worker-3的i值:14
ForkJoinPool-1-worker-2的i值:6

這里只列舉了部分值,可以看到,ForkJoinPool啟動了4個線程來執行這個任務,因為我電腦的邏輯CPU個數是4個,并且可以看到分解后的任務是并行執行的,并不是順序執行的。

6.1 RecursiveTask有返回值

下面來看一下RecursiveTask,計算一個整數數組的和:

package task;

import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;

/**
 * 計算一個大的整數數組的和
 *
 * @author zhangkeke
 * @since 2018/9/27 15:09
 */
public class RecursiveTaskTest extends RecursiveTask<Integer> {
    /**
     * 閾值,每個小任務處理的數量
     */
    private static final int THRESHOLD = 5;
    private int[] array;
    private int low;
    private int high;

    private RecursiveTaskTest(int[] array, int low, int high) {
        this.array = array;
        this.low = low;
        this.high = high;
    }

    @Override
    protected Integer compute() {
        int sum = 0;
        if (high - low <= THRESHOLD) {
            // 小于閾值則直接計算
            for (int i = low; i < high; i++) {
                sum += array[i];
            }
        } else {
            // 一個大任務分割成兩個子任務
            int mid = (low + high) >>> 1;
            RecursiveTaskTest left = new RecursiveTaskTest(array, low, mid);
            RecursiveTaskTest right = new RecursiveTaskTest(array, mid + 1, high);

            // 異步執行
            left.fork();
            right.fork();
            // 以上兩行也可以使用 invokeAll(left,right);
            // invokeAll方法會執行很多任務,并且會阻塞,直到這些任務都執行完成

            // 結果合并
            sum = left.join() + right.join();
        }
        return sum;
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        int[] array = new int[20];
        for (int i = 0; i < array.length; i++) {
            array[i] = new Random().nextInt(100);
        }
        System.out.println(Arrays.toString(array));

        // 開始創建任務
        RecursiveTaskTest sumTask = new RecursiveTaskTest(array, 0, array.length - 1);
        // 創建ForkJoinPool線程池
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        // 提交任務到線程池
        forkJoinPool.submit(sumTask);
        // 獲取結果,get方法會阻塞
        Integer result = sumTask.get();
        System.out.println("計算結果:" + result);
    }
}

計算結果為:

[38, 98, 45, 49, 76, 74, 43, 18, 11, 73, 56, 0, 91, 7, 80, 2, 85, 88, 96, 87]
計算結果:801

該部分代碼來自:https://blog.csdn.net/ouyang_peng/article/details/46491217

6.3 fork方法問題

在網上看到有人說在進行計算的時候,fork方法有點問題,代碼是:

left.fork();
right.fork();

應該使用invokeAll的方式,這樣可以避免線程的浪費,實現線程的重用:

invokeAll(left, right);

記不清這是哪里的問題了,有時間再研究下這塊。

三、總結

??到這里基本上就大致介紹完了Fork/Join框架的流程了,不過我們需要注意下該框架的適用場景,其實也就是分治算法的適用場景,畢竟Fork/Join框架也可以看作是分治算法的并發版本了。

  1. Fork/Join框架的適用場景,簡單來說就是密集型計算,也就是我們的任務可以拆分成足夠小的任務,并且可用根據小任務的結果來組裝大任務的結果;比如求一個大數組中的最大值/最小值,求和,排序等類似操作。
  2. 需要注意的是,選取劃分子任務的粒度(分割任務的閾值,也就是臨界值)對ForkJoinPool執行任務的效率有很大影響,使用Fork/Join框架并不一定比順序執行任務的效率高。
    • 如果閾值選取過大,任務分割的不夠細,則不能充分利用CPU資源;
    • 閾值太小,則可能會產生過多的子任務,那么子任務的調度開銷可能會大于并行計算的性能開銷,并且我們還需要考慮創建子任務、fork()子任務、線程調度以及合并子任務處理結果的耗時以及相應的內存消耗;
  3. 官方文檔給出的粗略經驗是:任務應該執行100~10000個基本的計算步驟。決定子任務的粒度的最好辦法是實踐,通過實際測試結果來確定這個閾值才是最明智的做法。

再簡單說下 ForkJoinPoolThreadPoolExecutor 的區別:

  1. ForkJoinPool 和 ThreadPoolExecutor 都是 ExecutorService(線程池)的實現,但ForkJoinPool 的獨特點在于:ThreadPoolExecutor 只能執行 Runnable 和 Callable 任務,而 ForkJoinPool 不僅可以執行 Runnable 和 Callable 任務,還可以執行 Fork/Join 型任務ForkJoinTask,從而滿足并行地實現分治算法的需要;
  2. ThreadPoolExecutor 中任務的執行順序是按照其在共享隊列中的順序來執行的,所以后面的任務需要等待前面任務執行完畢后才能執行,而 ForkJoinPool 每個線程有自己的任務隊列,并在此基礎上實現了 Work-Stealing 的功能,使得在某些情況下 ForkJoinPool 能更大程度的提高并發效率。

除了JDK的文檔,本文還參考自:
《Java并發編程實戰》
并發編程網 - Fork and Join: Java也可以輕松地編寫并發程序
并發編程網 - 聊聊并發(八)——Fork/Join框架介紹
Java 多線程(5):Fork/Join 型線程池與 Work-Stealing 算法

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。