fork/join详解

260 阅读8分钟

在JDK中,提供了这样一种功能:它能够将复杂的逻辑拆分成一个个简单的逻辑来并行执行,待每个并行执行的逻辑执行完成后,再将各个结果进行汇总,得出最终的结果数据。

ForkJoin是由JDK1.7之后提供的多线程并发处理框架。ForkJoin框架的基本思想是分而治之。什么是分而治之?分而治之就是将一个复杂的计算,按照设定的阈值分解成多个计算,然后将各个计算结果进行汇总。相应的,ForkJoin将复杂的计算当做一个任务,而分解的多个计算则是当做一个个子任务来并行执行。

ForkJoin框架的本质是一个用于并行执行任务的框架, 能够把一个大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务的计算结果。在Java中,ForkJoin框架与ThreadPool共存,并不是要替换ThreadPool

其实,在Java 8中引入的并行流计算,内部就是采用的ForkJoinPool来实现的。例如,下面使用并行流实现打印数组的程序。

public class SumArray {
    public static void main(String[] args){
        List<Integer> numberList = Arrays.asList(1,2,3,4,5,6,7,8,9);
        numberList.parallelStream().forEach(System.out::println);
    }
}

forkjoin原理

它同ThreadPoolExecutor一样,也实现了Executor和ExecutorService接口。它使用了一个无限队列来保存需要执行的任务,而线程的数量则是通过构造函数传入,如果没有向构造函数中传入指定的线程数量,那么当前计算机可用的CPU数量会被设置为线程数量作为默认值。

ForkJoinPool主要使用分治法(Divide-and-Conquer Algorithm) 来解决问题。典型的应用比如快速排序算法。这里的要点在于,ForkJoinPool能够使用相对较少的线程来处理大量的任务。比如要对1000万个数据进行排序,那么会将这个任务分割成两个500万的排序任务和一个针对这两组500万数据的合并任务。以此类推,对于500万的数据也会做出同样的分割处理,到最后会设置一个阈值来规定当数据规模到多少时,停止这样的分割处理。比如,当元素的数量小于10时,会停止分割,转而使用插入排序对它们进行排序。那么到最后,所有的任务加起来会有大概200万+ 个。问题的关键在于,对于一个任务而言,只有当它所有的子任务完成之后,它才能够被执行。

所以当使用ThreadPoolExecutor时,使用分治法会存在问题,因为ThreadPoolExecutor中的线程无法向任务队列中再添加一个任务并在等待该任务完成之后再继续执行。而使用ForkJoinPool就能够解决这个问题,它就能够让其中的线程创建新的任务,并挂起当前的任务,此时线程就能够从队列中选择子任务执行。

工作窃取算法

假如我们需要做一个比较大的任务,我们可以把这个任务分割为若干互不依赖的子任务,为了减少线程间的竞争,于是把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应,比如A线程负责处理A队列里的任务。但是有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务等待处理。干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。

工作窃取算法的优点: 充分利用线程进行并行计算,并减少了线程间的竞争。

工作窃取算法的缺点: 在某些情况下还是存在竞争,比如双端队列里只有一个任务时。并且该算法会消耗更多的系统资源,比如创建多个线程和多个双端队列。

forkjoin的局限性

对于Fork/Join框架而言,当一个任务正在等待它使用Join操作创建的子任务结束时,执行这个任务的工作线程查找其他未被执行的任务,并开始执行这些未被执行的任务,通过这种方式,线程充分利用它们的运行时间来提高应用程序的性能。为了实现这个目标,Fork/Join框架执行的任务有一些局限性。

(1)任务只能使用Fork和Join操作来进行同步机制,如果使用了其他同步机制,则在同步操作时,工作线程就不能执行其他任务了。比如,在Fork/Join框架中,使任务进行了睡眠,那么,在睡眠期间内,正在执行这个任务的工作线程将不会执行其他任务了。 (2)在Fork/Join框架中,所拆分的任务不应该去执行IO操作,比如:读写数据文件。 (3)任务不能抛出检查异常,必须通过必要的代码来出来这些异常。

为什么在Fork/Join框架中,所拆分的任务不应该去执行IO操作?

在Fork/Join框架中,任务的划分和执行是为了充分利用多核处理器的并行性能,以提高程序的执行效率。这种框架适用于CPU密集型的任务,其中任务需要大量的计算而不涉及太多的I/O操作。

当任务执行I/O操作时,例如读取文件、网络通信等,任务的执行将会被I/O阻塞。在这种情况下,CPU会空闲等待I/O操作完成,导致无法充分利用处理器的性能,反而降低了并行计算的效率。这就是为什么在Fork/Join框架中,拆分的任务应该尽量避免执行I/O操作的原因。

对于涉及大量I/O操作的任务,通常使用异步I/O模型或线程池等技术来处理。异步I/O模型允许任务在等待I/O操作完成的同时继续执行其他计算,而线程池则可以让多个任务共享一组线程,避免了I/O阻塞对任务执行的影响。

