Java8 并行流(parallelStream)原理分析及注意事项

前言

众所周知,Java 使用Stream流做多线程处理是非常方便的。随着并行编程越来越流行,Java从1.7就开始提供了Fork/Join 支持并行处理,并且在1.8版本进一步加强了相关功能。并行处理就是将任务拆分子任务,分发给多个处理器同时处理之后进行合并。下面将会对并行流(parallelStream)原理分析及注意事项进行详细介绍。


一、parallelStream是什么

Java8中提供了能够更方便处理集合数据的Stream类,其中parallelStream()方法能够充分利用多核CPU的优势,使用多线程加快对集合数据的处理速度。parallelStream主要用于利用处理器的多个核心。通常,任何Java代码都有一个处理流,在这里它是按顺序执行的。然而,通过使用并行流,我们可以将代码分成多个流,这些流在不同的内核上并行执行,最终的结果是各个结果的组合。然而,处理的顺序不在我们的控制之下

因此,建议在以下情况下使用并行流:无论执行顺序如何,结果不受影响一个元素的状态不影响另一个元素,并且数据源也不受影响

parallelStream()方法的源码如下:

 
  1. /**
  2. * @return a possibly parallel {@code Stream} over the elements in this
  3. * collection
  4. * @since 1.8
  5. */
  6.  
  7. default Stream<E> parallelStream() {
  8. return StreamSupport.stream(spliterator(), true);
  9. }
 

从上面代码中注释的@return a possibly parallel可以看得出来,parallelStream()并不是一定返回一个并行流,有可能parallelStream()全是由主线程顺序执行的。因此使用parallelStream时要特别注意。


二、parallelStream原理分析

我们都知道在java 使用strem流做多线程处理是非常方便的。

 
  1. list.parallelStream().forEach(s -> {
  2. // 后续业务处理
  3. });
 

但是parallelStream是如何实现多线程处理的呢?其实看源码我们会发现parallelStream是使用线程池ForkJoin来调度的,并且参与并行处理的线程有主线程以及ForkJoinPool中的worker线程。

1.Fork/Join框架

parallelStream的底层是基于ForkJoinPool的,ForkJoinPool实现了ExecutorService接口,因此和线程池有着密不可分的关系。

ForkJoinPool和ExecutorService的继承关系如图所示:

Fork/Join框架主要采用分而治之的理念来处理问题,对于一个比较大的任务,首先将它拆分(fork)为多个小任务task1、task2等。再使用新的线程thread1去处理task1,thread2去处理task2。

如果thread1认为task1还是太大,则继续往下拆分成新的子任务task1.1与task1.2。thread2认为task2任务量不大,则立即进行处理,形成结果result2。

之后将task1.1和task1.2的处理结果合并(join)成result1,最后将result1与result2合并成最后的结果。

下面用图更清晰的进行描述:
 

Fork/Join流程图

 

1.1 work-stealing(工作窃取算法)

work-stealing(工作窃取):ForkJoinPool提供了一个更有效的利用线程的机制,当ThreadPoolExecutor还在用单个队列存放任务时,ForkJoinPool已经分配了与线程数相等的队列,当有任务加入线程池时,会被平均分配到对应的队列上,各线程进行正常工作,当有线程提前完成时,会从队列的末端“窃取”其他线程未执行完的任务,当任务量特别大时,CPU多的计算机会表现出更好的性能。

1.2 常用方法

1.ForkJoinTask:我们要使用ForkJoin框架,必须首先创建一个ForkJoin任务。它提供在任务中执行fork()和join()操作的机制,通常情况下我们不需要直接继承ForkJoinTask类,而只需要继承它的子类,Fork/Join框架提供了以下两个子类:

  • RecursiveAction:用于没有返回结果的任务。
  • RecursiveTask:用于有返回结果的任务。

2.ForkJoinPool :ForkJoinTask需要通过ForkJoinPool来执行,任务分割出的子任务会添加到当前工作线程所维护的双端队列中,进入队列的头部。当一个工作线程的队列里暂时没有任务时,它会随机从其他工作线程的队列的尾部获取一个任务。

2. 实例演示

