原文:When to Use a Parallel Stream in Java
Streams in Java
顺序流 Sequential Stream
默认,Java中的任何流操作都是顺序执行的,除非特别指定为并行。
顺序流是单线程执行的:
List<Integer> listOfNumbers = Arrays.asList(1,2,3,4);
listOfNumbers.stream().forEach(number ->
System.out.println(number + " " + Thread.currentThread().getName()));
执行结果:
1 main
2 main
3 main
4 main
并行流 Parallel Stream
Java中任何流都可以从顺序流转为并行流。通过添加parallel
方法到一个顺序流或者调用parallelStream
方法直接创建一个并行流。
List<Integer> listOfNumbers = Arrays.asList(1,2,3,4);
listOfNumbers.parallelStream().forEach(number ->
System.out.println(number + " " + Thread.currentThread().getName()));
执行结果:
3 main
4 ForkJoinPool.commonPool-worker-19
1 ForkJoinPool.commonPool-worker-23
2 ForkJoinPool.commonPool-worker-5
Fork-Join Framework
并行流使用的是fork-join框架和公共的工作者线程池。
fork-join
框架是Java7添加到java.util.concurrent
中的,用于处理多线程间的任务管理。
Splitting Source
fork-join
框架负责在工作着线程间分割数据源并且处理任务完成后的 回调。
List<Integer> listOfNumbers = Arrays.asList(1,2,3,4);
int sum = listOfNumbers.parallelStream().reduce(5,Integer::sum);
如果是顺序流,sum = 1+2+3+4+5 = 15。
但实际上reduce操作是并行执行的,每个工作者线程都执行了+5的操作:
所以sum的真正结果与公共fork-join线程池的工作者线程的数量相关。修复改问题,可以将+5的操作放到并行流的外面。
int sum = listOfNumbers.parallelStream().reduce(0,Integer::sum)+5;
公共线程池
公共线程池的线程数等于处理器核心数。
也可以通过JVM参数指定线程数:
-D java.util.concurrent.ForkJoinPool.common.parallelism=4
这个全局设置非常重要,它会 影响所有的并行流和任何其他使用公共线程池的fork-join。强烈建议不要修改该参数,除非我们有一个非常好的理由必须这么做。
自定义线程池
并行流除了默认运行在公共线程池,也可以运行在自定义线程池:
List<Integer> listOfNumbers = Arrays.asList(1,2,3,4);
ForkJoinPool customThreadPool = new ForkJoinPool(4);
int sum = customThreadPool.submit(() ->
listOfNumbers.parallelStream().reduce(0,Integer::sum)).get();
customThreadPool.shutdown();
注意:Oracle还是建议使用公共线程池。除非有一个非常好的理由,否则不要使用自定义线程池。
性能影响
开销
可以看出有时管理线程、资源和结果的开销比真正的操作还要大。
分割成本
均匀的分割数据源对于并行操作来说是一个必要的操作,但有些数据源分割会更好些。
arrays可以快速、均匀的分割,但了LinkedList没有这些特性。
TreeMap和HashSet比LinkedList更好分割,但也比不上arrays。
合并成本
合并操作对于某些操作非常廉价,比如reduction、addition等,但像grouping to sets 或 maps的合并操作会相当昂贵。
Memory Locality 内存局部性
private static final int[] intArray = new int[1_000_000];
private static final Integer[] integerArray = new Integer[1_000_000];
static {
IntStream.rangeClosed(1, 1_000_000).forEach(i -> {
intArray[i-1] = i;
integerArray[i-1] = i;
});
}
Arrays.stream(intArray).reduce(0, Integer::sum);
Arrays.stream(intArray).parallel().reduce(0, Integer::sum);
Arrays.stream(integerArray).reduce(0, Integer::sum);
Arrays.stream(integerArray).parallel().reduce(0, Integer::sum);
数据结构中指针越多,获取对应对象的内存压力越大。这个对并行都有负面影响,因为多核会同时从内存中获取对象。
NQ模型
Oracle提供了一个简单的模型,可以帮助我们决定并行是否能提告性能。
NQ模型:
- N:源数据元素的个数
- Q:每个元素执行的计算量
N*Q的结果越大,我们越能从并行中获取性能提升。如果Q非常少,比如数字相加,那么N应该大于10,000。随着计算量的增加,从并行获得性能促进所需的数据量越小。
什么时候使用并行流?
某些情况下并行会带来性能的提升。但并行流不能作为一个性能促进器。开发中应该仍然默认使用顺序流。
当我确实出于性能的需求才能将顺序流转换为并行流。鉴于这些要求,我们应该首先运行性能检测工具将并行流作为一种可能的优化策略。
大量的数据以及每个元素执行许多计算操作,意味着并行是一种好的选择。
另外,少量数据,不均匀的数据源,昂贵的合并操作以及不足的本地内存对于并行来说存在潜在问题。
总结
- Java8顺序流和并行流的不同
- 并行流默认使用fork-join线程池和它的工作者线程。
- 并行流并不总是带来性能提升。
- arrays对于并行操作来说是一个非常好的数据源,因为它可以均匀廉价的进行分割。
- NQ模型
- 当我们确实对性能有要求,才能考虑并行流。