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

文章目录


前言

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


一、parallelStream是什么

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

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

parallelStream()方法的源码如下:

    /**
     * @return a possibly parallel {@code Stream} over the elements in this
     * collection
     * @since 1.8
     */

    default Stream<E> parallelStream() {
        return StreamSupport.stream(spliterator(), true);
    }

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


二、parallelStream原理分析

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

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

但是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 提交有返回值的任务

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.RecursiveTask;
import java.util.stream.IntStream;

/**
 * @Description 提交有返回值的任务
 */
 
public class ForkJoinRecursiveTask {

    /**
     * 最大计算数
     */
    private static final int MAX_THRESHOLD = 100;

    public static void main(String[] args) {
        //创建ForkJoinPool
        ForkJoinPool pool = new ForkJoinPool();
        //异步提交RecursiveTask任务
        ForkJoinTask<Integer> forkJoinTask = pool.submit(new CalculatedRecursiveTask(0, 1000));
        try {
            //根据返回类型获取返回值
            Integer result = forkJoinTask.get();
            System.out.println("执行结果为:" + result);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        } finally {
            pool.shutdown();
        }
    }

    private static class CalculatedRecursiveTask extends RecursiveTask<Integer> {
        private final int start;
        private final int end;

        public CalculatedRecursiveTask(int start, int end) {
            this.start = start;
            this.end = end;
        }

        @Override
        protected Integer compute() {
            //判断计算范围,如果小于等于5,那么一个线程计算即可,否则进行分割
            if ((end - start) <= MAX_THRESHOLD) {
                //返回[start,end]的总和
                return IntStream.rangeClosed(start, end).sum();
            } else {
                //任务分割
                int middle = (end + start) / 2;
                CalculatedRecursiveTask task1 = new CalculatedRecursiveTask(start, middle);
                CalculatedRecursiveTask task2 = new CalculatedRecursiveTask(middle + 1, end);
                //执行
                task1.fork();
                task2.fork();
                //等待返回结果
                return task1.join() + task2.join();
            }
        }
    }
}

执行结果如下:

 2.2 提交无返回值的任务

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveAction;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;

/**
 * @Description 提交无返回值的任务
 */

public class ForkJoinRecursiveAction {

    /**
     * 最大计算数
     */
    private static final int MAX_THRESHOLD = 100;
    private static final AtomicInteger SUM = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        //创建ForkJoinPool
        ForkJoinPool pool = new ForkJoinPool();
        //异步提交RecursiveAction任务
        pool.submit(new CalculatedRecursiveTask(0, 1000));
        //等待3秒后输出结果,因为计算需要时间
        pool.awaitTermination(1, TimeUnit.SECONDS);
        System.out.println("结果为:" + SUM);
        pool.shutdown();
    }

    private static class CalculatedRecursiveTask extends RecursiveAction {
        private final int start;
        private final int end;

        public CalculatedRecursiveTask(int start, int end) {
            this.start = start;
            this.end = end;
        }

        @Override
        protected void compute() {
            //判断计算范围,如果小于等于5,那么一个线程计算即可,否则进行分割
            if ((end - start) <= MAX_THRESHOLD) {
                //因为没有返回值,所有这里如果要获取结果,需要存入公共的变量中
                SUM.addAndGet(IntStream.rangeClosed(start, end).sum());
            } else {
                //任务分割
                int middle = (end + start) / 2;
                CalculatedRecursiveTask task1 = new CalculatedRecursiveTask(start, middle);
                CalculatedRecursiveTask task2 = new CalculatedRecursiveTask(middle + 1, end);
                //执行
                task1.fork();
                task2.fork();
            }
        }
    }
}

执行结果如下:

虽然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. 因为是并行流,所以所涉及到的数据结构需要使用线程安全的。例如

    listByPage.parallelStream().forEach(str-> {
               //使用线程安全的数据结构
               //ConcurrentHashMap
               //CopyOnWriteArrayList
               //等等进行操作
            });
  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、线程池、使用方法进行了深刻的分析。

猜你喜欢

转载自blog.csdn.net/Clearlove_S7/article/details/130183990