2.1 提交有返回值的任务

 
  1. import java.util.concurrent.ExecutionException;
  2. import java.util.concurrent.ForkJoinPool;
  3. import java.util.concurrent.ForkJoinTask;
  4. import java.util.concurrent.RecursiveTask;
  5. import java.util.stream.IntStream;
  6.  
  7. /**
  8. * @Description 提交有返回值的任务
  9. */
  10.  
  11. public class ForkJoinRecursiveTask {
  12.  
  13. /**
  14. * 最大计算数
  15. */
  16. private static final int MAX_THRESHOLD = 100;
  17.  
  18. public static void main(String[] args) {
  19. //创建ForkJoinPool
  20. ForkJoinPool pool = new ForkJoinPool();
  21. //异步提交RecursiveTask任务
  22. ForkJoinTask<Integer> forkJoinTask = pool.submit(new CalculatedRecursiveTask(0, 1000));
  23. try {
  24. //根据返回类型获取返回值
  25. Integer result = forkJoinTask.get();
  26. System.out.println("执行结果为:" + result);
  27. } catch (InterruptedException | ExecutionException e) {
  28. e.printStackTrace();
  29. } finally {
  30. pool.shutdown();
  31. }
  32. }
  33.  
  34. private static class CalculatedRecursiveTask extends RecursiveTask<Integer> {
  35. private final int start;
  36. private final int end;
  37.  
  38. public CalculatedRecursiveTask(int start, int end) {
  39. this.start = start;
  40. this.end = end;
  41. }
  42.  
  43. @Override
  44. protected Integer compute() {
  45. //判断计算范围,如果小于等于5,那么一个线程计算即可,否则进行分割
  46. if ((end - start) <= MAX_THRESHOLD) {
  47. //返回[start,end]的总和
  48. return IntStream.rangeClosed(start, end).sum();
  49. } else {
  50. //任务分割
  51. int middle = (end + start) / 2;
  52. CalculatedRecursiveTask task1 = new CalculatedRecursiveTask(start, middle);
  53. CalculatedRecursiveTask task2 = new CalculatedRecursiveTask(middle + 1, end);
  54. //执行
  55. task1.fork();
  56. task2.fork();
  57. //等待返回结果
  58. return task1.join() + task2.join();
  59. }
  60. }
  61. }
  62. }
 

执行结果如下:

 2.2 提交无返回值的任务

 
  1. import java.util.concurrent.ForkJoinPool;
  2. import java.util.concurrent.RecursiveAction;
  3. import java.util.concurrent.TimeUnit;
  4. import java.util.concurrent.atomic.AtomicInteger;
  5. import java.util.stream.IntStream;
  6.  
  7. /**
  8. * @Description 提交无返回值的任务
  9. */
  10.  
  11. public class ForkJoinRecursiveAction {
  12.  
  13. /**
  14. * 最大计算数
  15. */
  16. private static final int MAX_THRESHOLD = 100;
  17. private static final AtomicInteger SUM = new AtomicInteger(0);
  18.  
  19. public static void main(String[] args) throws InterruptedException {
  20. //创建ForkJoinPool
  21. ForkJoinPool pool = new ForkJoinPool();
  22. //异步提交RecursiveAction任务
  23. pool.submit(new CalculatedRecursiveTask(0, 1000));
  24. //等待3秒后输出结果,因为计算需要时间
  25. pool.awaitTermination(1, TimeUnit.SECONDS);
  26. System.out.println("结果为:" + SUM);
  27. pool.shutdown();
  28. }
  29.  
  30. private static class CalculatedRecursiveTask extends RecursiveAction {
  31. private final int start;
  32. private final int end;
  33.  
  34. public CalculatedRecursiveTask(int start, int end) {
  35. this.start = start;
  36. this.end = end;
  37. }
  38.  
  39. @Override
  40. protected void compute() {
  41. //判断计算范围,如果小于等于5,那么一个线程计算即可,否则进行分割
  42. if ((end - start) <= MAX_THRESHOLD) {
  43. //因为没有返回值,所有这里如果要获取结果,需要存入公共的变量中
  44. SUM.addAndGet(IntStream.rangeClosed(start, end).sum());
  45. } else {
  46. //任务分割
  47. int middle = (end + start) / 2;
  48. CalculatedRecursiveTask task1 = new CalculatedRecursiveTask(start, middle);
  49. CalculatedRecursiveTask task2 = new CalculatedRecursiveTask(middle + 1, end);
  50. //执行
  51. task1.fork();
  52. task2.fork();
  53. }
  54. }
  55. }
  56. }
 

