Java 8 (6) Stream 流 - 并行数据处理与性能

在Java 7之前,并行处理集合非常麻烦。首先你要明确的把包含数据的数据结构分成若干子部分,然后你要把每个子部分分配一个独立的线程。然后,你需要在恰当的时候对他们进行同步来避免竞争,等待所有线程完成。最后,把这些部分结果合并起来。Java 7中引入了一个叫做 分支/合并的框架,让这些操作更稳定,更不容易出错。

并行流

  使用Stream接口可以方便的处理它的元素,可以通过对收集源调用parallelStream方法来把集合转换为并行流。并行流就是一个把内容分成多个数据块,并用不同的线程分别处理每个数据块的流。这样就可以把给定操作的工作负荷分配给多核处理器的所有内核,让它们都忙起来。

例如求和:1到10000之间的和。

    //求和
    public static long getSum(long n){
        return Stream.iterate(1L,i->i+1).limit(n).reduce(0L,Long::sum);
    }

这段代码等价于传统Java:

    //求和
    public static long getSum(long n){
        long sum = 0;
        for(long i = 1L;i<=10000;i++){
            sum += i;
        }
        return sum;
    }

将顺序流转换为并行流

  只需要对顺序流调用parallel方法即可转换为并行流:

    public static long getSum(long n){
        return Stream.iterate(1L,i->i+1).limit(n).parallel().reduce(0L,Long::sum);
    }

这段代码在内部将Stream分成了几块,因此可以对不同的块独立进行归纳操作。最后,同一个归纳操作会将各个子流的部分归纳结果合并起来,得到整个原始流结果。

对顺序流执行parallel方法并不意味着流本身有任何实际的变化,它内部就是一个布尔值,表示parallel之后进行的操作都并行执行,只需要对并行流调用sequential方法就可以变回顺序流。这两个方法可以结合起来,在需要并行的时候并行,需要串行的时候串行。

    Stream.parallel()
          .filter(...)
          .sequential()
          .map(...)
          .parallel()
          .reduce();

但是 最后一次parallel或sequential调用会影响整个流水线,上面的例子流水线会并行执行,因为最后调用的是它。

并行流内部使用了默认的ForkJoinPool,它默认的线程数量就是你的处理器数量,这个值是由Runtime.getRuntime().availableProcessors()得到的,可以通过系统属性java.util.concurrent.ForkJoinPool.common.parallelism来改变线程池的大小,例如:System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism","12")。这是一个全局属性,意味着所有的并行操作都会受影响。一般不建议修改它。

对这三个方法进行测量:

编写一个测量方法,这个方法接受一个函数和一个long参数,他会对传给方法的long应用函数10次,记录每次执行的时间(毫秒),并返回最短的一次执行时间:

    public static long measureSumPerf(Function<Long, Long> adder, long n) {
        long fastest = Long.MAX_VALUE;
        for (int i = 0; i < 10; i++) {
            long start = System.nanoTime();
            long sum = adder.apply(n);
            long duration = (System.nanoTime() - start) / 1_000_000;
            System.out.println("Result: " + sum);
            if (duration < fastest) fastest = duration;
        }
        return fastest;
    }
    //iterate
    public static long getSum(long n){
        return Stream.iterate(1L,i->i+1).limit(n).reduce(0L,Long::sum);
    }
    //iterate Parallel
    public static long getSumParallel(long n){
        return Stream.iterate(1L,i->i+1).limit(n).parallel().reduce(0L,Long::sum);
    }
    //Java Old
    public static long getSumOldJava(long n){
        long sum = 0;
        for(int i = 0;i<=n;i++){
            sum += i;
        }
        return sum;
    }
System.out.println(measureSumPerf(Main::getSum,10000000)); //105
System.out.println(measureSumPerf(Main::getSumParallel,10000000)); //147
System.out.println(measureSumPerf(Main::getSumOldJava,10000000)); //5

用传统for循环的方式是最快的,因为它更为底层,更重要的是不需要对原始类型进行任何装箱或拆箱操作。他才5毫秒即可完成。

顺序化执行结果为105毫秒,

用并行化进行测试,结果居然是最慢的 147毫秒,因为iterate生成的是装箱的对象,必须拆箱成数字才能求和,并且我们很难把iterate分成多个独立块来进行并行执行。

这意味着 并行化编程可能很复杂,如果用的不对,它甚至会让程序的整体性能更差。

LongStream.rangeClosed方法与iterate相比有两个优点:

1.LongStream.rangeClosed直接产生原始类型的long数字,没有装箱和拆箱。

