目录
流管道(stream pipelines)的组成:源、中间方法、终端方法
果被问及Java8的新特性Stream流知不知道、用过没有?往往大家都会说知道,用过。有些可能说用来简化for循环代码,有些说Map、List转换,有些则用来过滤数据等等。也会说自己之前如何用在实际代码中使用的。但往往大家都没有对流有一个相对深入全貌的认知,代码中的使用看起来运用到了流式编程,但是其实有些写法并没有运用到流的特性,而只是当成了简化for循环。
我之前也专门找资料查过,有些不太记得了,有些当时也没研究的那么深入。正好趁这次整理Effective Java 第三版中的Stream,准备深入了解记录一下。
Stream流是什么?
与Stream平级的还有IntStream、DoubleStream、LongStream。它们拥有一个共同的父类BaseStream,全部在java.util.stream包结构下面。
package java.util.stream;
public interface BaseStream<T, S extends BaseStream<T, S>>
extends AutoCloseable {
// 返回流的迭代器、终端方法
Iterator<T> iterator();
// 返回流的拼接器、终端方法
Spliterator<T> spliterator();
// 判断是否是一个并行流
boolean isParallel();
// 返回顺序的等效流、终端方法
S sequential();
// 并行流、终端方法
S parallel();
// 返回无序的等效流、终端方法
S unordered();
// 返回带有附加关闭处理程序的等效流、终端方法
S onClose(Runnable closeHandler);
// AutoCloseable方法、允许try-with-resource自动关闭
@Override
void close();
}
public interface Stream<T> extends BaseStream<T, Stream<T>> {
// 返回由与此给定 谓词 匹配的此流的元素组成的流、中间方法
Stream<T> filter(Predicate<? super T> predicate);
// 返回由给定函数应用于此流的元素的结果组成的流、中间方法
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
// 返回由通过将提供的映射函数应用于每个元素而产生的映射流的内容来替换该流的每个元素的结果的流、中间方法
<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);
// 返回去重之后的流、有状态中间方法
Stream<T> distinct();
// 返回排序之后的流、有状态中间方法
Stream<T> sorted(Comparator<? super T> comparator);
// 返回由该流的元素组成的流,另外在从生成的流中消耗元素时对每个元素执行提供的操作、中间方法
Stream<T> peek(Consumer<? super T> action);
// 返回由该流的元素组成的流,截断长度不能超过maxSize、短路有状态中间方法
Stream<T> limit(long maxSize);
// 循环遍历、终端方法
void forEach(Consumer<? super T> action);
// 返回数组、终端方法
Object[] toArray();
// 根据身份值执行累加器、终端方法
T reduce(T identity, BinaryOperator<T> accumulator);
// 使用 Collector对此流的元素执行 mutable reduction Collector 、终端方法
<R, A> R collect(Collector<? super T, A, R> collector);
// 最小、终端方法
Optional<T> min(Comparator<? super T> comparator);
// 计数、终端方法
long count();
// 任意匹配、短路状态终端方法
boolean anyMatch(Predicate<? super T> predicate);
// 全部匹配、短路状态终端方法
boolean allMatch(Predicate<? super T> predicate);
// 都不匹配、短路状态终端方法
boolean noneMatch(Predicate<? super T> predicate);
// 第一个(无序的话、等同findAny)、短路状态终端方法
Optional<T> findFirst();
// 任意一个匹配、短路状态终端方法
Optional<T> findAny();
// ...不全、只列举部分
}
本人认为,Stream流 和 Collection集合最大的侧重点在于,集合更关注于存储的数据本身,而流侧重于如何处理这些数据。
先来解决一些基础的知识
- Predicate:标准函数接口、谓词
- Function:标准函数接口、函数
- Consumer:标准函数接口、消费者
Java8提供了几十种标准函数接口,全部在java.util.function包下,主要可以分为6大类,不清楚的可以看这篇《Effective Java 第三版 第七章》中的Rule44 坚持使用标准的函数接口中的表格。
流的方法:终端方法 中间方法
流操作区分,就两种:
- 终端方法(terminal operation.):流的结束方法,所有流的运用中,最后一定会由终端方法进行结束,而且一旦执行过终端方法、这个流就相当于结束了。
- 中间方法(intermediate operation.):从代码很容易区分,所有的中间方法返回的一定是一个Stream。而且需要注意源码中是用 Returns:the new stream来形容的。
流管道(stream pipelines)的组成:源、中间方法、终端方法
- 源:如集合、数组、生成器函数或I/O通道。
- 中间方法:零个或多个,如Stream.filter或Stream.map。需要注意返回的是new stream,即懒惰性(又称延迟执行特性)。执行诸如filter()之类的中间操作实际上并不执行任何过滤,而是创建一个新的流,在遍历时,该流包含与给定谓词匹配的初始流元素,而且需要注意的是流并不存储数据。在执行管道的终端操作之前,不会开始遍历管道源。
- 终端方法:如Stream.forEach或IntStream.sum,可以遍历流以产生结果或副作用。终端操作完成后,认为流管道已消耗,不能再使用;如果需要再次遍历同一数据源,则必须返回该数据源以获取新的流。
// 举个简单的例子,打印年龄在22~28之间的任意一个人的名字
List<People> list = new ArrayList<>();
list.add(People.builder().name("xuw1").age(31).build());
list.add(People.builder().name("xuw2").age(22).build());
list.add(People.builder().name("xuw2").age(22).build());
list.add(People.builder().name("xuw3").age(25).build());
list.add(People.builder().name("xuw3").age(25).build());
list.add(People.builder().name("xuw4").age(15).build());
System.out.println(list.stream()
.filter(x -> x.getAge()>21 && x.getAge()< 29).distinct()
.map(People::getName).findAny());
中间方法:有状态 无状态
- 有状态(stateful):有状态操作(如distinct和sorted)可能会合并以前看到的元素的状态。有状态操作可能需要在生成结果之前处理整个输入。(与懒惰性相悖)
- 无状态:无状态操作(如filter和map)在处理新元素时不保留以前看到的元素的任何状态——每个元素都可以独立于对其他元素的操作进行处理。
在运用流时,一般都推荐使用无状态方法,因为与有状态方法最大的区别就是,有状态方法可能需要在执行终结方法之前就需要循环处理完所有数据,而无状态方法,它们关注的并不是判断筛选之后的结果集,而是判断的方法,存在循环合并,到执行最终方法时可能只要循环一次就足够。
举个例子,list.stream().filter(x -> x.getAge()>21 && x.getAge()< 29).filter(x -> x.getName().length() > 3).map(People::getName).findAny();
比如这段代码,虽然有两次filter()、一次map(),但是由于都是无状态方法,到了最终findAny()执行时,只要循环一次就足够了。
但是如果,list.stream().filter(x -> x.getAge()>21 && x.getAge()< 29).distinct().filter(x -> x.getName().length() > 3).map(People::getName).findAny();
中间加入一个有状态方法distinct(),语义就发生了变化,它必须依赖于上一步运算之后所有的结果集才能执行,无法像无状态方法一样在一次循环中就处理所有事情。每个有状态方法,都是一个单独的内部循环,所以编写流式代码时,顺序会影响到最终的执行结果以及性能。
短路
同时我们会看到findAny()的注释中,还有一个形容词short-circuiting,即短路。
短路:可能乍一看,讲不出什么是短路。其实我们日常都在运用到,比方,x < 10 || x >20,如果x = 5,执行这段代码判断的时候,由于5<10,后面的条件就不会再判断了,即短路。针对上例中的流,findAny()就是短路,当它找到了第一个符合条件的元素之后,就不会再处理后面其他的元素判断了。当输入源无限大、终端操作有可能在有限时间内终止,就是短路(但并不是所有的终端方法都是短路方法)。
平行度:串行 并行
- 串行流(stream):单线程
- 并行流(parallelStream):默认使用了fork-join框架,其默认线程数是CPU核心数。
除了被标识为显式不确定的操作(如findAny())之外,流是顺序执行还是并行执行都不应更改计算结果。
修改并行流线程数的两种方式:
// 全局设置
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "20");
// 局部设置
ForkJoinPool forkJoinPool = new ForkJoinPool(20);
ForkJoinTask<Boolean> fs = forkJoinPool.submit(() -> inputStream.allMatch(element -> {
Thread.sleep(300);
System.out.println(Thread.currentThread().getName());
System.out.println("线程数量:" + Thread.activeCount());
return new Random().nextInt(100) >= 0;
}));
try {
result = fs.get();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e){
e.printStackTrace();
}
forkJoinPool.shutdown();
用了并行流之后一定会变快么
/**
* @author xuwejsnj
* @date 2021/7/19
*/
public class StreamTest {
public static void main(String[] args) {
System.out.println("getSum:" + StreamTest.measureSumPerf(StreamTest::getSum, 10000000) + "ms");
System.out.println("getSumParallel:" + StreamTest.measureSumPerf(StreamTest::getSumParallel, 10000000) + "ms");
System.out.println("getSumOldJava:" + StreamTest.measureSumPerf(StreamTest::getSumOldJava, 10000000) + "ms");
System.out.println("getRangeClosedSum:" + StreamTest.measureSumPerf(StreamTest::getRangeClosedSum, 10000000) + "ms");
System.out.println("getRangeClosedSumParallel:" + StreamTest.measureSumPerf(StreamTest::getRangeClosedSumParallel, 10000000) + "ms");
}
public static long measureSumPerf(UnaryOperator<Long> adder, long n) {
long fastest = Long.MAX_VALUE;
for (int i = 0; i < 10; i++) {
long start = System.nanoTime();
long sum = adder.apply(n);
long duration = (System.nanoTime() - start) / 1_000_000;
// System.out.println("Result: " + sum);
if (duration < fastest) fastest = duration;
}
return fastest;
}
public static long getSum(long n){
return Stream.iterate(1L, i->i+1).limit(n).reduce(0L,Long::sum);
}
public static long getSumParallel(long n){
return Stream.iterate(1L,i->i+1).limit(n).parallel().reduce(0L,Long::sum);
}
public static long getSumOldJava(long n){
long sum = 0;
for(int i = 0;i<=n;i++){
sum += i;
}
return sum;
}
public static long getRangeClosedSum(long n){
return LongStream.rangeClosed(1,n).reduce(0L,Long::sum);
}
public static long getRangeClosedSumParallel(long n){
return LongStream.rangeClosed(1,n).parallel().reduce(0L,Long::sum);
}
}
-----打印结果
getSum:81ms
getSumParallel:146ms
getSumOldJava:4ms
getRangeClosedSum:2ms
getRangeClosedSumParallel:0ms
可以看见,基础常用的for循环是5ms,用Stream.iterate(1L, i->i+1).limit(n)写法实现的不管是串行流还是并行流,最终的运行时间都比for循环慢了,并行流是最慢的。如果采用LongStream.rangeClosed(1,n),不管是串行流、还是并行流都比for快了不少。
事实证明,运用并行流,想要讲效率,得先看写法。以下一些注意事项可以参考:
- 能运用IntStream、LongStream、DoubleStream的优先,而不是迭代器。
- 尽量用无状态方法,而不是limit、findFirst等这种有状态方法。
- 并行数据是否容易切分,ArrayList比LinkedList切分效率就快。range方法创建的原始类型流也容易切分。
- 数据量过小不建议用并行流。
- 避免自动拆装箱。建议用IntStream、LongStream、DoubleStream。
- 避免使用流的副作用来达到目的。
- 真实环境测试。
流的副作用
什么是流的副作用?
ArrayList<String> results = new ArrayList<>();
stream.filter(s -> pattern.matcher(s).matches())
.forEach(s -> results.add(s)); // Unnecessary use of side-effects!
List<String>results =
stream.filter(s -> pattern.matcher(s).matches())
.collect(Collectors.toList()); // No side-effects!
如果简单来理解的话,并不是仅仅运用纯粹的流写法,特别是在运用forEach循环时,内部逻辑往往会针对流以外的对象进行操作从而达到某种目的的做法。但是我们也得承认有些时候,某些场景下,运用流的副作用是必须的。但是我们不鼓励用副作用来达到目的,因为有可能会违反无状态要求、或者带来线程同步问题。
关于排序
有些数据源本身就是排序好的,有些是无序的。同时流也可以进行显示的sorted()、unordered()。针对排序这一点,针对连续流,是否排序并不影响其性能,而是产生的结果是否有序。
针对并行流,放松排序约束有时可以实现更高效的执行。如果不排序,有些聚合函数可能会更加有效的实现,例如distinct()或者Collectors.groupingBy()。但是有些时候,例如limit()可能需要缓冲以保证正确的顺序。
以上部分就是我对于流相关的一些了解,至于是否运用了流的写法之后,对性能产生了正面或负面的影响,希望大家在自己的实际运用中去真实测试,而不是要人云亦云。但是前提条件是在运用流的写法时,本身没有一些影响性能的错误写法,而是实际运用到了流的相关特性。
本文技术菜鸟个人学习使用,如有不正欢迎指出修正。xuweijsnj