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{ 将任务拆分为两个子任务 递归调用本方法,拆分每个子任务,等待所有子任务完成 合并每个子任务的结果 }
一般来说没有确切的标准来绝对一个任务是否可以被拆分,但是有几种试探方法可以查看是否可以拆分。
以前面的求和例子为基础,我们试着用这个框架为一个数字范围long[]数组求和,首选需要为RecursiveTask类做一个实现,ForkJoinSumCalculator
public class ForkJoinSumCalculator extends java.util.concurrent.RecursiveTask<Long> { //要求和的数组 private final long[] numbers; //子任务处理的数组的开始位置。 private final int start; //子任务处理的数组的终止位置 private final int end; //不可将任务分解为子任务的数组大小 public static final long THRESHOLD = 10000; //共用构造函数用于创建主任务 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); //利用另一个ForkJoinPool线程异步执行新创建的子任务 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; } }
现在就可以通过调用构造函数来求和了:
long[] numbers = LongStream.rangeClosed(1,10000000).toArray(); ForkJoinTask<Long> task = new ForkJoinSumCalculator(numbers); System.out.println(new ForkJoinPool().invoke(task));
这里使用LongStream.rangeClosed生成了一个long 数组,然后创建了一个ForkJoinTask的父类,并把数组传递给ForkJoinSumCalculator的公共构造函数,最后创建爱你了一个新的ForkJoinPool,并把任务传给它调用方法。在ForkJoinPool中执行时,最后一个方法返回的值就是ForkJoinSumCalculator类定义的任务结果。
在实际应用时,使用多个ForkJoinPool是没有意义的,一般来说把它实例化一次,然后把实力保存在静态字段中,使之成为单例。这样就可以在任何地方方便的重用了。
运行ForkJoinSumCalculator
当把ForkJoinSumCalculator任务传给ForkJoinPool时,这个任务就由池中的一个线程执行,这个线程会调用任务的compute方法。该方法会检查任务是否小到足以顺序执行,如果不够小则会把要求和的数组分成两半,分给两个新的ForkJoinSumCalculator,而它们也由ForkJoinPool安排执行。因此,这一过程可以递归重复,把原任务分为更小的任务,直到满足不方便或不可能再进一步拆分的条件。这时会顺序计算每个任务的结果,然后由分支过程创建的任务二叉树遍历回到它的根。接下来回合并每个子任务的部分结果,从而得到总任务的结果。
System.out.println(measureSumPerf(Main::forkJoinSum,10000000)); // 79
执行速度为79毫秒,是因为必须先把整个数字流都放进一个long数组,之后才能在ForkJoinSumCalculator任务中使用它。
使用分支/合并框架的最佳做法
1.对一个任务调用join方法会阻塞调用方,直到该任务做出结果。因此,有必要在两个字任务的计算都开始后再调用它。否则,你得到的版本会比原始的顺序算法更慢更复杂,因为每个子任务都必须等待另一个子任务完成才能启动。
2.不应该在RecursiveTask内部使用ForkJoinPool的invoke方法。相反,你应该始终直接调用compute或fork方法,只有顺序代码才应该用invoke来启动并行计算。
3.对于子任务调用fork方法可以把它排进ForkJoinPool。同时对左边和右边的子任务调用它似乎很自然,但这样的效率要比直接对其中一个调用compute低。这样做你可以为其中一个子任务重用同一个线程,从而避免在线程池中多分配一个任务造成的开销。
4.对于分支/合并拆分策略你必须选择一个标准,来决定任务是要进一步拆分还是已到可以顺序求值。
工作窃取
在ForkJoinSumCalculator的例子中,设置的阀值是10000,就是在数组为10000时就不会再创建子任务了。在测试案例中,我们先有了一个1000万的数组,意味着ForkJoinSumCalculator至少会分出1000个子任务来。分出大量的小任务一般来说是一个好的选择,理想的情况下,划分并行任务时,应该让每个任务都用完全相同时间完成,让所有的CPU都同样繁忙,但在实际中,每个子任务所花费的时间可能天差地别,要么是因为划分策略效率低,要么是有不可预知的原因,比如磁盘访问慢,或是需要和外部服务协调执行。
分支/合并框架工程用一种称为工作窃取(work stealing)的技术来解决这个问题。在实际应用中,这意味着这些任务差不多被平均分配到ForkJoinPool中的所有线程上。每个线程都为分配给它的任务保存一个双向链式队列,没完成一个任务,他就会从队列头上取出下一个任务开始执行。基于前面所述的原因,某个线程可能早早完成了分配给它的任务,也就是它的队列已经空了,而其他的线程还很忙。这时,这个线程没有闲下来,而是随机选了一个别的线程,从队列的尾巴上偷走一个任务。这个过程一直继续下去,直到所有的任务都执行完毕,所有队列都清空。这就是为什么划成许多小任务而不是少数几个大任务,这样有助于更好地在工作线程之间平衡负载。
现在应该清楚 流 是如何使用 分支/合并 框架来并行处理它的项目了。本例中我们明确指定了将数组拆分成多少个任务的逻辑。但是,使用并行流时就不用这么做了,有一种机制来为你拆分流。
Spliterator
Spliterator是Java 8 中加入的另一个新接口,这个名字代表“可分迭代器”,和Iterator一样,Spliterator也用于遍历数据源中的元素,但它是为了并行执行而设计的。Java 8已经为集合框架中包含了所有数据结构提供了一个默认的Spliterator实现。 集合实现了Spliterator接口,接口提供了一个spliterator方法。这个接口定义了若干方法:
public interface Spliterator<T> { boolean tryAdvance(Consumer<? super T> action); Spliterator<T> trySplit(); long estimateSize(); int characteristics(); }
T是Spliterator遍历的元素类型,tryAdvance方法的行为类似于普通的Iterator,因为它会按顺序一个个地使用Spliterator中的元素,并且如果还有其他元素要遍历就返回true。但trySplit是专为Spliterator接口设计的,因为它可以把一些元素划出去分给第二个Spliterator,让它们两个并行处理。Spliterator还可通过estimateSize,让它们两个并行处理。Spliterator还可通过estimateSize方法估计还剩下多少元素要遍历,因为即使不那么确切,能快速算出来是一个值也有助于让拆分均匀一点。
拆分过程
将Stream拆分成多个部分的算法是一个递归过程,如图所示,第一步是对第一个Spliterator调用trySplit,生成第二个Spliterator。第二步对这两个Spliterator调用trySplit直到它返回null,表明它处理的数据结构不能再分割,第三部,这个递归拆分过程到第四步就终止了,这时所有的Spliterator在调用trySplit时都返回了null。
这个拆分过程也受Spliterator本身特性影响,而特性是通过characteristics方法声明的。
Spliterator的特性
Spliterator接口声明的最后一个抽象方法是characteristics,它返回一个int,代表Spliterator本身特性集的编码。
ORDERED : 元素有既定的顺序,因此Spliterator在遍历和划分时也遵循这一点
DISTINCT : 对于任意一对遍历过的元素x和y,x.equals(y) 返回false
SORTED : 遍历的元素按照一个预定义的顺序排序
SIZED : 该Spliterator由一个已知大小的源建立,因此estimatedSize()返回的是 准确值
NONNULL : 保证遍历的元素不会为null
IMMUTABLE: Spliterator的数据源不能修改,这意味着在遍历时不能添加、删除、修改任何元素
CONCURRENT : 该Spliterator的数据源可以被其他线程同时修改而无需同步
SUBSIZED : 该Spliterator和所有从它拆分出来的Spliterator都是SIZED
小结:
1.内部迭代让你可以并行处理一个流,而无需在代码中显示使用和协调不同的线程。
2.虽然并行处理一个流很容易,却不能保证程序在所有情况下都运行的更快。因此一定要测量,确保你并没有把程序拖的更慢。
3.像并行流那样昂对一个数据集并行执行操作可以提升性能,特别是要处理的元素数量庞大,或处理单个元素特别耗时时。
4.尽量使用原始特化流,来避免装箱和拆箱操作。
5.分支/合并框架让你得以用递归方式将可以并行的任务拆分成功效的任务,在不同的线程上执行,然后将各个子任务的结果合并起来生成整体结果。
6.Spliterator定义了并行流如何拆分它要遍历的数据。