执行结果如下:

 

虽然ForkJoin实际的代码非常复杂,但是通过这个例子应该了解到ForkJoinPool底层的分治算法和工作窃取原理。ForkJoin不仅在Java8之后的Stream中广泛使用。golang等其他语言的协程机制,也是采用类似的原理来实现的。

二、使用方法

1. 为什么使用并行流

并行流的引入是为了提高程序的性能,但是选择并行流并不总是最好的选择在某些情况下,我们需要以特定的顺序执行代码,在这些情况下,我们最好使用顺序流以牺牲性能为代价来执行任务。这两种流之间的性能差异仅在大型程序或复杂项目中才值得关注。对于小规模的项目,它甚至可能不明显。基本上,当顺序流表现不佳时,您应该考虑使用并行流。

2. Stream和parallelStream选择

在从stream和parallelStream方法中进行选择时,我们可以考虑以下几个问题:

1.是否需要并行?

2.任务之间是否是独立的?是否会引起任何竞态条件?

3.结果是否取决于任务的调用顺序?

对于问题1,在回答这个问题之前,需要明确要解决的问题是什么,数据量有多大,计算的特点是什么?并不是所有的问题都适合使用并发程序来求解,比如当数据量不大时,顺序执行往往比并行执行更快。毕竟,准备线程池和其它相关资源也是需要时间的。但是,当任务涉及到I/O操作并且任务之间不互相依赖时,那么并行化就是一个不错的选择。通常而言,将这类程序并行化之后,执行速度会提升好几个等级。

对于问题2,如果任务之间是独立的,并且代码中不涉及到对同一个对象的某个状态或者某个变量的更新操作,那么就表明代码是可以被并行化的。

对于问题3,由于在并行环境中任务的执行顺序是不确定的,因此对于依赖于顺序的任务而言,并行化也许不能给出正确的结果。

3. 正确使用并行流

并行流并不总是比顺序流快。所以正确的姿势使用并行流是尤为重要的,不然适得其反。

决定某个特定情况下是否有必要使用并行流。可以参考一下几点建议

  • 1、如果有疑问,提前进行测量和检查。并行流有时候会和直觉不一致,所以在考虑选择顺序流还是并行流时,很重要的建议就是用适当的基准来检查其性能。

  • 2、留意装箱。自动装箱和拆箱操作会大大降低性能。Java 8中有原始类型流(IntStream、LongStream和DoubleStream)来避免这种操作,尽量使用这些流进行操作。

  • 3、有些操作本身在并行流上的性能就比顺序流差。特别是limit和findFirst等依赖于元素顺序的操作,它们在并行流上执行的代价非常大。例如,findAny会比findFirst性能好,因为它不一定要按顺序来执行。你总是可以调用unordered方法来把有序流变成无序流。那么,如果你需要流中的N个元素而不是专门要前N个的话,对无序并行流调用limit可能会比单个有序流(比如数据源是一个List)更高效。

  • 4、考虑流的操作流水线的总计算成本。设N是要处理的元素的总数,Q是一个元素通过流水线的大致处理成本,则N*Q就是这个对成本的一个粗略的定性估计。Q值较高就意味着使用并行流时性能好的可能性比较大。

  • 5、对于较小的数据量,选择并行流几乎从来都不是一个好的决定。并行处理少数几个元素的好处还抵不上并行化造成的额外开销。

  • 6、考虑流背后的数据结构是否易于分解。例如,ArrayList的拆分效率比LinkedList高得多,因为前者用不着遍历就可以平均拆分,后者则必须遍历。另外,用range工厂方法创建的原始类型流也可以快速分解。可以参考一下表格:

数据源 性能
ArrayList 极佳
LinkedList
IntStrean.range 极佳
Strean.iterate
HashSet
TreeSet
  • 7、流自身的特点以及流水线中的中间操作修改流的方式,都可能会改变分解过程的性能。例如,一个SIZED流可以分成大小相等的两部分,这样每个部分都可以比较高效地并行处理,但筛选操作可能丢弃的元素个数无法预测,从而导致流本身的大小未知。

  • 8、还要考虑终端操作中合并步骤的代价是大是小(例如Collector中的combiner方法)。如果这一步代价很大,那么组合每个子流产生的部分结果所付出的代价就可能会超出通过并行流得到的性能提升。

