分治、ForkJoinPool与Parallel Stream

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 5 天,点击查看活动详情

分治

分而治之。 把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。

image.png

说起来很抽象,但上面的图贴出来之后,各位,是不是突然觉得很眼熟? 经典排序算法之一的归并排序,其核心思想就是分治。 当然,快速排序,二分查找等,都是分治思想的实际应用。

以下为使用分治思想,计算n累加的一个简单demo:

public class Main {
    public static void main(String[] args) {
        System.out.println(accumulate(1, 10));
    }

    /**
     * @param n >= 1
     * @return 1 + 2 + 3 + ... + n
     */
    public static int accumulate(int n) {
        return accumulate(1, n);
    }

    /**
     * 0 < from <= to
     *
     * @param from
     * @param to
     * @return from + (from + 1) + ... + to
     */
    private static int accumulate(int from, int to) {
        // 问题拆分为足够简单时计算返回
        if (from == to) {
            return from;
        }
        // 拆分 & 合并
        int center = (from + to) / 2;
        return accumulate(from, center) + accumulate(center + 1, to);
    }
}

Fork/Join框架

分治思想很简单,但大家可能也都发现了,上面给出的demo貌似并不能体现出分治思想的优点所在。 相反的,好像问题的拆分让n累加的计算显得更加麻烦了。 不瞒各位说,我在举例的时候特地选了会降低算法性能的分治例子,这样你才知道分治还能进一步提高效率,我是故意不小心的。

image.png

话都说到这儿了,大家也都是聪明人,我也就不藏着掖着了。 在多核的现在,并发处理已经是家常便饭了,如果分治后的子任务能由各个不同的线程并发的进行处理,那么不仅能够补足拆分任务带来的额外消耗,更能提高整体计算的速度

ForkJoinPool

ForkJoinPoolForkJoinTask是在jdk1.7时引入的,其是一个实现了Fork/Join框架思想的并发任务框架,通过ForkJoinPoolForkJoinTask能够轻松的实现并发的分治问题处理。

ForkJoinTask为抽象类,需要继承的任务实现三个方法:

  1. getRawResult获取计算值
  2. setRawResult设置计算值
  3. exec用于执行核心的任务代码

常规情况下我们不直接继承ForkJoinTask类,而是通过继承其子类RecursiveTaskRecursiveAction来实现有返回值和无返回值的分治任务。

通过继承RecursiveTask类,我们如上的并发处理任务代码可以修改为:

public class Main {
    public static void main(String[] args) {
        System.out.println(accumulate(10));
    }
    
    /**
     * @param n >= 1
     * @return 1 + 2 + 3 + ... + n
     */
    public static int accumulate(int n) {
        return ForkJoinPool.commonPool().invoke(new Adder(1, n));
    }
}


class Adder extends RecursiveTask<Integer> {

    private int from;

    private int to;

    Adder(int from, int to) {
        this.from = from;
        this.to = to;
    }

    @Override
    protected Integer compute() {
        if(from == to) {
            return from;
        }else {
            int center = (from + to) / 2;
            Adder adder1 = new Adder(from, center);
            Adder adder2 = new Adder(center + 1, to);
            adder1.fork();
            adder2.fork();
            return adder1.join() + adder2.join();
        }
    }
}

ForkJoinPoolForkJoinTask的优点在于:

  1. 并行的执行分治后的子任务
  2. 等待子任务计算返回时父任务阻塞,但线程不阻塞,线程将继续执行其他任务队列中的任务
  3. 空闲线程将进行任务的窃取,避免出现旱的旱死涝的涝死

1. 任务队列

如各位所知的那样,我们常使用的ThreadPoolExecutor其内部只维护了单个任务队列(核心构造参数之一),线程池中各个线程都通过对这单个任务队列进行任务poll来获取可执行任务。 而在ForkJoinPool中,情况却大不一样了:

  1. ForkJoinPool为每一个线程都维护了一个任务队列,各个线程优先的执行自己任务队列下的任务。
  2. 当线程任务阻塞时,线程并不会进行死等,而是将继续执行任务队列下的其他任务,避免浪费线程资源。

2. 任务窃取

由于ForkJoinTask的:

  1. 任务时长不可控
  2. fork任务将自动的分配到当前线程任务队列
  3. ForkJoinPool任务提交中包含了随机hash的算法

所以任务在各个任务队列中的分布可能是不均匀的,这样可能导致部分线程的压力过大,而部分线程任务过少,导致白白浪费了部分计算资源。 因此在ForkJoinPool存在任务窃取的思想。 即空闲线程会自发的扫描仍旧存在任务的其他线程的任务队列,并且在队尾尝试进行任务的获取、执行。

Parallel Stream

Parallel Stream是在jdk1.8引入的,他将可被分治的流失处理拆分并使用ForkJoinPool执行并发处理:

  1. 如果执行stream操作的线程非ForkJoinTask,将使用ForkJoinPool.commonPool来进行任务的执行,并且当前线程也为作为一个临时的任务线程来共同执行子任务。
  2. 如果执行stream操作的线程为ForkJoinTask,则将自动的提交到对应的线程池中。

image2022-10-10_17-59-7.png

需要注意的是,由于parallel stream本质是分治的并行流处理,因此对于强顺序的stream操作,不应该盲目使用parallel stream