4.并行数据处理与性能
4.1并行流
Stream.parallelStream:把集合转换为并行流
并行流就是一个把内容分成多个数据块,并用不同的线程分别处理每个数据块的流。这样一来,你就可以自动把给定操作的工作负荷分配给多核处理器的所有内核,让它们都忙起来。
public static void main(String[] args) throws IOException {
long sum = getSum(10000000);
long sum2 = getSum2(10000000);
System.out.println(sum + " " + sum2);
/**
* 并行流内部使用了默认的ForkJoinPool(7.2节会进一步讲到分支/合并框架),它默认的
* 线程数量就是你的处理器数量,这个值是由Runtime.getRuntime().availableProcessors()得到的。
* 可以通过以下设置来改变线程池大小,但一般不建议这样做
* System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism","12");
*/
//使用并行流高效求和,注意要避免共享可变状态,确保并行Stream得到正确的结果
long total = LongStream.rangeClosed(1, 10000000).parallel().reduce(0L, Long::sum);
System.out.println(total);
}
//求和:传统的java代码
public static long getSum(long n){
long result = 0;
for (int i=1;i<=n;i++){
result += i;
}
return result;
}
//求和:java8流
public static long getSum2(long n){
return Stream.iterate(1L,i -> i + 1) //生成自然数无限流
.limit(n) //限制到前n个数
.reduce(0L,Long::sum); //对所有数字求和来归纳流
}
使用并行流的建议:
- 测试,检验当把顺序流变为并行流时的性能提升;
- 留意装箱.。自动装箱和拆箱操作会大大降低性能。 Java 8中有原始类型流(IntStream、LongStream、 DoubleStream)来避免这种操作,但凡有可能都应该用这些流。
- 有些操作本身在并行流上的性能就比顺序流差。特别是limit和findFirst等依赖于元素顺序的操作,它们在并行流上执行的代价非常大。
- 还要考虑流的操作流水线的总计算成本。
- 对于较小的数据量,选择并行流几乎从来都不是一个好的决定。并行处理少数几个元素的好处还抵不上并行化造成的额外开销。
- 要考虑流背后的数据结构是否易于分解。例如, ArrayList的拆分效率比LinkedList高得多,因为前者用不着遍历就可以平均拆分,而后者则必须遍历。另外,用range工厂方法创建的原始类型流也可以快速分解。可以实现Spliterator来完全掌控分解过程.
- 流自身的特点,以及流水线中的中间操作修改流的方式,都可能会改变分解过程的性能。
- 还要考虑终端操作中合并步骤的代价是大是小(例如Collector中的combiner方法) 。如果这一步代价很大,那么组合每个子流产生的部分结果所付出的代价就可能会超出通过并行流得到的性能提升。
最后,我们还要强调并行流背后使用的基础架构是Java 7中引入的分支/合并框架。并行汇总的示例证明了要想正确使用并行流,了解它的内部原理至关重要.
4.2分支/合并框架
分支/合并框架的目的是以递归方式将可以并行的任务拆分成更小的任务,然后将每个子任务的结果合并起来生成整体结果。它是ExecutorService接口的一个实现,它把子任务分配给线程池(称为ForkJoinPool)中的工作线程。
使用 RecursiveTask
要把任务提交到这个池,必须创建RecursiveTask<V>的一个子类,其中V是并行化任务 (以及所有子任务)产生的结果类型,或者如果任务不返回结果,则是RecursiveAction类型(当然它可能会更新其他非局部机构)。要定义RecursiveTask, 只需实现它唯一的抽象方法compute:
public abstract class RecursiveTask<V> extends ForkJoinTask<V> {
private static final long serialVersionUID = 5232453952276485270L;
/**
* The result of the computation.
*/
V result;
/**
* The main computation performed by this task.
* @return the result of the computation
*/
protected abstract V compute();
public final V getRawResult() {
return result;
}
protected final void setRawResult(V value) {
result = value;
}
/**
* Implements execution conventions for RecursiveTask.
*/
protected final boolean exec() {
result = compute();
return true;
}
}
这个方法同时定义了将任务拆分成子任务的逻辑,以及无法再拆分或不方便再拆分时,生成单个子任务结果的逻辑。正由于此,这个方法的实现类似于下面的伪代码:
if (任务足够小或不可分) {
顺序计算该任务
} else {
将任务分成两个子任务
递归调用本方法,拆分每个子任务,等待所有子任务完成
合并每个子任务的结果
}
package com.h.java8;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.RecursiveTask;
import java.util.stream.LongStream;
/**
* Created by John on 2018/9/30.
*/
public class ForkJoinSumCalculator extends RecursiveTask<Long>{
private final long[] numbers;
private final int start;
private final int end;
public static final long THRESHOLD = 10_000;
public ForkJoinSumCalculator(long[] numbers) {
this(numbers, 0, numbers.length);
}
private ForkJoinSumCalculator(long[] numbers, int start, int end) {
this.numbers = numbers;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
int length = end - start;
if (length <= THRESHOLD){
return computeSequentially();
}
ForkJoinSumCalculator leftTask = new ForkJoinSumCalculator(numbers,start,start + length/2);
leftTask.fork();
ForkJoinSumCalculator rightTask = new ForkJoinSumCalculator(numbers,start + length/2,end);
Long rightResult = rightTask.compute();
Long leftResult = leftTask.join();
return leftResult + rightResult;
}
private long computeSequentially(){
long sum = 0;
for (int i = start; i < end; i++) {
sum += numbers[i];
}
return sum;
}
public static long forkJoinSum(long n) {
long[] numbers = LongStream.rangeClosed(1, n).toArray();
ForkJoinTask<Long> task = new ForkJoinSumCalculator(numbers);
return new ForkJoinPool().invoke(task);
}
}
工作窃取
每个线程都为分配给它的任务保存一个双向链式队列,每完成一个任务,就会从队列头上取出下一个任务开始执行。基于前面所述的原因,某个线程可能早早完成了分配给它的所有任务,也就是它的队列已经空了,而其他的线程还很忙。这时,这个线程并没有闲下来,而是随机选了一个别的线程,从队列的尾巴上“偷走”一个任务。这个过程一直继续下去,直到所有的任务都执行完毕,所有的队列都清空。这就是为什么要划成许多小任务而不是少数几个大任务,这有助于更好地在工作线程之间平衡负载。
4.3Spliterator
并行流既然内部依赖的是分支/合并框架,那他内部是怎么拆分流的呢?这种新的自动机制称为Spliterator.Spliterator是Java 8中加入的另一个新接口;这个名字代表“可分迭代器”(splitable iterator) 。和Iterator一样, Spliterator也用于遍历数据源中的元素,但它是为了并行执行而设计的。控制拆分数据结构的策略.
public interface Spliterator<T> {
boolean tryAdvance(Consumer<? super T> action);
/**
*trySplit方法是Spliterator中最重要的一个方法,因为它定义了拆分要遍历的数据结构的逻辑。
*/
Spliterator<T> trySplit();
long estimateSize();
int characteristics();
}
Spliterator还有最后一
个值得注意的功能,就是可以在第一次遍历、第一次拆分或第一次查询估计大小时绑定元素的数据源,而不是在创建时就绑定。这种情况下,它称为延迟绑定(late-binding)的Spliterator。
小结:
- 内部迭代让你可以并行处理一个流,而无需在代码中显式使用和协调不同的线程。
- 虽然并行处理一个流很容易,却不能保证程序在所有情况下都运行得更快。并行软件的行为和性能有时是违反直觉的,因此一定要测量,确保你并没有把程序拖得更慢。
- 像并行流那样对一个数据集并行执行操作可以提升性能,特别是要处理的元素数量庞大,或处理单个元素特别耗时的时候。
- 从性能角度来看,使用正确的数据结构,如尽可能利用原始流而不是一般化的流,几乎 总是比尝试并行化某些操作更为重要。
- 分支/合并框架让你得以用递归方式将可以并行的任务拆分成更小的任务,在不同的线程 上执行,然后将各个子任务的结果合并起来生成整体结果。
- Spliterator定义了并行流如何拆分它要遍历的数据。