三、注意事项

  1. 因为是并行流,所以所涉及到的数据结构需要使用线程安全的。例如

     
    1. listByPage.parallelStream().forEach(str-> {
    2. //使用线程安全的数据结构
    3. //ConcurrentHashMap
    4. //CopyOnWriteArrayList
    5. //等等进行操作
    6. });
     
  2. 线程关联的ThreadLocal将会失效。

    由于开头提到的主线程有可能参与到parallelStream中的任务处理的过程中。因此如果我们处理的任务方法中包含对ThreadLocal的处理,可能除主线程之外的所有线程都获取不到自己的线程局部变量,加之ForkJoinPool中的线程是反复使用的,线程关联的ThreadLocal会发生共用的情况。

    所以我的建议是,parallelStream中就不要使用ThreadLocal了,要么在任务处理方法中,第一行先进行ThreadLocal.set(),之后再由ThreadLocal.get()获取到自己的线程局部变量

  3. 使用并行流时,不要使用collectors.groupingBy、collectors.toMap

    使用并行流时,不要使用collectors.groupingBy、collectors.toMap,替代为collectors.groupingByConcurrent、collectors.toConcurrentMap,或直接使用串行流。

    原因,并行流执行时,通过操作Key来合并多个map的操作比较昂贵。详细大家可以查看官网介绍。

     

    https://docs.oracle.com/javase/tutorial/collections/streams/parallelism.html#concurrent_reduction

  4. 使用parallelStream也不一定会提升性能

    在CPU资源紧张的时候,使用并行流可能会带来频繁的线程上下文切换,导致并行流执行的效率还没有串行执行的效率高。


总结

本文对Java8的并行流(parallelStream)原理分析及注意事项进行了详细的介绍,主要对其中的Fork Join、线程池、使用方法进行了深刻的分析。

 

Stream

    无状态:指元素的处理不受之前元素的影响;

    有状态:指该操作只有拿到所有元素之后才能继续下去。

    非短路操作:指必须处理所有元素才能得到最终结果;

    短路操作:指遇到某些符合条件的元素就可以得到最终结果,如 A || B,只要A为true,则无需判断B的结果。

ParallelStream
对于ParallelStream,需要知道的是里面的执行是异步的,并且使用的线程池是ForkJoinPool.common,可以通过设置-Djava.util.concurrent.ForkJoinPool.common.parallelism = N来调整线程池的大小;

ParallelStream的作用
Stream具有平行处理能力,处理的过程会分而治之,也就是将一个大任务切分成多个小任务,这表示每个任务都是一个操作,因此像以下的程式片段:

 
  1. List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
  2.  
  3. numbers.parallelStream().forEach(System.out::println);
 

得到的展示顺序不一定会是1、2、3、4、5、6、7、8、9,而可能是任意的顺序。得到的结论就是parallelStream()每次执行的结果都不相同,与多线程程序中执行的结果类似。如果希望最后顺序是按照原来Stream的数据顺序,那可以调用forEachOrdered()。例如:

 
  1. List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
  2.  
  3. numbers.parallelStream().forEachOrdered(System.out::println);
 

你得到的展示顺序就是1、2、3、4、5、6、7、8、9。

Java8并行流parallelStream()和stream()的区别就是支持并行执行,提高程序运行效率。但是如果使用不当可能会发生线程安全的问题。

 

其他同类知识点:

1、Java集合Stream类filter的使用;

2、Java中的排序问题(Java8新特性 stream流、stream多字段排序);

 

参考:

https://blog.csdn.net/y_k_y/article/details/84633001

https://blog.csdn.net/zhxdick/article/details/79228605

about Stream

什么是流?

Stream是java8中新增加的一个特性,被java猿统称为流.

Stream 不是集合元素,它不是数据结构并不保存数据,它是有关算法和计算的,它更像一个高级版本的 Iterator。原始版本的 Iterator,用户只能显式地一个一个遍历元素并对其执行某些操作;高级版本的 Stream,用户只要给出需要对其包含的元素执行什么操作,比如 “过滤掉长度大于 10 的字符串”、“获取每个字符串的首字母”等,Stream 会隐式地在内部进行遍历,做出相应的数据转换。

