Java8-Lambda编程[4] 串行与并行

引言

程序运行的方式可以分为三种:串行(Sequential)、并行(Parallel)、并发(Concurrent)。串行是最一般的情况,程序会按顺序执行每个任务,效率往往十分低下。与之相对的是并行,多个任务会同时运行在不同的cpu线程上,效率较高,但受限于cpu线程数,如果任务数量超过了CPU线程数,那么每个线程上的任务仍然是顺序执行的。而并发是指多个线程在宏观(相对于较长的时间区间而言)上表现为同时执行,而实际上是轮流穿插着执行,并发与并行串行并不是互斥的概念,如果是在一个CPU线程上启用并发,那么自然就还是串行的,而如果在多个线程上启用并发,那么程序的执行就可以是既并发又并行的。如下图:

《Java8-Lambda编程[4] 串行与并行》 uA4CxPzHl6Y53va.jpg

指尖轻扣 开启并行

我们之前很多地方用到了流操作来对集合进行遍历操作。默认情况下流是串行的,效率较低,为了提高流的效率,可以将串行流变成并行流。收集类在Java8中除了新加了stream方法,还有一个与之对应的parallelStream方法,调用该方法即可生成一个并行的流。而对于已经生成的流,我们也只需要调用一下parallel方法便能将其简单转化为并行。

例4.1:
    Stream.iterate(1, i -> i + 1)
            .limit(1000)
            .parallel()
            .reduce(0, Integer::sum);

上述代码从1开始数到1000然后求和,我们在最后一步之前调用了parallel方法将流设为并行。parallel方法的实现在AbstractPipeline抽象类中,仅仅将一个名为parallel的标志位置成了true,然后返回自身,这里大家不必深究源码,下一章我会专门带大家探索一下。

并行出错 改回串行

parallel有个与之相对的方法sequential,可用来手动将流设为串行,以防有一些操作并不适合并行,如下面的例子。

例4.2:
    System.out.println(Stream.iterate(1, i -> i + 1)
            .limit(1000)
            .sequential()
            .reduce(0, (a, b) -> a - b));

正确的结果是-500500,但如果将上面的代码改为并行的,则会输出0。原因在于并行流会将流中的元素拆分开来分别进行计算再将结果合并,而我们要进行的操作是求当前元素与之前结果的差,结果应当是1到1000累加的相反数,而并行流由于分割开来求差,再对每一部分的差值进行合并,对两个差值再次求取差值,结果自然为0。

上面的例子就不适合并行,是不能对其进行简单随机的分支合并操作。此外还有两种情况也不太适合并行,这两种情况虽然不会导致计算出错,效率上却可能比串行还要低。一种是选择了不恰当的数据结构来生成流,如我们第一个例子中由iterate方法生成的流,还有链表等不易随机分割的数据结构所生成的流。由于其在分支合并操作上浪费掉的时间,可能足以抵消掉并行所节省的时间,甚至更加浪费时间,所以不推荐直接对其开启并行。再就是老生常谈的包装类的装包拆包操作,这个问题同样在我们第一个例子中出现,频繁的进行装包拆包,增加了完全不必要的时间开销。针对这个问题,我们仍可以跟前面一样,通过改用IntStream等基本类型流来解决。

并行串行 不可兼得

明白了并行与串行的适用场景,我们就可以来思考下面这个问题,如果一次级联的Stream操作,既需要并行执行一些操作,又需要串行执行其他操作,我们该则么办呢?可不可以两个方法都调用?答案当然是可以,因为两个方法的实现都只是改变了一个标志位而已,我们可以花式重复调用这两个方法,但令人失望的是只有最后一次调用会生效。因为流的运行不同于我们手动的foreach迭代,流的运行宏观上是将所有操作综合在一起运行的,迭代过程中并不会反复调用parallel与sequential,而是在一开始要迭代的时候就已经决定了流的属性。这一点可以通过下一章的源码分析看得很明白。

