第 7 章
并行数据处理与性能——流内部的工作原理;
// 输出CPU的核心数
@Test
public void func_01() {
System.out.println(Runtime.getRuntime().availableProcessors());//打印CPU核心数量-6核
}
并行流和串行流的互相转换:
@Test
public void func_02() {//串行/并行流的互相转换-例如求和,比较一下性能
//1.转化的方法.parallel()和.sequential()
Stream.of(1, 2, 3).parallel().filter(n -> n < 3).sequential().forEach(System.out::println);
}
*性能小测试
(1)生成随机数组-排序
@Test
public void func_04() {
//排序
int[] arr = getArray(50000000);
TraditionSort(arr);
StreamSort(arr);
ParellelStreamSort(arr);
}
static int[] getArray(int n){//随机生成一个长度为n的乱序序列
int[] res = new int[n];
Random r = new Random();
for (int i = 0; i < n; i++) {
res[i] = (r.nextInt(10000000));
}
return res;
}
static void TraditionSort(int[] arr) {//n个乱序自然数-排序
long start = System.nanoTime();
Arrays.sort(arr);
long end = System.nanoTime();
System.out.println("传统方法排序时间:" + (end - start) / (1000000) + "ms");
}
static void StreamSort(int[] arr) {//Stream
long start = System.nanoTime();
Stream.of(arr).sorted().count();//因为流是"惰性计算"的,为了终结流得到结果
long end = System.nanoTime();
System.out.println("Stream排序时间:" + (end - start) / (1000000) + "ms");
}
static void ParellelStreamSort(int[] arr) {//parallelStream
long start = System.nanoTime();
Stream.of(arr).parallel().sorted().count();
long end = System.nanoTime();
System.out.println("parallelStream排序时间:" + (end - start) / (1000000) + "ms");
}
输出结果:
传统方法排序时间:4134ms
Stream排序时间:32ms
ParellelStream排序时间:4ms
分析:在排序上,并行流>串行>>JDK优化后的单线程传统排序;(排序每一步的计算结果不依赖之前一步的结果,只需要把每个结果"合并",有点像归并排序的思想)
(2)自然数序列前n项累加求和
@Test
public void func_05() {
//生成自然数前n项-求和
TraditionIterate(100000000);
StreamIterate(100000000);
StreamIterate_2(100000000);
ParellelStreamIterate(100000000);
}
static void TraditionIterate(long n) {//传统求前n个自然数的和
long start = System.nanoTime();
long res = 0;
for (int i = 1; i < n; i++) {
res += i;
}
long end = System.nanoTime();
System.out.println("求前n个自然数序列之和" + "\n传统方法:" + res + " 计算时间:" + (end - start) / (1000000) + "ms");
}
static void StreamIterate(long n) {//Stream求前n个自然数的和
long start = System.nanoTime();
long res = Stream.iterate(0L, i -> i + 1).limit(n).reduce(0L, (i, j) -> (i + j));
long end = System.nanoTime();
System.out.println("Stream:" + res + " 计算时间:" + (end - start) / (1000000) + "ms");
}
static void ParellelStreamIterate(long n) {//ParellelStream求前n个自然数的和
long start = System.nanoTime();
long res = Stream.iterate(0L, i -> i + 1).limit(n).parallel().reduce(0L, Long::sum);
long end = System.nanoTime();
System.out.println("Parallel-Stream:" + res + " 计算时间:" + (end - start) / (1000000) + "ms");
}
static void StreamIterate_2(long n) {//LongStream求前n个自然数的和
long start = System.nanoTime();
// long res = LongStream.iterate(0L, i -> i + 1).limit(n).reduce(0L, Long::sum);
long res = LongStream.range(1,n).reduce(0L, Long::sum);
long end = System.nanoTime();
System.out.println("Stream_02:" + res + " 计算时间:" + (end - start) / (1000000) + "ms");
}
输出:
求前n个自然数序列之和
传统方法:4999999950000000 计算时间:37ms
Stream:4999999950000000 计算时间:1135ms
Stream_02:4999999950000000 计算时间:42ms
Parallel-Stream:4999999950000000 计算时间:23587ms
*解释:for循环使用基本类型,没有装箱,而LongStream避免了装箱,因此相对快,也看出了装箱对性能的影响也是很可观的!
第二点,为什么"并行计算"不仅没有性能提升,还要慢那么多!?因为计算难以划分模块,求和需要依赖之前的计算结果!所以,parallelStream不能滥用,要小心!
针对上面的求和——引出:流的线程安全问题
eg:
采用一种新思路,不依赖每次的求和结果,定义一个累加器;
@Test
public void func_06() {//求和的另一种思路:使用求和器对象来保存上一次的计算结果(状态);
long start = System.nanoTime();
long res = 0;
Adder adder = new Adder();
LongStream.range(1,100000000).forEach(adder::add);//示例使用求和器对象来保存上一次的计算结果(状态),这样每次计算就不依赖前一次而是仅依赖这个累加器;
res = adder.total;
long end = System.nanoTime();
System.out.println("求前n个自然数序列之和" + "\n使用求和器的方法:" + res + " 计算时间:" + (end - start) / (1000000) + "ms");
//现在,使用并行流进行上面的计算,看看时间和结果,看看会发什什么;
long start2 = System.nanoTime();
long res2 = 0;
Adder adder2 = new Adder();
LongStream.range(1,100000000).parallel().forEach(adder2::add);//使用并行流;
res2 = adder2.total;
long end2 = System.nanoTime();
System.out.println("求前n个自然数序列之和" + "\n使用求和器的方法:" + res2 + " 计算时间:" + (end2 - start2) / (1000000) + "ms");
}
static class Adder{//累加器——记录当前的结果;
public long total = 0;//当前结果
public void add(long value){total+=value;}
}
输出结果:
求前n个自然数序列之和
使用求和器的方法:4999999950000000 计算时间:86ms
求前n个自然数序列之和
使用求和器的方法:512183811469674 计算时间:34ms
解释:total+=value;不是原子操作,并行时候会发生线程安全问题;再使用并行流的时候不要尝试使用"共享的可变状态";
3. 对使用并行流来提高性能的建议
这个框架是用来实现并行流的,ForkJoinPool的优势在于,可以充分利用多cpu、多核cpu的优势,把一个任务拆分成多个“小任务”,把多个“小任务”放到多个处理器核心上并行执行;当多个“小任务”执行完成之后,再将这些执行结果合并起来即可。它是ExcutorService接口的实现,把子任务分给ForkJoinPool中的工作线程。
核心方法:compute()
// 伪代码:
if(任务足够小/不可分){
顺序计算该任务
}
else {
将任务拆成子任务;
递归调用本方法:拆分每个子任务,等所有子任务执行完
合并每个子任务的计算结果;
}
是不是很像归并排序?来复习一波代码!
归并排序就是利用归并的思想实现排序。基本思想是假设初始序列有n个记录,可以将其看成n个子序列,然后两两归并,得到n/2个有序子序列,然后继续两两归并,直到最后得到一个长度为n的有序序列。这种排序方法称为2路归并排序。
eg:
// 归并排序,将数组递归划分,最小为块1个元素,然后将2个块“归并”,再将2个有2个元素的“已经有序”块再“归并”,以此类推;
private static int[] MergeSort(int[] arrTest01) {
if (arrTest01 == null) {
return null;
}
int result[] = arrTest01.clone();
int len = arrTest01.length;
//
MergingSort(result, 0, len - 1);
return result;
}
private static void MergingSort(int[] src, int start, int end) {
if (start >= end) {//注意递归返回的条件
return;
}
//划分;
int mid = (start + end) >>> 1; //分别对前后快递归调用“归并排序”
MergingSort(src, start, mid);
MergingSort(src, mid + 1, end);
//合并前后块;
Merge(src, start, end, mid);
}
private static void Merge(int[] src, int start, int end, int mid) {
int indexA = start;
int indexB = mid + 1;
int index = 0;
int[] tempArr = new int[end - start + 1];
while (indexA <= mid && indexB <= end) {
if (src[indexA] < src[indexB]) {
tempArr[index++] = src[indexA++];
} else {
tempArr[index++] = src[indexB++];
}
}
//只会执行下面while中的一个
while (indexA <= mid) {
tempArr[index++] = src[indexA++];
}
while (indexB <= end) {
tempArr[index++] = src[indexB++];
}
//将tempArr合并到原来result[]里面
index = start;
while (index <= end) {
src[index] = tempArr[index - start];
index++;
}
}
compute()方法的一个关键步骤,判断任务可划分——Spliterator接口,核心方法trySplit();(实践中,这个接口不需要我们自己用,但是可以了解一下原理)
当前的Spliterator调用trySplit()会把一些元素(任务)划出去分给一个新产生的Spliterator,让他们并行执行;
试想一种情况,尽管我们可以把任务拆分,甚至在数量少拆分的尽可能均匀,但每个任务的执行时间是不可控的,意味着两个线程即使有相同任务数,他们的结束时间不一定相同;
怎么办?——"窃取任务",即让每个线程为他的任务列表保存一个"双向链式队列",当前任务每执行一个任务,就从"头"往下去找下一个任务执行,而某个早早完成了所有任务的线程就从其他有任务的线程的"任务队列尾巴"去"偷"走一个任务执行,这样直至所有任务都被执行完;
这样做的目的——在线程之间重新分配和平衡任务负载;
在多线程并发编程中,有时候会遇到将大任务分解成小任务再并发执行的场景。Java 8新增的ForkJoinPool很好的支持了这个问题。
ForkJoinPool是一种支持任务分解的线程池,当提交给他的任务“过大”,他就会按照预先定义的规则将大任务分解成小任务,多线程并发执行。 一般要配合可分解任务接口ForkJoinTask来使用,ForkJoinTask有两个实现它的抽象类:RecursiveAction(有返回值)和RecursiveTask(无返回值)。
ForkJoinPool的优势在于,可以充分利用多cpu,多核cpu的优势,把一个任务拆分成多个“小任务”,把多个“小任务”放到多个处理器核心上并行执行;当多个“小任务”执行完成之后,再将这些执行结果合并起来即可。
使用方法:创建了ForkJoinPool实例之后,就可以调用ForkJoinPool的submit(ForkJoinTask
其中ForkJoinTask代表一个可以并行、合并的任务。ForkJoinTask是一个抽象类,它还有两个抽象子类:RecusiveAction和RecusiveTask。其中RecusiveTask代表有返回值的任务,而RecusiveAction代表没有返回值的任务。
应用场景:简单来说,如果你的问题能很容易分解成子问题,则比较适合ForkJoinPool。如适合CPU密集型的场景。
eg:一个简单的实例:计算1+2+…+100;并行计算
public class RecursiveTaskDemo extends RecursiveTask {
private int first;
private int last;
public RecursiveTaskDemo(int first, int last) {//RecursiveTask即为被ForkJoinPool执行的Task,它的compute方法会把计算划分成子任务(递归调用)
this.first = first;
this.last = last;
}
@Override
protected Integer compute() {
System.out.println(Thread.currentThread().getName() + " ... ");
/**
* 这里面要写自己的划分逻辑 构造ForkJoin
*/
int sum = 0;
// 拆分任务
if (last - first <= 2) {
// 计算
for (int i = first; i <= last; i++) {
sum += i;
}
} else {
/**
* 类似于分支递归思想,拆分成两个子任务;
*/
RecursiveTaskDemo demo01 = new RecursiveTaskDemo(first, (last + first) / 2);
RecursiveTaskDemo demo02 = new RecursiveTaskDemo((last + first) / 2 + 1, last);
// 执行
demo01.fork();//fork是分叉的意思;执行fork方法,会将当前任务放入workQueue里面;立即返回自身;
demo02.fork();
Integer a = demo01.join();//join得到上一步任务在线程池中运行后的执行结果;
Integer b = demo02.join();
sum = a + b;//合并两个分支的结果
}
return sum;
}
public static void main(String[] args) {
ForkJoinPool forkJoinPool = new ForkJoinPool(3);//3个worker的Thread来执行任务
Future future = forkJoinPool.submit(new RecursiveTaskDemo(1, 100));
System.out.println("处理其他程序...");
try {
System.out.println("计算的值为:" + future.get());//get方法阻塞,返回Future的结果
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
问题:这里的compute方法没有显示的调用自身(递归),怎么将任务不断划分呢?它的子任务会被提交给某个workQueue(双端队列),被对应的ForkJoinPool 中的某个线程按序执行;执行过程中会调用compute方法;
*原理分析:
Fork/Join Framework 的实现算法并不是那么“显然”,而是一个更加复杂的算法——这个算法的名字就叫做 work stealing (工作窃取)算法。
join()的JDK8源码分析:
(图)
fork & join
所谓work-stealing模式,即每个工作线程都会有自己的任务队列。当工作线程完成了自己所有的工作后,就会去“偷”别的工作线程的任务。
假如我们需要做一个比较大的任务,我们可以把这个任务分割为若干互不依赖的子任务,为了减少线程间的竞争,于是把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应,比如A线程负责处理A队列里的任务。但是有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务等待处理。干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。
submit方法
其实除了前面介绍过的每个工作线程自己拥有的工作队列以外,ForkJoinPool 自身也拥有工作队列,这些工作队列的作用是用来接收由外部线程(非 ForkJoinThread 线程)提交过来的任务,而这些工作队列被称为 submitting queue 。(如打印的结果里,外部线程为1)
...
ForkJoinPool-1-worker-3 ...
ForkJoinPool-1-worker-3 ...
计算的值为:5050
submit() 和 fork() 其实没有本质区别,只是提交对象变成了 submitting queue 而已(还有一些同步,初始化的操作)。submitting queue 和其他 work queue 一样,是工作线程”窃取“的对象,因此当其中的任务被一个工作线程成功窃取时,就意味着提交的任务真正开始进入执行阶段。
ForkJoinPool与ThreadPoolExecutor区别:
ForkJoinPool在JDK8中的应用:
stream中应用ForkJoinPool——parallelStream让部分Java代码自动地以并行的方式执行
Arrays.asList("a1", "a2", "b1", "c2", "c1")
.parallelStream()
.filter(s -> {
System.out.format("filter: %s [%s]\n", s, Thread.currentThread().getName());
return true;
})
.map(s -> {
System.out.format("map: %s [%s]\n", s, Thread.currentThread().getName());
return s.toUpperCase();
})
.sorted((s1, s2) -> {
System.out.format("sort: %s <> %s [%s]\n", s1, s2, Thread.currentThread().getName());
return s1.compareTo(s2);
})
.forEach(s -> System.out.format("forEach: %s [%s]\n", s, Thread.currentThread().getName()));
ForkJoinPool在多线程情况下,能够实现工作窃取(Work Stealing),在该线程池的每个线程中会维护一个队列来存放需要被执行的任务。当线程自身队列中的任务都执行完毕后,它会从别的线程中拿到未被执行的任务并帮助它执行。
ThreadPoolExecutor因为它其中的线程并不会关注每个任务之间任务量的差异。当执行任务量最小的任务的线程执行完毕后,它就会处于空闲的状态(Idle),等待任务量最大的任务执行完毕。
因此多任务在多线程中分配不均时,ForkJoinPool效率高。