Stream 就如同一个迭代器(Iterator),单向,不可往复,数据只能遍历一次,遍历过一次后即用尽了,就好比流水从面前流过,一去不复返。

而和迭代器又不同的是,Stream 可以并行化操作,迭代器只能命令式地、串行化操作。顾名思义,当使用串行方式去遍历时,每个 item 读完后再读下一个 item。而使用并行去遍历时,数据会被分成多个段,其中每一个都在不同的线程中处理,然后将结果一起输出。Stream 的并行操作依赖于 Java7 中引入的 Fork/Join 框架(JSR166y)来拆分任务和加速处理过程。Java 的并行 API 演变历程基本如下:

 
  1. 1.0-1.4 中的 java.lang.Thread
  2. 5.0 中的 java.util.concurrent
  3. 6.0 中的 Phasers 等
  4. 7.0 中的 Fork/Join 框架
  5. 8.0 中的 Lambda
 

parallelStream是什么

parallelStream其实就是一个并行执行的流.它通过默认的ForkJoinPool,可能提高你的多线程任务的速度.

parallelStream的作用

Stream具有平行处理能力,处理的过程会分而治之,也就是将一个大任务切分成多个小任务,这表示每个任务都是一个操作,因此像以下的程式片段:

 
  1. List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
  2. numbers.parallelStream()
  3. .forEach(out::println);
 

你得到的展示顺序不一定会是1、2、3、4、5、6、7、8、9,而可能是任意的顺序,就forEach()这个操作來讲,如果平行处理时,希望最后顺序是按照原来Stream的数据顺序,那可以调用forEachOrdered()。例如:

 
  1. List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
  2. numbers.parallelStream()
  3. .forEachOrdered(out::println);
 

注意:如果forEachOrdered()中间有其他如filter()的中介操作,会试着平行化处理,然后最终forEachOrdered()会以原数据顺序处理,因此,使用forEachOrdered()这类的有序处理,可能会(或完全失去)失去平行化的一些优势,实际上中介操作亦有可能如此,例如sorted()方法。

parallelStream背后的男人:ForkJoinPool

要想深入的研究parallelStream之前,那么我们必须先了解ForkJoin框架和ForkJoinPool.本文旨在parallelStream,但因为两种关系甚密,故在此简单介绍一下ForkJoinPool,如有兴趣可以更深入的去了解下ForkJoin***(当然,如果你想真正的搞透parallelStream,那么你依然需要先搞透ForkJoinPool).*

ForkJoin框架是从jdk7中新特性,它同ThreadPoolExecutor一样,也实现了Executor和ExecutorService接口。它使用了一个无限队列来保存需要执行的任务,而线程的数量则是通过构造函数传入,如果没有向构造函数中传入希望的线程数量,那么当前计算机可用的CPU数量会被设置为线程数量作为默认值。

ForkJoinPool主要用来使用分治法(Divide-and-Conquer Algorithm)来解决问题。典型的应用比如快速排序算法。这里的要点在于,ForkJoinPool需要使用相对少的线程来处理大量的任务。比如要对1000万个数据进行排序,那么会将这个任务分割成两个500万的排序任务和一个针对这两组500万数据的合并任务。以此类推,对于500万的数据也会做出同样的分割处理,到最后会设置一个阈值来规定当数据规模到多少时,停止这样的分割处理。比如,当元素的数量小于10时,会停止分割,转而使用插入排序对它们进行排序。那么到最后,所有的任务加起来会有大概2000000+个。问题的关键在于,对于一个任务而言,只有当它所有的子任务完成之后,它才能够被执行。

所以当使用ThreadPoolExecutor时,使用分治法会存在问题,因为ThreadPoolExecutor中的线程无法像任务队列中再添加一个任务并且在等待该任务完成之后再继续执行。而使用ForkJoinPool时,就能够让其中的线程创建新的任务,并挂起当前的任务,此时线程就能够从队列中选择子任务执行。

那么使用ThreadPoolExecutor或者ForkJoinPool,会有什么性能的差异呢? 
首先,使用ForkJoinPool能够使用数量有限的线程来完成非常多的具有父子关系的任务,比如使用4个线程来完成超过200万个任务。但是,使用ThreadPoolExecutor时,是不可能完成的,因为ThreadPoolExecutor中的Thread无法选择优先执行子任务,需要完成200万个具有父子关系的任务时,也需要200万个线程,显然这是不可行的。