既然流只能是完全并行或串行的,而无法做到部分并行部分串行,设计者又为什么要给出sequential方法呢?如果我们不调用parallel方法,流默认就是串行的,调用之后流又只能是完全并行的,那么sequential还有什么用处和意义呢?可以考虑这样一种情况,我们调用一个别人写好的函数,这个函数返回一个Stream对象,这个Stream调用了一些延时求值的方法,并可以通过函数签名与注释可以知道它是并行流。而现在你需要对这个流进一步处理,并且在最后调用一个及时求值方法来使前面的所有方法都得到执行,但是这时你发现你最后调用的reduce方法与我们前面例子中一样不适合并行求值,那么你该怎么办呢?显然只有调用一个名为sequential的方法来将流转化为串行的。

强行并行 引入Spliterator接口

并行有三种不适宜的情况,其中后两种只会降低效率且可以通过更换数据结构和调用基本类型流来解决,而第一种情况会在分割流的过程中出现错误,导致并行根本无法使用,那么有没有解决办法呢?这就需要我们先了解一下它底层的分支合并框架了,对此本文就不做深究,有兴趣的读者可以自行查询资料。而要想解决分支合并后出错的问题,我们就得自己告诉要并行的流,到底需要按照什么样的规则来分割流中的元素,分割后又该按照什么规则来合并每个分支。为此,就需要引入Java8中一个新增的迭代器Spliterator,该接口与Iterator、Enumeration一样都可以用来对收集元素进行迭代。Spliterator的特点在于,它不是一一顺序迭代的,而是专为分支合并框架而设计,实现该接口需要实现下面四个无默认方法的方法:

public interface Spliterator<T> {
    boolean tryAdvance(Consumer<? super T> action);
    Spliterator<T> trySplit();
    long estimateSize();
    int characteristics();
}

这里我只简单讲讲前两个方法。tryAdvance方法用来处理当前迭代元素并判断后面还有没有要迭代的元素,有点类似Iterator的next方法与hasNext方法的结合。trySplite用来判断当前分支是否还可以继续拆分,如果可以拆分,就按照我们设计的规则,从当前位置一直拆分到合适的拆分位置,并返回一个包含该部分元素的新Spliterator。反之如果分支已经足够小不可以再继续拆分,或者后面已经没有元素了,就直接返回null,不再进行拆分。
实现了自己的Spliterator后,我们又该怎么使用它呢,stream类也没有一个可以传入Spliterator的方法。这里我们就要借助StreamSupport辅助类,该类有两个名为stream的方法,其中一个用法如下。

例4.3:
    Spliterator<String> spliterator=new MySpliterator<String>();//伪代码
    Stream<String> stream= StreamSupport.stream(spliterator,true);

该方法传一个Spliterator参数来手动规定分割操作,并返回一个流,第二个参数代表着是否开启并行。另一个重载方法则是要传入一个Supplier参数来生成Spliterator。StreamSupport类还有几个类似的方法,看返回值可以知道是生成基本类型流用的。之所以不详细讲,是因为用到的地方不多,真的需要用这么高级的功能的话,想必使用者的知识水平也应该高于我,这里就不班门弄斧了。不过Spliterator虽然平时用不到,但它在Stream的实现中却起到了十分核心的作用,这一点会在下一章的源码探索中为大家展示。

小结

开启并行后,程序运行速度到底能快多少,一般取决于CPU的线程数,这个应该不难理解,如果任务数量超过了CPU线程数,那么速度一般就难以再提升了。最后还要注意一点,有些操作天生就是自带串行属性的,如我们上面举的求差反例,每一次迭代操作都需要上一次操作的结果,并且对操作顺序有着严格的要求,这类操作即使我们传入Spliterator来拯救他,也只能保证结果正确,效率却会非常低。因为这样的并行,实际上根本无法分割流程,反而在进行尝试分割与合并的时候在做出判断上浪费了大量时间,得不偿失!

    原文作者:斯特殿下
    原文地址: https://www.jianshu.com/p/0a67de388b51
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