摘要:Stream流式计算,本文讲解了Stream流式计算的概念,具体的使用步骤以及源码实现,最后讲解了使用Stream过程中需要注意的事项。Stream在公司项目中被频繁使用,在性能优化上具有广泛的使用场景,通过少量的代码即可优雅地实现并行计算。参考资料:刘超的《java性能调优实战》,公众号《java之间》
文章目录
1、什么是 Stream?
在 Java8 之前,我们通常是通过 for 循环或者 Iterator 迭代来重新排序合并数据,又或者通过重新定义 Collections.sorts 的 Comparator 方法来实现,这两种方式对于大数据量系统来说,效率并不是很理想。
Java8 中添加了一个新的接口类 Stream,他和我们之前接触的字节流概念不太一样,Java8 集合中的 Stream 相当于高级版的 Iterator,他通过 Lambda 表达式对集合进行各种非常便利、高效的聚合操作(Aggregate Operation),或者大批量数据操作 (Bulk Data Operation)。
Stream原理:将要处理的元素看做一种流,流在管道中传输,并且可以在管道的节点上处理,包括过滤筛选、去重、排序、聚合等。元素流在管道中进过中间操作的处理,最后由最终操作得到前面处理的结果。
例子1:需求是过滤分组一所中学里身高在 160cm 以上的男女同学
- 方法1:使用传统的迭代方式来实现
Map<String, List<Student>> stuMap = new HashMap<String, List<Student>>();
for (Student stu: studentsList) {
if (stu.getHeight() > 160) { // 如果身高大于 160
if (stuMap.get(stu.getSex()) == null) { // 该性别还没分类
List<Student> list = new ArrayList<Student>(); // 新建该性别学生的列表
list.add(stu);// 将学生放进去列表
stuMap.put(stu.getSex(), list);// 将列表放到 map 中
} else { // 该性别分类已存在
stuMap.get(stu.getSex()).add(stu);// 该性别分类已存在,则直接放进去即可
}
}
}
- 方法2:Java8 中的 Stream API 进行实现
- stream() - 为集合创建串行流
Map<String, List<Student>> stuMap = stuList.stream().filter((Student s) -> s.getHeight() > 160) .collect(Collectors.groupingBy(Student ::getSex));
- parallelStream() - 为集合创建并行流
Map<String, List<Student>> stuMap = stuList.parallelStream().filter((Student s) -> s.getHeight() > 160) .collect(Collectors.groupingBy(Student ::getSex));
2、Stream 如何优化遍历?
2.1.Stream 操作分类
Stream 的操作分为两大类:中间操作(Intermediate operations)和终结操作(Terminal operations)。我们通常还会将中间操作称为懒操作,也正是由这种懒操作结合终结操作、数据源构成的处理管道(Pipeline),实现了 Stream 的高效。
操作类型 | 详情 | 备注 |
---|---|---|
中间操作 | 只对操作进行了记录,即只会返回一个流,不会进行计算操作 | 可以分为无状态(Stateless)与有状态(Stateful)操作,前者是指元素的处理不受之前元素的影响,后者是指该操作只有拿到所有元素之后才能继续下去。 |
终结操作 | 终结操作是实现了计算操作 | 可以分为短路(Short-circuiting)与非短路(Unshort-circuiting)操作,前者是指遇到某些符合条件的元素就可以得到最终结果,后者是指必须处理完所有元素才能得到最终结果。 |
- 操作分类详情如下图所示
举例说明:
1、新建一个Student类
@Data
public class Student {
private Long id;
private String name;
private int age;
private String address;
public Student() {}
public Student(Long id, String name, int age, String address) {
this.id = id;
this.name = name;
this.age = age;
this.address = address;
}
@Override
public String toString() {
return "Student{" + "id=" + id + ", name='" + name + '\'' +
", age=" + age + ", address='" + address + '\'' + '}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Student student = (Student) o;
return age == student.age &&
Objects.equals(id, student.id) &&
Objects.equals(name, student.name) &&
Objects.equals(address, student.address);
}
@Override
public int hashCode() {
return Objects.hash(id, name, age, address);
}
}
2、执行Stream操作
public static void main(String [] args) {
Student s1 = new Student(1L, "肖战", 15, "浙江");
Student s2 = new Student(2L, "王一博", 15, "湖北");
Student s3 = new Student(3L, "杨紫", 17, "北京");
Student s4 = new Student(4L, "李现", 17, "浙江");
List<Student> students = new ArrayList<>();
students.add(s1);
students.add(s2);
students.add(s3);
students.add(s4);
List<Student> streamStudents = testFilter(students);
streamStudents.forEach(System.out::println);
}
/**
* 1、集合的筛选
* @param students
* @return
*/
private static List<Student> testFilter(List<Student> students) {
//筛选年龄大于15岁的学生
//return students.stream().filter(s -> s.getAge()>15).collect(Collectors.toList());
//筛选住在浙江省的学生
return students.stream().filter(s ->"浙江".equals(s.getAddress())).collect(Collectors.toList());
}
/**
* 2、集合转换
* @param students
* @return
*/
private static void testMap(List<Student> students) {
//在地址前面加上部分信息,只获取地址输出
List<String> addresses = students.stream().map(s ->"住址:"+s.getAddress()).collect(Collectors.toList());
addresses.forEach(a ->System.out.println(a));
}
/**
* 3、集合去重(基本类型)
*/
private static void testDistinct1() {
//简单字符串的去重
List<String> list = Arrays.asList("111","222","333","111","222");
list.stream().distinct().forEach(System.out::println);
}
/**
* 3、集合去重(引用对象)
* 两个重复的“肖战”同学进行了去重,这不仅因为使用了distinct()方法,而且因为Student对象重写了equals和hashCode()方法,否则去重是无效的
*/
private static void testDistinct2() {
//引用对象的去重,引用对象要实现hashCode和equal方法,否则去重无效
Student s1 = new Student(1L, "肖战", 15, "浙江");
Student s2 = new Student(2L, "王一博", 15, "湖北");
Student s3 = new Student(3L, "杨紫", 17, "北京");
Student s4 = new Student(4L, "李现", 17, "浙江");
Student s5 = new Student(1L, "肖战", 15, "浙江");
List<Student> students = new ArrayList<>();
students.add(s1);
students.add(s2);
students.add(s3);
students.add(s4);
students.add(s5);
students.stream().distinct().forEach(System.out::println);
}
/**
* 4、集合排序(默认排序)
*/
private static void testSort1() {
List<String> list = Arrays.asList("333","222","111");
list.stream().sorted().forEach(System.out::println);
}
/**
* 4、集合排序(指定排序规则)
* 先按照学生的id进行降序排序,再按照年龄进行降序排序
*
*/
private static void testSort2() {
Student s1 = new Student(1L, "肖战", 15, "浙江");
Student s2 = new Student(2L, "王一博", 15, "湖北");
Student s3 = new Student(3L, "杨紫", 17, "北京");
Student s4 = new Student(4L, "李现", 17, "浙江");
List<Student> students = new ArrayList<>();
students.add(s1);
students.add(s2);
students.add(s3);
students.add(s4);
students.stream()
.sorted((stu1,stu2) ->Long.compare(stu2.getId(), stu1.getId()))
.sorted((stu1,stu2) -> Integer.compare(stu2.getAge(),stu1.getAge()))
.forEach(System.out::println);
}
/**
* 5、集合limit,返回前几个元素
*/
private static void testLimit() {
List<String> list = Arrays.asList("333","222","111");
list.stream().limit(2).forEach(System.out::println);
}
/**
* 6、集合skip,删除前n个元素
*/
private static void testSkip() {
List<String> list = Arrays.asList("333","222","111");
list.stream().skip(2).forEach(System.out::println);
}
/**
* 7、集合reduce,将集合中每个元素聚合成一条数据
*/
private static void testReduce() {
List<String> list = Arrays.asList("欢","迎","你");
String appendStr = list.stream().reduce("北京",(a,b) -> a+b);
System.out.println(appendStr);
}
/**
* 8、求集合中元素的最小值
* 求所有学生中年龄最小的一个
*/
private static void testMin() {
Student s1 = new Student(1L, "肖战", 14, "浙江");
Student s2 = new Student(2L, "王一博", 15, "湖北");
Student s3 = new Student(3L, "杨紫", 17, "北京");
Student s4 = new Student(4L, "李现", 17, "浙江");
List<Student> students = new ArrayList<>();
students.add(s1);
students.add(s2);
students.add(s3);
students.add(s4);
Student minS = students.stream().min((stu1,stu2) ->Integer.compare(stu1.getAge(),stu2.getAge())).get();
System.out.println(minS.toString());
}
/**
* 9、anyMatch/allMatch/noneMatch(匹配)
* anyMatch:Stream 中任意一个元素符合传入的 predicate,返回 true
allMatch:Stream 中全部元素符合传入的 predicate,返回 true
noneMatch:Stream 中没有一个元素符合传入的 predicate,返回 true
*/
private static void testMatch() {
Student s1 = new Student(1L, "肖战", 15, "浙江");
Student s2 = new Student(2L, "王一博", 15, "湖北");
Student s3 = new Student(3L, "杨紫", 17, "北京");
Student s4 = new Student(4L, "李现", 17, "浙江");
List<Student> students = new ArrayList<>();
students.add(s1);
students.add(s2);
students.add(s3);
students.add(s4);
Boolean anyMatch = students.stream().anyMatch(s ->"湖北".equals(s.getAddress()));
if (anyMatch) {
System.out.println("有湖北人");
}
Boolean allMatch = students.stream().allMatch(s -> s.getAge()>=15);
if (allMatch) {
System.out.println("所有学生都满15周岁");
}
Boolean noneMatch = students.stream().noneMatch(s -> "杨洋".equals(s.getName()));
if (noneMatch) {
System.out.println("没有叫杨洋的同学");
}
}
}
2.2 Stream 源码实现
- Stream相关的类
BaseStream 和 Stream 为最顶端的接口类。BaseStream 主要定义了流的基本接口方法,例如,spliterator、isParallel 等;Stream 则定义了一些流的常用操作方法,例如,map、filter 等
ReferencePipeline 是一个结构类,他通过定义内部类组装了各种操作流。他定义了 Head、StatelessOp、StatefulOp 三个内部类,实现了 BaseStream 与 Stream 的接口方法
Sink 接口是定义每个 Stream 操作之间关系的协议,他包含 begin()、end()、cancellationRequested()、accept() 四个方法
ReferencePipeline 最终会将整个 Stream 流操作组装成一个调用链,而这条调用链上的各个 Stream 操作的上下关系就是通过 Sink 接口协议来定义实现的
2.3 Stream 操作叠加
管道结构通常是由 ReferencePipeline 类实现的,前面讲解 Stream 包结构时,我提到过 ReferencePipeline 包含了 Head、StatelessOp、StatefulOp 三种内部类。
名词 | 作用 |
---|---|
Head | 主要用来定义数据源操作,在我们初次调用 names.stream() 方法时,会初次加载 Head 对象,此时为加载数据源操作 |
StatelessOp | 无状态中间操作 |
StatefulOp | 有状态中间操作 |
Stage | 在 JDK 中每次的中断操作会以使用阶段(Stage)命名 |
AbstractPipeline | 用于生成一个中间操作 Stage 链表 |
中间操作之后,此时的 Stage 并没有执行,而是通过 AbstractPipeline 生成了一个中间操作 Stage 链表;当我们调用终结操作时,会生成一个最终的 Stage,通过这个 Stage 触发之前的中间操作,从最后一个 Stage 开始,递归产生一个 Sink 链。
例子2:需求是查找出一个长度最长,并且以“张”为姓氏的名字
List<String> names = Arrays.asList(" 张三 ", " 李四 ", " 王老五 ", " 李三 ", " 刘老四 ", " 王小二 ", " 张四 ", " 张五六七 ");
String maxLenStartWithZ = names.stream()
.filter(name -> name.startsWith(" 张 "))
.mapToInt(String::length)
.max()
.toString();
执行流程 | 流程 | 是否正确 |
---|---|---|
流程: | 首先遍历一次集合,得到以“张”开头的所有名字;然后遍历一次 filter 得到的集合,将名字转换成数字长度;最后再从长度集合中找到最长的那个名字并且返回。 | 错误 |
流程: | 1、names.stream() 方法将会调用集合类基础接口 Collection 的 Stream 方法; 2、Stream 方法就会调用 StreamSupport 类的 Stream 方法,方法中初始化了一个 ReferencePipeline 的 Head 内部类对象; 3、 再调用 filter 和 map 方法,这两个方法都是无状态的中间操作,所以执行 filter 和 map 操作时,并没有进行任何的操作,而是分别创建了一个 Stage 来标识用户的每一次操作;4、new StatelessOp 将会调用父类 AbstractPipeline 的构造函数,这个构造函数将前后的 Stage 联系起来,生成一个 Stage 链表:5、当执行 max 方法时,会调用 ReferencePipeline 的 max 方法,此时由于 max 方法是终结操作,所以会创建一个 TerminalOp 操作,同时创建一个 ReducingSink,并且将操作封装在 Sink 类中。6、Java8 中的 Spliterator 的 forEachRemaining 会迭代集合,每迭代一次,都会执行一次 filter 操作,如果 filter 操作通过,就会触发 map 操作,然后将结果放入到临时数组 object 中,再进行下一次的迭代。完成中间操作后,就会触发终结操作 max。 | 正确 |
names.stream() 方法将会调用集合类基础接口 Collection 的 Stream 方法;
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
public static <T> Stream<T> stream(Spliterator<T> spliterator, boolean parallel) {
Objects.requireNonNull(spliterator);
return new ReferencePipeline.Head<>(spliterator, StreamOpFlag.fromCharacteristics(spliterator), parallel);
}
ReferencePipeline 的 filter 方法和 map 方法:
@Override
public final Stream<P_OUT> filter(Predicate<? super P_OUT> predicate) {
Objects.requireNonNull(predicate);
return new StatelessOp<P_OUT, P_OUT>(this, StreamShape.REFERENCE,StreamOpFlag.NOT_SIZED) {
@Override
Sink<P_OUT> opWrapSink(int flags, Sink<P_OUT> sink) {
return new Sink.ChainedReference<P_OUT, P_OUT>(sink) {
@Override
public void begin(long size) {
downstream.begin(-1);
}
@Override
public void accept(P_OUT u) {
if (predicate.test(u))
downstream.accept(u);
}
};
}
};
}
@Override
@SuppressWarnings("unchecked")
public final <R> Stream<R> map(Function<? super P_OUT, ? extends R> mapper) {
Objects.requireNonNull(mapper);
return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
@Override
Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {
return new Sink.ChainedReference<P_OUT, R>(sink) {
@Override
public void accept(P_OUT u) {
downstream.accept(mapper.apply(u));
}
};
}
};
}
new StatelessOp 将会调用父类 AbstractPipeline 的构造函数,这个构造函数将前后的 Stage 联系起来,生成一个 Stage 链表:
AbstractPipeline(AbstractPipeline<?, E_IN, ?> previousStage, int opFlags) {
if (previousStage.linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
previousStage.linkedOrConsumed = true;
previousStage.nextStage = this;// 将当前的 stage 的 next 指针指向之前的 stage
this.previousStage = previousStage;// 赋值当前 stage 当全局变量 previousStage
this.sourceOrOpFlags = opFlags & StreamOpFlag.OP_MASK;
this.combinedFlags = StreamOpFlag.combineOpFlags(opFlags, previousStage.combinedFlags);
this.sourceStage = previousStage.sourceStage;
if (opIsStateful())
sourceStage.sourceAnyStateful = true;
this.depth = previousStage.depth + 1;
}
因为在创建每一个 Stage 时,都会包含一个 opWrapSink() 方法,该方法会把一个操作的具体实现封装在 Sink 类中,Sink 采用(处理 -> 转发)的模式来叠加操作。
当执行 max 方法时,会调用 ReferencePipeline 的 max 方法,此时由于 max 方法是终结操作,所以会创建一个 TerminalOp 操作,同时创建一个 ReducingSink,并且将操作封装在 Sink 类中。
@Override
public final Optional<P_OUT> max(Comparator<? super P_OUT> comparator) {
return reduce(BinaryOperator.maxBy(comparator));
}
最后,调用 AbstractPipeline 的 wrapSink 方法,该方法会调用 opWrapSink 生成一个 Sink 链表,Sink 链表中的每一个 Sink 都封装了一个操作的具体实现。
@Override
@SuppressWarnings("unchecked")
final <P_IN> Sink<P_IN> wrapSink(Sink<E_OUT> sink) {
Objects.requireNonNull(sink);
for ( @SuppressWarnings("rawtypes") AbstractPipeline p=AbstractPipeline.this; p.depth > 0; p=p.previousStage) {
sink = p.opWrapSink(p.previousStage.combinedFlags, sink);
}
return (Sink<P_IN>) sink;
}
当 Sink 链表生成完成后,Stream 开始执行,通过 spliterator 迭代集合,执行 Sink 链表中的具体操作。
@Override
final <P_IN> void copyInto(Sink<P_IN> wrappedSink, Spliterator<P_IN> spliterator) {
Objects.requireNonNull(wrappedSink);
if (!StreamOpFlag.SHORT_CIRCUIT.isKnown(getStreamAndOpFlags())) {
wrappedSink.begin(spliterator.getExactSizeIfKnown());
spliterator.forEachRemaining(wrappedSink);
wrappedSink.end();
}
else {
copyIntoWithCancel(wrappedSink, spliterator);
}
}
Java8 中的 Spliterator 的 forEachRemaining 会迭代集合,每迭代一次,都会执行一次 filter 操作,如果 filter 操作通过,就会触发 map 操作,然后将结果放入到临时数组 object 中,再进行下一次的迭代。完成中间操作后,就会触发终结操作 max。
2.4.Stream 并行处理
List<String> names = Arrays.asList(" 张三 ", " 李四 ", " 王老五 ", " 李三 ", " 刘老四 ", " 王小二 ", " 张四 ", " 张五六七 ");
String maxLenStartWithZ = names.stream().parallel().filter(name -> name.startsWith("张"))
.mapToInt(String::length).max().toString();
Stream 的并行处理在执行终结操作之前,跟串行处理的实现是一样的。而在调用终结方法之后,实现的方式就有点不太一样,会调用 TerminalOp 的 evaluateParallel 方法进行并行处理。这里的并行处理指的是,Stream 结合了 ForkJoin 框架,对 Stream 处理进行了分片,Splititerator 中的 estimateSize 方法会估算出分片的数据量。
final <R> R evaluate(TerminalOp<E_OUT, R> terminalOp) {
assert getOutputShape() == terminalOp.inputShape();
if (linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
linkedOrConsumed = true;
return isParallel()
? terminalOp.evaluateParallel(this, sourceSpliterator(terminalOp.getOpFlags()))
: terminalOp.evaluateSequential(this, sourceSpliterator(terminalOp.getOpFlags()));
}
通过预估的数据量获取最小处理单元的阀值,如果当前分片大小大于最小处理单元的阀值,就继续切分集合。每个分片将会生成一个 Sink 链表,当所有的分片操作完成后,ForkJoin 框架将会合并分片任何结果集
3、合理使用 Stream
我们将对常规的迭代、Stream 串行迭代以及 Stream 并行迭代进行性能测试对比,迭代循环中,我们将对数据进行过滤、分组等操作。分别进行以下几组测试:
测试 | 结论(迭代使用时间) |
---|---|
多核 CPU 服务器配置环境下,对比长度 100 的 int 数组的性能; | 常规的迭代 <Stream 并行迭代 <Stream 串行迭代 |
多核 CPU 服务器配置环境下,对比长度 1.00E+8 的 int 数组的性能; | Stream 并行迭代 < 常规的迭代 <Stream 串行迭代 |
多核 CPU 服务器配置环境下,对比长度 1.00E+8 对象数组过滤分组的性能; | Stream 并行迭代 < 常规的迭代 <Stream 串行迭代 |
单核 CPU 服务器配置环境下,对比长度 1.00E+8 对象数组过滤分组的性能。 | 常规的迭代 <Stream 串行迭代 <Stream 并行迭代 |
结论:在循环迭代次数较少的情况下,常规的迭代方式性能反而更好;在单核 CPU 服务器配置环境中,也是常规迭代方式更有优势;而在大数据循环迭代中,如果服务器是多核 CPU 的情况下,Stream 的并行迭代优势明显。所以在平时处理大数据的集合时,应该尽量考虑将应用部署在多核 CPU 环境下,并且使用 Stream 的并行迭代方式进行处理。
4、总结
在串行处理操作中,Stream 在执行每一步中间操作时,并不会做实际的数据操作处理,而是将这些中间操作串联起来,最终由终结操作触发,生成一个数据处理链表,通过 Java8 中的 Spliterator 迭代器进行数据处理;此时,每执行一次迭代,就对所有的无状态的中间操作进行数据处理,而对有状态的中间操作,就需要迭代处理完所有的数据,再进行处理操作;最后就是进行终结操作的数据处理。
在并行处理操作中,Stream 对中间操作基本跟串行处理方式是一样的,但在终结操作中,Stream 将结合 ForkJoin 框架对集合进行切片处理,ForkJoin 框架将每个切片的处理结果 Join 合并起来。最后就是要注意 Stream 的使用场景。
5、思考题
这里有一个简单的并行处理案例,请你找出其中存在的问题。
// 使用一个容器装载 100 个数字,通过 Stream 并行处理的方式将容器中为单数的数字转移到容器 parallelList
List<Integer> integerList= new ArrayList<Integer>();
for (int i = 0; i <100; i++) {
integerList.add(i);
}
List<Integer> parallelList = new ArrayList<Integer>() ;
integerList.stream()
.parallel()
.filter(i->i%2==1)
.forEach(i->parallelList.add(i));
答案:ArrayList不是线程安全的,在并行操作时,会出现多线程操作问题,例如出现null值,有可能是在扩容时,复制出现问题。同时也会出现值被覆盖的情况。可以换成线程安全的ArrayList。