工作窃取算法

forkjoin最核心的地方就是利用了现代硬件设备多核,在一个操作时候会有空闲的cpu,那么如何利用好这个空闲的cpu就成了提高性能的关键,而这里我们要提到的工作窃取(work-stealing)算法就是整个forkjion框架的核心理念,工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行。

那么为什么需要使用工作窃取算法呢? 
假如我们需要做一个比较大的任务,我们可以把这个任务分割为若干互不依赖的子任务,为了减少线程间的竞争,于是把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应,比如A线程负责处理A队列里的任务。但是有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务等待处理。干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。

工作窃取算法的优点是充分利用线程进行并行计算,并减少了线程间的竞争,其缺点是在某些情况下还是存在竞争,比如双端队列里只有一个任务时。并且消耗了更多的系统资源,比如创建多个线程和多个双端队列。、

 

用看forkjion的眼光来看ParallelStreams

上文中已经提到了在Java 8引入了自动并行化的概念。它能够让一部分Java代码自动地以并行的方式执行,也就是我们使用了ForkJoinPool的ParallelStream。

Java 8为ForkJoinPool添加了一个通用线程池,这个线程池用来处理那些没有被显式提交到任何线程池的任务。它是ForkJoinPool类型上的一个静态元素,它拥有的默认线程数量等于运行计算机上的处理器数量。当调用Arrays类上添加的新方法时,自动并行化就会发生。比如用来排序一个数组的并行快速排序,用来对一个数组中的元素进行并行遍历。自动并行化也被运用在Java 8新添加的Stream API中。

比如下面的代码用来遍历列表中的元素并执行需要的操作:

 
  1. List<UserInfo> userInfoList =
  2. DaoContainers.getUserInfoDAO().queryAllByList(new UserInfoModel());
  3. userInfoList.parallelStream().forEach(RedisUserApi::setUserIdUserInfo);
 

对于列表中的元素的操作都会以并行的方式执行。forEach方法会为每个元素的计算操作创建一个任务,该任务会被前文中提到的ForkJoinPool中的通用线程池处理。以上的并行计算逻辑当然也可以使用ThreadPoolExecutor完成,但是就代码的可读性和代码量而言,使用ForkJoinPool明显更胜一筹。

对于ForkJoinPool通用线程池的线程数量,通常使用默认值就可以了,即运行时计算机的处理器数量。我这里提供了一个示例的代码让你了解jvm所使用的ForkJoinPool的线程数量, 你可以可以通过设置系统属性:-Djava.util.concurrent.ForkJoinPool.common.parallelism=N (N为线程数量),来调整ForkJoinPool的线程数量,可以尝试调整成不同的参数来观察每次的输出结果:

 
  1. import java.util.ArrayList;
  2. import java.util.List;
  3. import java.util.Set;
  4. import java.util.concurrent.CopyOnWriteArraySet;
  5. import java.util.concurrent.CountDownLatch;
  6.  
  7. /**
  8. * @description 这是一个用来让你更加熟悉parallelStream的原理的实力
  9. * @date 2016年10月11日18:26:55
  10. * @version v1.0
  11. * @author wangguangdong
  12. */
  13. public class App {
  14. public static void main(String[] args) throws Exception {
  15. System.out.println("Hello World!");
  16. // 构造一个10000个元素的集合
  17. List<Integer> list = new ArrayList<>();
  18. for (int i = 0; i < 10000; i++) {
  19. list.add(i);
  20. }
  21. // 统计并行执行list的线程
  22. Set<Thread> threadSet = new CopyOnWriteArraySet<>();
  23. // 并行执行
  24. list.parallelStream().forEach(integer -> {
  25. Thread thread = Thread.currentThread();
  26. // System.out.println(thread);
  27. // 统计并行执行list的线程
  28. threadSet.add(thread);
  29. });
  30. System.out.println("threadSet一共有" + threadSet.size() + "个线程");
  31. System.out.println("系统一个有"+Runtime.getRuntime().availableProcessors()+"个cpu");
  32. List<Integer> list1 = new ArrayList<>();
  33. List<Integer> list2 = new ArrayList<>();
  34. for (int i = 0; i < 100000; i++) {
  35. list1.add(i);
  36. list2.add(i);
  37. }
  38. Set<Thread> threadSetTwo = new CopyOnWriteArraySet<>();
  39. CountDownLatch countDownLatch = new CountDownLatch(2);
  40. Thread threadA = new Thread(() -> {
  41. list1.parallelStream().forEach(integer -> {
  42. Thread thread = Thread.currentThread();
  43. // System.out.println("list1" + thread);
  44. threadSetTwo.add(thread);
  45. });
  46. countDownLatch.countDown();
  47. });
  48. Thread threadB = new Thread(() -> {
  49. list2.parallelStream().forEach(integer -> {
  50. Thread thread = Thread.currentThread();
  51. // System.out.println("list2" + thread);
  52. threadSetTwo.add(thread);
  53. });
  54. countDownLatch.countDown();
  55. });
  56.  
  57. threadA.start();
  58. threadB.start();
  59. countDownLatch.await();
  60. System.out.print("threadSetTwo一共有" + threadSetTwo.size() + "个线程");
  61.  
  62. System.out.println("---------------------------");
  63. System.out.println(threadSet);
  64. System.out.println(threadSetTwo);
  65. System.out.println("---------------------------");
  66. threadSetTwo.addAll(threadSet);
  67. System.out.println(threadSetTwo);
  68. System.out.println("threadSetTwo一共有" + threadSetTwo.size() + "个线程");
  69. System.out.println("系统一个有"+Runtime.getRuntime().availableProcessors()+"个cpu");
  70. }
  71. }
 

