| OS | CentOS 6.7 x86_64 |
| CPU | Intel Xeon X5675, 12M Cache 3.06 GHz, 6 Cores 12 Threads |
| 内存 | 96GB |
| JDK | java version 1.8.0_91, Java HotSpot(TM) 64-Bit Server VM |
31 |
32 | 图中展示的是for循环外部迭代耗时为基准的时间比值。分析如下:
33 |
34 | 1. 对于基本类型Stream串行迭代的性能开销明显高于外部迭代开销(两倍);
35 | 2. Stream并行迭代的性能比串行迭代和外部迭代都好。
36 |
37 | 并行迭代性能跟可利用的核数有关,上图中的并行迭代使用了全部12个核,为考察使用核数对性能的影响,我们专门测试了不同核数下的Stream并行迭代效果:
38 |
39 |
40 |
41 | 分析,对于基本类型:
42 |
43 | 1. 使用Stream并行API在单核情况下性能很差,比Stream串行API的性能还差;
44 | 2. 随着使用核数的增加,Stream并行效果逐渐变好,比使用for循环外部迭代的性能还好。
45 |
46 | 以上两个测试说明,对于基本类型的简单迭代,Stream串行迭代性能更差,但多核情况下Stream迭代时性能较好。
47 |
48 |
49 | ## 实验二 对象迭代
50 |
51 | 再来看对象的迭代效果。
52 |
53 | 测试内容:找出字符串列表中最小的元素(自然顺序),对比for循环外部迭代和Stream API内部迭代性能。
54 |
55 | 测试程序[StringTest](./perf/StreamBenchmark/src/lee/StringTest.java),测试结果如下图:
56 |
57 |
58 |
59 | 结果分析如下:
60 |
61 | 1. 对于对象类型Stream串行迭代的性能开销仍然高于外部迭代开销(1.5倍),但差距没有基本类型那么大。
62 | 2. Stream并行迭代的性能比串行迭代和外部迭代都好。
63 |
64 | 再来单独考察Stream并行迭代效果:
65 |
66 |
67 |
68 | 分析,对于对象类型:
69 |
70 | 1. 使用Stream并行API在单核情况下性能比for循环外部迭代差;
71 | 2. 随着使用核数的增加,Stream并行效果逐渐变好,多核带来的效果明显。
72 |
73 | 以上两个测试说明,对于对象类型的简单迭代,Stream串行迭代性能更差,但多核情况下Stream迭代时性能较好。
74 |
75 | ## 实验三 复杂对象归约
76 |
77 | 从实验一、二的结果来看,Stream串行执行的效果都比外部迭代差(很多),是不是说明Stream真的不行了?先别下结论,我们再来考察一下更复杂的操作。
78 |
79 | 测试内容:给定订单列表,统计每个用户的总交易额。对比使用外部迭代手动实现和Stream API之间的性能。
80 |
81 | 我们将订单简化为`
84 |
85 | 分析,对于复杂的归约操作:
86 |
87 | 1. Stream API的性能普遍好于外部手动迭代,并行Stream效果更佳;
88 |
89 | 再来考察并行度对并行效果的影响,测试结果如下:
90 |
91 |
92 |
93 | 分析,对于复杂的归约操作:
94 |
95 | 1. 使用Stream并行归约在单核情况下性能比串行归约以及手动归约都要差,简单说就是最差的;
96 | 2. 随着使用核数的增加,Stream并行效果逐渐变好,多核带来的效果明显。
97 |
98 | 以上两个实验说明,对于复杂的归约操作,Stream串行归约效果好于手动归约,在多核情况下,并行归约效果更佳。我们有理由相信,对于其他复杂的操作,Stream API也能表现出相似的性能表现。
99 |
100 |
101 | ## 结论
102 |
103 | 上述三个实验的结果可以总结如下:
104 |
105 | 1. 对于简单操作,比如最简单的遍历,Stream串行API性能明显差于显示迭代,但并行的Stream API能够发挥多核特性。
106 | 2. 对于复杂操作,Stream串行API性能可以和手动实现的效果匹敌,在并行执行时Stream API效果远超手动实现。
107 |
108 | 所以,如果出于性能考虑,1. 对于简单操作推荐使用外部迭代手动实现,2. 对于复杂操作,推荐使用Stream API, 3. 在多核情况下,推荐使用并行Stream API来发挥多核优势,4.单核情况下不建议使用并行Stream API。
109 |
110 | 如果出于代码简洁性考虑,使用Stream API能够写出更短的代码。即使是从性能方面说,尽可能的使用Stream API也另外一个优势,那就是只要Java Stream类库做了升级优化,代码不用做任何修改就能享受到升级带来的好处。
111 |
--------------------------------------------------------------------------------
/perf/StreamBenchmark/src/lee/ReductionTest.java:
--------------------------------------------------------------------------------
1 | package lee;
2 |
3 | import java.util.ArrayList;
4 | import java.util.HashMap;
5 | import java.util.List;
6 | import java.util.Map;
7 | import java.util.Random;
8 | import java.util.UUID;
9 | import java.util.stream.Collectors;
10 |
11 | /**
12 | * java -server -Xms10G -Xmx10G -XX:+PrintGCDetails
13 | * -XX:+UseConcMarkSweepGC -XX:CompileThreshold=1000 lee/ReductionTest
14 | * taskset -c 0-[0,1,3,7] java ...
15 | * @author CarpenterLee
16 | */
17 | public class ReductionTest {
18 |
19 | public static void main(String[] args) {
20 | new ReductionTest().doTest();
21 | }
22 | public void doTest(){
23 | warmUp();
24 | int[] lengths = {
25 | 10000,
26 | 100000,
27 | 1000000,
28 | 10000000,
29 | 20000000,
30 | 40000000
31 | };
32 | for(int length : lengths){
33 | System.out.println(String.format("---orders length: %d---", length));
34 | List
16 |
17 | 图中4种*stream*接口继承自`BaseStream`,其中`IntStream, LongStream, DoubleStream`对应三种基本类型(`int, long, double`,注意不是包装类型),`Stream`对应所有剩余类型的*stream*视图。为不同数据类型设置不同*stream*接口,可以1.提高性能,2.增加特定接口函数。
18 |
19 |
22 |
23 | 你可能会奇怪为什么不把`IntStream`等设计成`Stream`的子接口?毕竟这接口中的方法名大部分是一样的。答案是这些方法的名字虽然相同,但是返回类型不同,如果设计成父子接口关系,这些方法将不能共存,因为Java不允许只有返回类型不同的方法重载。
24 |
25 | 虽然大部分情况下*stream*是容器调用`Collection.stream()`方法得到的,但*stream*和*collections*有以下不同:
26 |
27 | - **无存储**。*stream*不是一种数据结构,它只是某种数据源的一个视图,数据源可以是一个数组,Java容器或I/O channel等。
28 | - **为函数式编程而生**。对*stream*的任何修改都不会修改背后的数据源,比如对*stream*执行过滤操作并不会删除被过滤的元素,而是会产生一个不包含被过滤元素的新*stream*。
29 | - **惰式执行**。*stream*上的操作并不会立即执行,只有等到用户真正需要结果的时候才会执行。
30 | - **可消费性**。*stream*只能被“消费”一次,一旦遍历过就会失效,就像容器的迭代器那样,想要再次遍历必须重新生成。
31 |
32 | 对*stream*的操作分为为两类,**中间操作(*intermediate operations*)和结束操作(*terminal operations*)**,二者特点是:
33 |
34 | 1. __中间操作总是会惰式执行__,调用中间操作只会生成一个标记了该操作的新*stream*,仅此而已。
35 | 2. __结束操作会触发实际计算__,计算发生时会把所有中间操作积攒的操作以*pipeline*的方式执行,这样可以减少迭代次数。计算完成之后*stream*就会失效。
36 |
37 | 如果你熟悉Apache Spark RDD,对*stream*的这个特点应该不陌生。
38 |
39 | 下表汇总了`Stream`接口的部分常见方法:
40 |
41 | |操作类型|接口方法|
42 | |--------|--------|
43 | |中间操作|concat() distinct() filter() flatMap() limit() map() peek()
65 |
66 | 函数原型为`Stream
80 |
81 | 函数原型为`Stream
108 |
109 | 函数原型为`
121 |
122 | 函数原型为`
30 |
31 | 需求:*求出一组单词的长度之和*。这是个“求和”操作,操作对象输入类型是*String*,而结果类型是*Integer*。
32 | ```Java
33 | // 求单词长度之和
34 | Stream
88 |
89 | 收集器(*Collector*)是为`Stream.collect()`方法量身打造的工具接口(类)。考虑一下将一个*Stream*转换成一个容器(或者*Map*)需要做哪些工作?我们至少需要两样东西:
90 |
91 | 1. 目标容器是什么?是*ArrayList*还是*HashSet*,或者是个*TreeMap*。
92 | 2. 新元素如何添加到容器中?是`List.add()`还是`Map.put()`。
93 |
94 | 如果并行的进行规约,还需要告诉*collect()* 3. 多个部分结果如何合并成一个。
95 |
96 | 结合以上分析,*collect()*方法定义为`| Stream操作分类 | ||
| 中间操作(Intermediate operations) | 无状态(Stateless) | unordered() filter() map() mapToInt() mapToLong() mapToDouble() flatMap() flatMapToInt() flatMapToLong() flatMapToDouble() peek() |
| 有状态(Stateful) | distinct() sorted() sorted() limit() skip() | |
| 结束操作(Terminal operations) | 非短路操作 | forEach() forEachOrdered() toArray() reduce() collect() max() min() count() |
| 短路操作(short-circuiting) | anyMatch() allMatch() noneMatch() findFirst() findAny() | |
39 |
40 | 仍然考虑上述求最长字符串的程序,一种直白的流水线实现方式是为每一次函数调用都执一次迭代,并将处理中间结果放到某种数据结构中(比如数组,容器等)。具体说来,就是调用`filter()`方法后立即执行,选出所有以*A*开头的字符串并放到一个列表list1中,之后让list1传递给`mapToInt()`方法并立即执行,生成的结果放到list2中,最后遍历list2找出最大的数字作为最终结果。程序的执行流程如如所示:
41 |
42 | 这样做实现起来非常简单直观,但有两个明显的弊端:
43 |
44 | 1. 迭代次数多。迭代次数跟函数调用的次数相等。
45 | 2. 频繁产生中间结果。每次函数调用都产生一次中间结果,存储开销无法接受。
46 |
47 | 这些弊端使得效率底下,根本无法接受。如果不使用Stream API我们都知道上述代码该如何在一次迭代中完成,大致是如下形式:
48 |
49 | ```Java
50 | int longest = 0;
51 | for(String str : strings){
52 | if(str.startsWith("A")){// 1. filter(), 保留以A开头的字符串
53 | int len = str.length();// 2. mapToInt(), 转换成长度
54 | longest = Math.max(len, longest);// 3. max(), 保留最长的长度
55 | }
56 | }
57 | ```
58 |
59 | 采用这种方式我们不但减少了迭代次数,也避免了存储中间结果,显然这就是流水线,因为我们把三个操作放在了一次迭代当中。只要我们事先知道用户意图,总是能够采用上述方式实现跟Stream API等价的功能,但问题是Stream类库的设计者并不知道用户的意图是什么。如何在无法假设用户行为的前提下实现流水线,是类库的设计者要考虑的问题。
60 |
61 | ## Stream流水线解决方案
62 |
63 | 我们大致能够想到,应该采用某种方式记录用户每一步的操作,当用户调用结束操作时将之前记录的操作叠加到一起在一次迭代中全部执行掉。沿着这个思路,有几个问题需要解决:
64 |
65 | 1. 用户的操作如何记录?
66 | 2. 操作如何叠加?
67 | 3. 叠加之后的操作如何执行?
68 | 4. 执行后的结果(如果有)在哪里?
69 |
70 | ### >> 操作如何记录
71 |
72 |
73 |
74 | 注意这里使用的是“*操作(operation)*”一词,指的是“Stream中间操作”的操作,很多Stream操作会需要一个回调函数(Lambda表达式),因此一个完整的操作是<*数据来源,操作,回调函数*>构成的三元组。Stream中使用Stage的概念来描述一个完整的操作,并用某种实例化后的*PipelineHelper*来代表Stage,将具有先后顺序的各个Stage连到一起,就构成了整个流水线。跟Stream相关类和接口的继承关系图示。
75 |
76 | 还有*IntPipeline, LongPipeline, DoublePipeline*没在图中画出,这三个类专门为三种基本类型(不是包装类型)而定制的,跟*ReferencePipeline*是并列关系。图中*Head*用于表示第一个Stage,即调用调用诸如*Collection.stream()*方法产生的Stage,很显然这个Stage里不包含任何操作;*StatelessOp*和*StatefulOp*分别表示无状态和有状态的Stage,对应于无状态和有状态的中间操作。
77 |
78 | Stream流水线组织结构示意图如下:
79 |
80 |
81 |
82 | 图中通过`Collection.stream()`方法得到*Head*也就是stage0,紧接着调用一系列的中间操作,不断产生新的Stream。**这些Stream对象以双向链表的形式组织在一起,构成整个流水线,由于每个Stage都记录了前一个Stage和本次的操作以及回调函数,依靠这种结构就能建立起对数据源的所有操作**。这就是Stream记录操作的方式。
83 |
84 | ### >> 操作如何叠加
85 |
86 | 以上只是解决了操作记录的问题,要想让流水线起到应有的作用我们需要一种将所有操作叠加到一起的方案。你可能会觉得这很简单,只需要从流水线的head开始依次执行每一步的操作(包括回调函数)就行了。这听起来似乎是可行的,但是你忽略了前面的Stage并不知道后面Stage到底执行了哪种操作,以及回调函数是哪种形式。换句话说,只有当前Stage本身才知道该如何执行自己包含的动作。这就需要有某种协议来协调相邻Stage之间的调用关系。
87 |
88 | 这种协议由*Sink*接口完成,*Sink*接口包含的方法如下表所示:
89 |
90 | | 方法名 | 作用 |
| void begin(long size) | 开始遍历元素之前调用该方法,通知Sink做好准备。 |
| void end() | 所有元素遍历完成之后调用,通知Sink没有更多的元素了。 |
| boolean cancellationRequested() | 是否可以结束操作,可以让短路操作尽早结束。 |
| void accept(T t) | 遍历元素时调用,接受一个待处理元素,并对元素进行处理。Stage把自己包含的操作和回调方法封装到该方法里,前一个Stage只需要调用当前Stage.accept(T t)方法就行了。 |
174 |
175 | Sink完美封装了Stream每一步操作,并给出了[处理->转发]的模式来叠加操作。这一连串的齿轮已经咬合,就差最后一步拨动齿轮启动执行。是什么启动这一连串的操作呢?也许你已经想到了启动的原始动力就是结束操作(Terminal Operation),一旦调用某个结束操作,就会触发整个流水线的执行。
176 |
177 | 结束操作之后不能再有别的操作,所以结束操作不会创建新的流水线阶段(Stage),直观的说就是流水线的链表不会在往后延伸了。结束操作会创建一个包装了自己操作的Sink,这也是流水线中最后一个Sink,这个Sink只需要处理数据而不需要将结果传递给下游的Sink(因为没有下游)。对于Sink的[处理->转发]模型,结束操作的Sink就是调用链的出口。
178 |
179 | 我们再来考察一下上游的Sink是如何找到下游Sink的。一种可选的方案是在*PipelineHelper*中设置一个Sink字段,在流水线中找到下游Stage并访问Sink字段即可。但Stream类库的设计者没有这么做,而是设置了一个`Sink AbstractPipeline.opWrapSink(int flags, Sink downstream)`方法来得到Sink,该方法的作用是返回一个新的包含了当前Stage代表的操作以及能够将结果传递给downstream的Sink对象。为什么要产生一个新对象而不是返回一个Sink字段?这是因为使用opWrapSink()可以将当前操作与下游Sink(上文中的downstream参数)结合成新Sink。试想只要从流水线的最后一个Stage开始,不断调用上一个Stage的opWrapSink()方法直到最开始(不包括stage0,因为stage0代表数据源,不包含操作),就可以得到一个代表了流水线上所有操作的Sink,用代码表示就是这样:
180 |
181 | ```Java
182 | // AbstractPipeline.wrapSink()
183 | // 从下游向上游不断包装Sink。如果最初传入的sink代表结束操作,
184 | // 函数返回时就可以得到一个代表了流水线上所有操作的Sink。
185 | final | 返回类型 | 对应的结束操作 |
| boolean | anyMatch() allMatch() noneMatch() |
| Optional | findFirst() findAny() |
| 归约结果 | reduce() collect() |
| 数组 | toArray() |