2.LongStream.rangeClosed会生成数字范围,很容易拆分为独立的小块。

    //5
    public static long GetRangeClosedSum(long n){
        return LongStream.rangeClosed(1,n).reduce(0L,Long::sum);
    }

顺序化的LongStream.rangeClosed 只花费了5毫秒,他比iterate顺序化要快得多,因为他没有装箱和拆箱。再来看看并行化:

    //1
    public static long GetRangeClosedSumParallel(long n){
        return LongStream.rangeClosed(1,n).parallel().reduce(0L,Long::sum);
    }

LongStream.rangeClosed 调用parallel方法后,执行只使用了1毫秒,终于可以像上面图中一样并行了,并行化过程本身需要对流做递归划分,把每个子流的归纳操作分配到不同的线程,然后把这些操作的结果合并成一个值。

正确使用并行流
  错用并行流而产生错误的首要原因,就是使用的算法改变了某些共享状态,例如 另一种实现对n个自然数求和的方法,但这会改变一个共享累加器:

public class Accumulator {
    public long total = 0;
    public void add(long value){
        total += value;
    }
}
    public static long sideEffectSum(long n){
        Accumulator accumulator = new Accumulator();
        LongStream.rangeClosed(1,n).forEach(Accumulator::add);
        return accumulator.total;
    }

这种代码本质就是顺序的,每次访问total都会出现数据竞争。如果你尝试用同步来修复,那就完全失去并行的意义了。我们试着在forEach前加入parallel方法使其并行化:

public static long sideEffectSum(long n){
        Accumulator accumulator = new Accumulator();
        LongStream.rangeClosed(1,n).parallel().forEach(Accumulator::add);
        return accumulator.total;
    }

调用上面的测试方法:

System.out.println(measureSumPerf(Main::sideEffectSum,10000000));
Result: 10140890625203
Result: 9544849565325
Result: 6438093946815
Result: 11805543046590
Result: 6658954367405
Result: 4642751863823
Result: 5948081550315
Result: 7219270279482
Result: 7258008360508
Result: 4898539133022
1

性能无关紧要了,因为结果都是错误的,每次执行都会返回不同的结果,都离正确值差很远。这是由于多个线程在同时访问累加器,执行total+=value;foreach中调用的方法会改变多个线程共享对象的可变状态。 共享可变状态会影响并行流以及并行计算。

如何使用并行流

  1.测量,把顺序流转换成并行流很容易,但不一定性能会提升。并行流不一定总是比顺序流快,所以使用并行流时对其和顺序流进行测量。

  2.留意装箱。自动装箱和拆箱操作会大大降低性能,Java 8中又原始类型流(IntStream、LongStream、DoubleStream)来避免这些操作。

  3.有些操作本身在并行流上的性能就比顺序流差。特别是limit何findFirst等依赖于元素顺序的操作,他们在并行流上执行的代价非常大。例如,findAny会比findFrist性能好,因为它不一定要按照顺序来执行。

  4.对于小数据量,不建议使用并行流。

  5.要考虑流背后的数据结构是否易于分解。例如,ArrayList的拆分效率比LinkedList高的多,因为ArrayList用不着遍历就可以拆分,而LinkedList必须遍历。另外,用range方法创建的原始类型流也可以快速分解。

分支/合并框架

  分支/合并框架的目的是以递归的方式将可以并行的任务拆分成更小的任务,然后将每个子任务结果合并起来成为整体结果。它是ExecutorService接口的一个实现,它把子任务分配给线程池(ForkJoinPool)中的工作线程。

1.使用RecursiveTask

  要把任务提交到这个池,必须创建RecursiveTask<R>的一个子类,其中R是并行化任务(以及所有子任务)产生的结果类型,或者如果任务不返回结果,则是RecursiveAction类型(它可能会更新其他非局部机构)。要定义RecursiveTask,只需实现它唯一的抽象方法compute,这个方法同时定义了将任务拆分成子任务的逻辑,以及无法再拆分或不方便再拆分时,生成单个子任务结果的逻辑。类似以下伪代码:

        if(任务足够小或不可分) {
            顺序计算该任务
        }else{
            将任务拆分为两个子任务 
            递归调用本方法,拆分每个子任务,等待所有子任务完成 
            合并每个子任务的结果
        }

一般来说没有确切的标准来绝对一个任务是否可以被拆分,但是有几种试探方法可以查看是否可以拆分。

猜你喜欢

转载自www.cnblogs.com/baidawei/p/9370048.html