出现这种现象的原因是,forEach方法用了一些小把戏。它会将执行forEach本身的线程也作为线程池中的一个工作线程。因此,即使将ForkJoinPool的通用线程池的线程数量设置为1,实际上也会有2个工作线程。因此在使用forEach的时候,线程数为1的ForkJoinPool通用线程池和线程数为2的ThreadPoolExecutor是等价的。

所以当ForkJoinPool通用线程池实际需要4个工作线程时,可以将它设置成3,那么在运行时可用的工作线程就是4了。

小结:

 
  1.  
  2. 1. 当需要处理递归分治算法时,考虑使用ForkJoinPool。
  3. 2. 仔细设置不再进行任务划分的阈值,这个阈值对性能有影响。
  4. 3. Java 8中的一些特性会使用到ForkJoinPool中的通用线程池。在某些场合下,需要调整该线程池的默认的线程数量。
 

 

ParallelStreams 的陷阱

上文中我们已经看到了ParallelStream他强大无比的特性,但这里我们就讲告诉你ParallelStreams不是万金油,而是一把双刃剑,如果错误的使用反倒可能伤人伤己.

以下是一个我们项目里使用 parallel streams 的很常见的情况。在这个例子中,我们想同时调用不同地址的api中并且获得第一个返回的结果。

 
  1.  
  2. public static String query(String q, List<String> engines) { Optional<String> result = engines.stream().parallel().map((base) -> {
  3. String url = base + q;
  4. return WS.url(url).get();
  5. }).findAny();
  6. return result.get();
  7. }
 

可能有很多朋友在jdk7用future配合countDownLatch自己实现的这个功能,但是jdk8的朋友基本都会用上面的实现方式,那么自信深究一下究竟自己用future实现的这个功能和利用jdk8的parallelStream来实现这个功能有什么不同点呢?坑又在哪里呢?

让我们细思思考一下整个功能究竟是如何运转的。首先我们的集合元素engines 由ParallelStreams并行的去进行map操作(ParallelStreams使用JVM默认的forkJoin框架的线程池由当前线程去执行并行操作).

然而,这里需要注意的一地方是我们在调用第三方的api请求是一个响应略慢而且会阻塞操作的一个过程。所以在某时刻所有线程都会调用 get() 方法并且在那里等待结果返回.

再回过头仔细思考一下这个功能的实现过程是我们一开始想要的吗?我们是在同一时间等待所有的结果,而不是遍历这个列表按顺序等待每个回答.然而,由于ForkJoinPool workders的存在,这样平行的等待相对于使用主线程的等待会产生的一种副作用.