为什么任务不能抛出检查异常,必须通过必要的代码来出来这些异常?

  1. 任务的执行在不同线程中: 在Fork/Join框架中,任务可能会在不同的工作线程中执行,而线程之间通常是无法直接捕获和处理其他线程抛出的异常的。如果一个任务在执行过程中抛出了检查异常,而该异常无法被当前线程处理,就会导致整个框架无法正常运行。
  2. 任务的执行方式: Fork/Join框架采用了"分而治之"的策略,将大任务拆分成许多小任务,然后分配给多个线程并行执行。这些小任务通常会在任务池中重用线程,如果其中一个任务抛出了异常,可能会导致线程的状态不一致,影响其他任务的执行。
  3. 任务的合并: 在Fork/Join框架中,子任务的结果通常会合并成为父任务的结果。如果子任务抛出了异常,父任务在合并结果时就无法得到正确的结果,从而破坏了任务的正确性。

forkjoin的具体实现

img

重要的类有这么几个:ForkJoinPool、ForkJoinWorkerThread、ForkJoinTask类

ForkJoinPool:实现了ForkJoin框架中的线程池,由类图可以看出,ForkJoinPool类实现了线程池的Executor接口。其中,可以使用Executors.newWorkStealPool()方法创建ForkJoinPool。

ForkJoinWorkerThread:实现ForkJoin框架中的线程。

ForkJoinTask:ForkJoinTask封装了数据及其相应的计算,并且支持细粒度的数据并行。ForkJoinTask比线程要轻量,ForkJoinPool中少量工作线程能够运行大量的ForkJoinTask。

ForkJoinTask类中主要包括两个方法fork()和join(),分别实现任务的分拆与合并。

fork()方法类似于Thread.start(),但是它并不立即执行任务,而是将任务放入工作队列中。跟Thread.join()方法不同,ForkJoinTask的join()方法并不简单的阻塞线程,而是利用工作线程运行其他任务,当一个工作线程中调用join(),它将处理其他任务,直到注意到目标子任务已经完成。

ForkJoinTask有三个实现类:

  • RecursiveAction:无返回值的任务。实现了Callable
  • RecursiveTask:有返回值的任务。
  • CountedCompleter:完成任务后将触发其他任务。在任务完成执行后会触发执行一个自定义的钩子函数。

案例

使用forkjoin实现对数组的排序,类似归并排序

import java.util.Arrays;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
​
public class ForkJoinQuickSort extends RecursiveTask<int[]> {
    private final int[] array;
    private final int left;
    private final int right;
​
    public ForkJoinQuickSort(int[] array, int left, int right) {
        this.array = array;
        this.left = left;
        this.right = right;
    }
​
    @Override
    protected int[] compute() {
        if (left < right) {
            // 使用快速排序算法将数组分区
            int pivotIndex = partition(array, left, right);
​
            // 判断左右子任务的范围是否有效,避免越界访问
            if (left < pivotIndex - 1) {
                ForkJoinQuickSort leftTask = new ForkJoinQuickSort(array, left, pivotIndex - 1);
                leftTask.fork(); // 异步执行左子任务
                leftTask.join(); // 等待左子任务完成
            }
            if (pivotIndex + 1 < right) {
                ForkJoinQuickSort rightTask = new ForkJoinQuickSort(array, pivotIndex + 1, right);
                rightTask.fork(); // 异步执行右子任务
                rightTask.join(); // 等待右子任务完成
            }
        }
        return array;
    }
​
    // 快速排序中的分区算法
    private int partition(int[] array, int left, int right) {
        int pivot = array[right]; // 选择最右边的元素作为枢纽(可以使用其他选择方式)
        int i = left - 1;
        for (int j = left; j < right; j++) {
            if (array[j] <= pivot) {
                i++;
                swap(array, i, j); // 将较小的元素放到左侧
            }
        }
        swap(array, i + 1, right); // 将枢纽元素放到正确的位置
        return i + 1; // 返回枢纽元素的索引
    }
​
    // 辅助方法:交换数组中两个元素的位置
    private void swap(int[] array, int i, int j) {
        int temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }
​
    public static void main(String[] args) {
        int[] arr = {8, 2, 10, 4, 3, 1, 9, 6, 5, 7};
        System.out.println("未排序的数组: " + Arrays.toString(arr));
​
        // 创建ForkJoinPool并执行排序任务
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        ForkJoinQuickSort task = new ForkJoinQuickSort(arr, 0, arr.length - 1);
        forkJoinPool.invoke(task);
​
        System.out.println("排序后的数组: " + Arrays.toString(arr));
    }
}

forkjoin和协程的关系

在jdk19中的虚拟线程就是两者的结合体这样的虚拟线程技术可以在单个线程中执行多个任务,并允许任务在遇到阻塞时让出CPU,避免了线程切换开销,从而提高了并发性能。

参考博客

www.cnblogs.com/binghe001/p…