开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 5 天,点击查看活动详情
分治
分而治之。 把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。
说起来很抽象,但上面的图贴出来之后,各位,是不是突然觉得很眼熟? 经典排序算法之一的归并排序,其核心思想就是分治。 当然,快速排序,二分查找等,都是分治思想的实际应用。
以下为使用分治思想,计算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累加的计算显得更加麻烦了。 不瞒各位说,我在举例的时候特地选了会降低算法性能的分治例子,这样你才知道分治还能进一步提高效率,我是故意不小心的。
话都说到这儿了,大家也都是聪明人,我也就不藏着掖着了。 在多核的现在,并发处理已经是家常便饭了,如果分治后的子任务能由各个不同的线程并发的进行处理,那么不仅能够补足拆分任务带来的额外消耗,更能提高整体计算的速度
ForkJoinPool
ForkJoinPool和ForkJoinTask是在jdk1.7时引入的,其是一个实现了Fork/Join框架思想的并发任务框架,通过ForkJoinPool和ForkJoinTask能够轻松的实现并发的分治问题处理。
ForkJoinTask为抽象类,需要继承的任务实现三个方法:
getRawResult获取计算值setRawResult设置计算值exec用于执行核心的任务代码
常规情况下我们不直接继承ForkJoinTask类,而是通过继承其子类RecursiveTask和RecursiveAction来实现有返回值和无返回值的分治任务。
通过继承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();
}
}
}
ForkJoinPool和ForkJoinTask的优点在于:
- 并行的执行分治后的子任务
- 等待子任务计算返回时父任务阻塞,但线程不阻塞,线程将继续执行其他任务队列中的任务
- 空闲线程将进行任务的窃取,避免出现旱的旱死涝的涝死
1. 任务队列
如各位所知的那样,我们常使用的ThreadPoolExecutor其内部只维护了单个任务队列(核心构造参数之一),线程池中各个线程都通过对这单个任务队列进行任务poll来获取可执行任务。
而在ForkJoinPool中,情况却大不一样了:
ForkJoinPool为每一个线程都维护了一个任务队列,各个线程优先的执行自己任务队列下的任务。- 当线程任务阻塞时,线程并不会进行死等,而是将继续执行任务队列下的其他任务,避免浪费线程资源。
2. 任务窃取
由于ForkJoinTask的:
- 任务时长不可控
- fork任务将自动的分配到当前线程任务队列
ForkJoinPool任务提交中包含了随机hash的算法
所以任务在各个任务队列中的分布可能是不均匀的,这样可能导致部分线程的压力过大,而部分线程任务过少,导致白白浪费了部分计算资源。
因此在ForkJoinPool存在任务窃取的思想。 即空闲线程会自发的扫描仍旧存在任务的其他线程的任务队列,并且在队尾尝试进行任务的获取、执行。
Parallel Stream
Parallel Stream是在jdk1.8引入的,他将可被分治的流失处理拆分并使用ForkJoinPool执行并发处理:
- 如果执行stream操作的线程非
ForkJoinTask,将使用ForkJoinPool.commonPool来进行任务的执行,并且当前线程也为作为一个临时的任务线程来共同执行子任务。 - 如果执行stream操作的线程为
ForkJoinTask,则将自动的提交到对应的线程池中。
需要注意的是,由于parallel stream本质是分治的并行流处理,因此对于强顺序的stream操作,不应该盲目使用parallel stream