现在ForkJoin pool (关于forkjion的更多实现你可以去搜索引擎中去看一下他的具体实现方式) 的实现是: 它并不会因为产生了新的workers而抵消掉阻塞的workers。那么在某个时间所有 ForkJoinPool.common() 的线程都会被用光.也就是说,下一次你调用这个查询方法,就可能会在一个时间与其他的parallel stream同时运行,而导致第二个任务的性能大大受损。或者说,例如你在这个功能里是用来快速返回调用的第三方api的,而在其他的功能里是用于一些简单的数据并行计算的,但是假如你先调用了这个功能,同一时间之后调用计算的函数,那么这里forkjionPool的实现会让你计算的函数大打折扣.

不过也不要急着去吐槽ForkJoinPool的实现,在不同的情况下你可以给它一个ManagedBlocker实例并且确保它知道在一个阻塞调用中应该什么时候去抵消掉卡住的workers.现在有意思的一点是,在一个parallel stream处理中并不一定是阻塞调用会拖延程序的性能。任何被用于映射在一个集合上的长时间运行的函数都会产生同样的问题.

正如我们上面那个列子的情况分析得知,lambda的执行并不是瞬间完成的,所有使用parallel streams的程序都有可能成为阻塞程序的源头,并且在执行过程中程序中的其他部分将无法访问这些workers,这意味着任何依赖parallel streams的程序在什么别的东西占用着common ForkJoinPool时将会变得不可预知并且暗藏危机.

怎么正确使用parallelStream

如果你正在写一个其他地方都是单线程的程序并且准确地知道什么时候你应该要使用parallel streams,这样的话你可能会觉得这个问题有一点肤浅。然而,我们很多人是在处理web应用、各种不同的框架以及重量级应用服务。一个服务器是怎样被设计成一个可以支持多种独立应用的主机的?谁知道呢,给你一个可以并行的却不能控制输入的parallel stream.

很抱歉,请原谅我用的标注[怎么正确使用parallelStream],因为目前为止我也没有发现一个好的方式来让我真正的正确使用parallelStream.下面的网上写的两种方式:

一种方式是限制ForkJoinPool提供的并行数。可以通过使用-Djava.util.concurrent.ForkJoinPool.common.parallelism=1 来限制线程池的大小为1。不再从并行化中得到好处可以杜绝错误的使用它(其实这个方式还是有点搞笑的,既然这样搞那我还不如不去使用并行流)

另一种方式就是,一个被称为工作区的可以让ForkJoinPool平行放置的 parallelStream() 实现。不幸的是现在的JDK还没有实现。

Parallel streams 是无法预测的,而且想要正确地使用它有些棘手。几乎任何parallel streams的使用都会影响程序中无关部分的性能,而且是一种无法预测的方式。。但是在调用stream.parallel() 或者parallelStream()时候在我的代码里之前我仍然会重新审视一遍他给我的程序究竟会带来什么问题,他能有多大的提升,是否有使用他的意义.

stream or parallelStream?

上面我们也看到了parallelStream所带来的隐患和好处,那么,在从stream和parallelStream方法中进行选择时,我们可以考虑以下几个问题:

 
  1. 1. 是否需要并行?
  2. 2. 任务之间是否是独立的?是否会引起任何竞态条件?
  3. 3. 结果是否取决于任务的调用顺序?
 

对于问题1,在回答这个问题之前,你需要弄清楚你要解决的问题是什么,数据量有多大,计算的特点是什么?并不是所有的问题都适合使用并发程序来求解,比如当数据量不大时,顺序执行往往比并行执行更快。毕竟,准备线程池和其它相关资源也是需要时间的。但是,当任务涉及到I/O操作并且任务之间不互相依赖时,那么并行化就是一个不错的选择。通常而言,将这类程序并行化之后,执行速度会提升好几个等级。

对于问题2,如果任务之间是独立的,并且代码中不涉及到对同一个对象的某个状态或者某个变量的更新操作,那么就表明代码是可以被并行化的。

对于问题3,由于在并行环境中任务的执行顺序是不确定的,因此对于依赖于顺序的任务而言,并行化也许不能给出正确的结果。

 

本文转载自:https://blog.csdn.net/u011001723/article/details/52794455

posted @ 2024-10-24 09:28  CharyGao  阅读(34)  评论(0编辑  收藏  举报