欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

多线程编程学习七( Fork/Join 框架).

程序员文章站 2023-11-14 17:30:22
一、介绍 使用 java8 lambda 表达式大半年了,一直都知道底层使用的是 Fork/Join 框架,今天终于有机会来学学 Fork/Join 框架了。 Fork/Join 框架是 Java 7 提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大 ......

一、介绍

使用 java8 lambda 表达式大半年了,一直都知道底层使用的是 fork/join 框架,今天终于有机会来学学 fork/join 框架了。

fork/join 框架是 java 7 提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。

fork/join 的运行流程示意图:
多线程编程学习七( Fork/Join 框架).

比如,一个 1+2+3+...+100 的工作任务,我们可以把它 fork 成 10 个子任务,分别计算这 10 个子任务的运行结果。最后再把 10 个子任务的结果 join 起来,汇总成最后的结果。

为了减少线程间的竞争,通常把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应。但是,有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务等待处理。干完活的线程与其等着,不如去帮其它线程干活,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。线程的这种执行方式,我们称之为“工作窃取”算法。

二、设计

实现 fork/join 框架的设计,大抵需要两步:

1. 分割任务

首先我们需要创建一个 forkjoin 任务,把大任务分割成子任务,如果子任务不够小,则继续往下分,直到分割出的子任务足够小。

在 java 中我们可以使用 forkjointask 类,它提供在任务中执行 fork() 和 join() 操作的机制,通常情况下,我们只需要继承它的子类:

  • recursiveaction — 用于没有返回结果的任务
  • recursivetask — 用于有返回结果的任务

2. 任务执行并返回结果

分割的子任务分别放在双端队列里,然后启动几个线程分别从双端队列里获取任务执行。子任务执行完的结果都统一放在一个队列里,启动一个线程从队列里拿数据,然后合并这些数据。

在 java 中任务的执行需要通过 forkjoinpool 来执行。

三、示例

来一个阿里面试题:百万级 integer 数据量的一个 array 求和。

public class arraycounttask extends recursivetask<long> {
    /**
     * 阈值
     */
    private static final integer threshold = 10000;

    private integer[] array;
    private integer start;
    private integer end;

    public arraycounttask(integer[] array, integer start, integer end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @override
    protected long compute() {
        long sum = 0;
        // 最小子任务计算
        if (end - start <= threshold) {
            for (int i = start; i < end; i++) {
                sum += array[i];
            }
        } else {
            // 把大于阈值的任务继续往下拆分,有点类似递归的思维。 recursive 就是递归的意思。
            int middle = (start + end) >>> 1;
            arraycounttask leftarraycounttask = new arraycounttask(array, start, middle);
            arraycounttask rightarraycounttask = new arraycounttask(array, middle, end);
            // 执行子任务
            //leftarraycounttask.fork();
            //rightarraycounttask.fork();

            // invokeall 方法使用
            invokeall(leftarraycounttask, rightarraycounttask);

            //等待子任务执行完,并得到其结果
            long leftjoin = leftarraycounttask.join();
            long rightjoin = rightarraycounttask.join();
            // 合并子任务的结果
            sum = leftjoin + rightjoin;
        }

        return sum;
    }
}
    public static void main(string[] args) {
        // 1. 造一个 int 类型的百万级别数组
        integer[] array = new integer[150000000];
        for (int i = 0; i < array.length; i++) {
            array[i] = new random().nextint(100);
        }
        // 2.普通方式计算结果
        long start = system.currenttimemillis();
        long sum = 0;
        for (int i = 0; i < array.length; i++) {
            sum += array[i];
        }
        long end = system.currenttimemillis();
        system.out.println("普通方式计算结果:" + sum + ",耗时:" + (end - start));
        long start2 = system.currenttimemillis();
        // 3.fork/join 框架方式计算结果
        arraycounttask arraycounttask = new arraycounttask(array, 0, array.length);
        forkjoinpool forkjoinpool = new forkjoinpool();
        sum = forkjoinpool.invoke(arraycounttask);
        long end2 = system.currenttimemillis();
        system.out.println("fork/join 框架方式计算结果:" + sum + ",耗时:" + (end2 - start2));

        // 结论:
        // 1. 电脑 i5-4300m,双核四线程
        // 2. 数组量少的时候,fork/join 框架要进行线程创建/切换的操作,性能不明显。
        // 3. 数组量超过 100000000,fork/join 框架的性能才开始体现。

    }

forkjointask 与一般任务的主要区别在于它需要实现 compute 方法,在这个方法里,首先需要判断任务是否足够小,如果足够小就直接执行任务。如果不足够小,就必须分割成两个子任务,每个子任务在调用 fork 方法时,又会进入 compute 方法,看看当前子任务是否需要继续分割成子任务,如果不需要继续分割,则执行当前子任务并返回结果。使用 join 方法会等待子任务执行完并得到其结果。

在执行子任务时调用 fork 方法并不是最佳的选择,最佳的选择是 invokeall 方法。因为执行 compute() 方法的线程本身也是一个 worker 线程,当对两个子任务调用 fork() 时,这个worker 线程就会把任务分配给另外两个 worker,但是它自己却停下来等待不干活了!这样就白白浪费了 fork/join 线程池中的一个 worker 线程,导致了4个子任务至少需要7个线程才能并发执行。

比如甲把 400 分成两个 200 后,fork() 写法相当于甲把一个 200 分给乙,把另一个 200 分给丙,然后,甲成了监工,不干活,等乙和丙干完了他直接汇报工作。乙和丙在把 200 分拆成两个 100 的过程中,他俩又成了监工,这样,本来只需要 4 个工人的活,现在需要 7 个工人才能完成,其中有3个是不干活的。

 

forkjoinpool 由 forkjointask 数组和 forkjoinworkerthread 数组组成,forkjointask 数组负责将存放程序提交给 forkjoinpool 的任务,而 forkjoinworkerthread 数组负责执行这些任务,forkjoinworkerthread 体现的就是“工作窃取”算法。

  • 当我们调用 forkjointask 的 fork 方法时,程序会调用 forkjoinworkerthread 的 pushtask 方法异步地执行这个任务,然后立即返回结果。
  • 当我们调用 forkjointask 的 join 方法时,程序会阻塞当前线程并等待获取结果。

forkjoinpool 使用 submit 或 invoke 提交的区别:invoke 同步执行,调用之后需要等待任务完成,才能执行后面的代码;submit 是异步执行,只有在 future 调用 get 的时候会阻塞。

forkjoinpool 继承自 abstractexecutorservice, 不是为了替代 executorservice,而是它的补充,在某些应用场景下性能比 executorservice 更好。