拥抱Java 8并行流吧,速度飞起!

开发 后端
Java8 为我们提供了并行流,可以一键开启并行模式。是不是很酷呢?让我们来看看吧。

[[334438]]

前言

在 Java7 之前,如果想要并行处理一个集合,我们需要以下几步:

  1.  手动分成几部分 
  2.  为每部分创建线程
  3.  在适当的时候合并。并且还需要关注多个线程之间共享变量的修改问题。

而 Java8 为我们提供了并行流,可以一键开启并行模式。是不是很酷呢?让我们来看看吧

并行流

认识和开启并行流

什么是并行流: 并行流就是将一个流的内容分成多个数据块,并用不同的线程分别处理每个不同数据块的流。例如有这么一个需求:

有一个 List 集合,而 list 中每个 apple 对象只有重量,我们也知道 apple 的单价是 5元/kg,现在需要计算出每个 apple 的单价,传统的方式是这样: 

  1. List<Apple> appleList = new ArrayList<>(); // 假装数据是从库里查出来的  
  2. for (Apple apple : appleList) {  
  3.     apple.setPrice(5.0 * apple.getWeight() / 1000);  

我们通过迭代器遍历 list 中的 apple 对象,完成了每个 apple 价格的计算。而这个算法的时间复杂度是 O(list.size()) 随着 list 大小的增加,耗时也会跟着线性增加。并行流可以大大缩短这个时间。

并行流处理该集合的方法如下: 

  1. appleList.parallelStream().forEach(apple -> apple.setPrice(5.0 * apple.getWeight() / 1000)); 

和普通流的区别是这里调用的 parallelStream() 方法。当然也可以通过 stream.parallel() 将普通流转换成并行流。推荐看下:Java 8 创建 Stream 的 10 种方式,更多可以关注Java技术栈公众号回复java获取系列教程。

并行流也能通过 sequential() 方法转换为顺序流,但要注意:流的并行和顺序转换不会对流本身做任何实际的变化,仅仅是打了个标记而已。并且在一条流水线上对流进行多次并行 / 顺序的转换,生效的是最后一次的方法调用

并行流如此方便,它的线程从那里来呢?有多少个?怎么配置呢?

并行流内部使用了默认的 ForkJoinPool 线程池。默认的线程数量就是处理器的核心数,而配置系统核心属性:java.util.concurrent.ForkJoinPool.common.parallelism 可以改变线程池大小。不过该值是全局变量。

改变他会影响所有并行流。目前还无法为每个流配置专属的线程数。一般来说采用处理器核心数是不错的选择

测试并行流的性能

为了更容易的测试性能,我们在每次计算完苹果价格后,让线程睡 1s,表示在这期间执行了其他 IO 相关的操作,并输出程序执行耗时,顺序执行的耗时: 

  1. public static void main(String[] args) throws InterruptedException {  
  2.     List<Apple> appleList = initAppleList();  
  3.     Date begin = new Date();  
  4.     for (Apple apple : appleList) {  
  5.         apple.setPrice(5.0 * apple.getWeight() / 1000);  
  6.         Thread.sleep(1000);  
  7.     }  
  8.     Date end = new Date();  
  9.     log.info("苹果数量:{}个, 耗时:{}s", appleList.size(), (end.getTime() - begin.getTime()) /1000);  

并行版本 

  1. List<Apple> appleList = initAppleList();  
  2. Date begin = new Date();  
  3. appleList.parallelStream()  
  4. .forEach(apple ->  
  5.          {  
  6.              apple.setPrice(5.0 * apple.getWeight() / 1000);  
  7.              try {  
  8.                  Thread.sleep(1000);  
  9.              } catch (InterruptedException e) {  
  10.                  e.printStackTrace();  
  11.              }  
  12.          }  
  13.         );  
  14. Date end = new Date();  
  15. log.info("苹果数量:{}个, 耗时:{}s", appleList.size(), (end.getTime() - begin.getTime()) /1000); 

耗时情况

跟我们的预测一致,我的电脑是 四核I5 处理器,开启并行后四个处理器每人执行一个线程,最后 1s 完成了任务!

并行流可以随便用吗?

可拆分性影响流的速度

通过上面的测试,有的人会轻易得到一个结论:并行流很快,我们可以完全放弃 foreach/fori/iter 外部迭代,使用 Stream 提供的内部迭代来实现了。

事实真的是这样吗?并行流真的如此完美吗?答案当然是否定的。大家可以复制下面的代码,在自己的电脑上测试。测试完后可以发现,并行流并不总是最快的处理方式。

  1.  对于 iterate 方法来处理的前 n 个数字来说,不管并行与否,它总是慢于循环的,非并行版本可以理解为流化操作没有循环更偏向底层导致的慢。可并行版本是为什么慢呢?这里有两个需要注意的点:

      2.  iterate 生成的是装箱的对象,必须拆箱成数字才能求和

      3.  我们很难把 iterate 分成多个独立的块来并行执行

          这个问题很有意思,我们必须意识到某些流操作比其他操作更容易并行化。对于 iterate 来说,每次应用这个函数都要依赖于前一次应用的结果。因此在这种情况下,我们不仅不能有效的将流划分成小块处理。反而还因为并行化再次增加了开支。

      4.  而对于 LongStream.rangeClosed() 方法来说,就不存在 iterate 的第两个痛点了。它生成的是基本类型的值,不用拆装箱操作,另外它可以直接将要生成的数字 1 - n 拆分成 1 - n/4, 1n/4 - 2n/4, ... 3n/4 - n 这样四部分。因此并行状态下的 rangeClosed() 是快于 for 循环外部迭代的 

  1. package lambdasinaction.chap7;  
  2. import java.util.stream.*;  
  3. public class ParallelStreams {  
  4.     public static long iterativeSum(long n) {  
  5.         long result = 0 
  6.         for (long i = 0; i <= n; i++) {  
  7.             result += i;  
  8.         }  
  9.         return result;  
  10.     }  
  11.     public static long sequentialSum(long n) {  
  12.         return Stream.iterate(1L, i -> i + 1).limit(n).reduce(Long::sum).get();  
  13.     }  
  14.     public static long parallelSum(long n) {  
  15.         return Stream.iterate(1L, i -> i + 1).limit(n).parallel().reduce(Long::sum).get();  
  16.     }  
  17.     public static long rangedSum(long n) {  
  18.         return LongStream.rangeClosed(1, n).reduce(Long::sum).getAsLong();  
  19.     }  
  20.     public static long parallelRangedSum(long n) {  
  21.         return LongStream.rangeClosed(1, n).parallel().reduce(Long::sum).getAsLong();  
  22.     }  
  23.  
  24. package lambdasinaction.chap7;  
  25. import java.util.concurrent.*;  
  26. import java.util.function.*;  
  27. public class ParallelStreamsHarness {  
  28.     public static final ForkJoinPool FORK_JOIN_POOL = new ForkJoinPool();  
  29.     public static void main(String[] args) {  
  30.         System.out.println("Iterative Sum done in: " + measurePerf(ParallelStreams::iterativeSum, 10_000_000L) + " msecs");  
  31.         System.out.println("Sequential Sum done in: " + measurePerf(ParallelStreams::sequentialSum, 10_000_000L) + " msecs");  
  32.         System.out.println("Parallel forkJoinSum done in: " + measurePerf(ParallelStreams::parallelSum, 10_000_000L) + " msecs" );  
  33.         System.out.println("Range forkJoinSum done in: " + measurePerf(ParallelStreams::rangedSum, 10_000_000L) + " msecs");  
  34.         System.out.println("Parallel range forkJoinSum done in: " + measurePerf(ParallelStreams::parallelRangedSum, 10_000_000L) + " msecs" );  
  35.     }  
  36.     public static <T, R> long measurePerf(Function<T, R> f, T input) {  
  37.         long fastest = Long.MAX_VALUE;  
  38.         for (int i = 0; i < 10; i++) {  
  39.             long start = System.nanoTime();  
  40.             R result = f.apply(input);  
  41.             long duration = (System.nanoTime() - start) / 1_000_000;  
  42.             System.out.println("Result: " + result);  
  43.             if (duration < fastestfastest = duration 
  44.         }  
  45.         return fastest;  
  46.     }  

共享变量修改的问题

并行流虽然轻易的实现了多线程,但是仍未解决多线程中共享变量的修改问题。下面代码中存在共享变量 total,分别使用顺序流和并行流计算前n个自然数的和 

  1. public static long sideEffectSum(long n) {  
  2.     Accumulator accumulator = new Accumulator();  
  3.     LongStream.rangeClosed(1, n).forEach(accumulator::add);  
  4.     return accumulator.total;  
  5.  
  6. public static long sideEffectParallelSum(long n) {  
  7.     Accumulator accumulator = new Accumulator();  
  8.     LongStream.rangeClosed(1, n).parallel().forEach(accumulator::add);  
  9.     return accumulator.total;  
  10.  
  11. public static class Accumulator {  
  12.     private long total = 0 
  13.     public void add(long value) {  
  14.         total += value;  
  15.     }  

顺序执行每次输出的结果都是:50000005000000,而并行执行的结果却五花八门了。这是因为每次访问 totle 都会存在数据竞争,关于数据竞争的原因,大家可以看看关于 volatile 的博客。因此当代码中存在修改共享变量的操作时,是不建议使用并行流的。

并行流的使用注意

在并行流的使用上有下面几点需要注意:

  •  尽量使用 LongStream / IntStream / DoubleStream 等原始数据流代替 Stream 来处理数字,以避免频繁拆装箱带来的额外开销
  •  要考虑流的操作流水线的总计算成本,假设 N 是要操作的任务总数,Q 是每次操作的时间。N * Q 就是操作的总时间,Q 值越大就意味着使用并行流带来收益的可能性越大

例如:前端传来几种类型的资源,需要存储到数据库。每种资源对应不同的表。我们可以视作类型数为 N,存储数据库的网络耗时 + 插入操作耗时为 Q。一般情况下网络耗时都是比较大的。因此该操作就比较适合并行处理。当然当类型数目大于核心数时,该操作的性能提升就会打一定的折扣了。更好的优化方法在日后的博客会为大家奉上

  •  对于较少的数据量,不建议使用并行流
  •  容易拆分成块的流数据,建议使用并行流

以下是一些常见的集合框架对应流的可拆分性能表:

可拆分性
ArrayList 极佳
LinkedList
IntStream.range 极佳
Stream.iterate
HashSet
TreeSet

码字不易,如果你觉得读完以后有收获,不妨点个推荐让更多的人看到吧! 

 

责任编辑:庞桂玉 来源: Java技术栈
相关推荐

2014-06-05 08:47:52

Spark 1.0Mapreduce

2019-09-02 08:58:27

Python编译器编程语言

2021-08-09 19:01:36

并行场景程序

2018-03-05 10:27:47

电脑卡顿旧电脑

2013-09-26 16:25:47

微软甲骨文Windows Azu

2023-11-07 12:00:41

数据并行Java 8数据

2011-03-07 14:15:33

standby数据库

2024-04-19 08:28:57

JavaAPI场景

2018-05-15 11:05:36

Wifi速度数字

2023-11-10 18:03:04

业务场景SQL

2010-10-22 14:43:09

移动开发

2020-05-05 22:48:18

工业物联网IIOT物联网

2009-04-23 08:59:37

Windows 7微软操作系统

2023-05-12 07:40:01

Java8API工具

2014-07-15 13:57:53

Java8

2017-06-14 16:15:13

拥抱开发者技术落地微软

2014-04-16 07:48:56

Java 8Permgen

2017-04-25 09:38:19

戴尔

2019-06-27 10:32:57

Java开发代码

2023-10-12 08:29:06

线程池Java
点赞
收藏

51CTO技术栈公众号