Stream 概述
Stream 是对集合操作的一种高级抽象,Stream 使用一种类似于 SQL 语句从数据库查询数据的直观方式来提供一种对 Java 集合运算和表达的高阶抽象。这种风格将要处理的元素集合看作一种流,流在管道中传输,并且可以在管道的节点上进行处理,比如筛选,排序,聚合等。
Stream 可以看作迭代器(Iterator)的一个高级版本,它允许你以声明性的方式处理数据集合。
Stream 的特性及优点:
- 无存储。
Stream不是一种数据结构,它只是某种数据源的一个视图,数据源可以是一个数组,Java 容器或 I/O channel 等。 - 为函数式编程而生。
对Stream的任何修改都不会修改背后的数据源,比如对Stream执行过滤操作并不会删除被过滤的元素,而是会产生一个不包含被过滤元素的新Stream。 - 惰式执行。
Stream上的操作并不会立即执行,只有等到用户真正需要结果的时候才会执行。可消费性。Stream只能被“消费”一次,一旦遍历过就会失效,就像容器的迭代器那样,想要再次遍历必须重新生成。
Stream 的操作主要有三种,分别是流的创建、中间操作和最终操作。
- 流的创建
- 通过已有的集合来创建流。
在 Java 8 中,除了增加了很多 Stream 相关的类以外,还对集合类自身做了增强,在其中增加了 stream 方法,可以将一个集合类转换成流。
通过一个已有的List创建一个流。除此以外,还有一个parallelStream方法,可以为集合创建一个并行流。这种通过集合创建出一个Stream的方式也是比较常用的一种方式。
- 通过已有的集合来创建流。
List<String> strings = Arrays.asList("Hollis", "HollisChuang", "hollis", "Hello", "HelloWorld", "Hollis");
Stream<String> stream = strings.stream();
- 通过Stream创建流可以使用Stream类提供的方法,直接返回一个由指定元素组成的流。如以上代码,直接通过of方法,创建并返回一个Stream。
Stream<String> stream = Stream.of("Hollis", "HollisChuang", "hollis", "Hello", "HelloWorld", "Hollis");
- 中间操作
Stream有很多中间操作,多个中间操作可以连接起来形成一个流水线,每一个中间操作就像流水线上的一个工人,每人工人都可以对流进行加工,加工后得到的结果还是一个流。这些操作都是惰性化的,意味着它们不会立即执行,只有在遇到终端操作(最终操作)时才会执行。
常见的中间操作包括 filter, map, flatMap, distinct, sorted, peek, limit, skip 等。 - 最终操作
Stream 的中间操作得到的结果还是一个 Stream,那么如何把一个 Stream 转换成我们需要的类型呢?比如计算出流中元素的个数、将流装换成集合等。这就需要最终操作(terminal operation),最终操作会消耗流,产生一个最终结果。也就是说,在最终操作之后,不能再次使用流,也不能在使用任何中间操作,否则将抛出异常:java.lang.IllegalStateException: stream has already been operated upon or closed
。
常见的最终操作包括 forEach, forEachOrdered, toArray, reduce, collect, min, max, count, anyMatch, allMatch, noneMatch, findFirst, findAny 等。
Stream 支持顺序和并行两种模式的数据处理。Stream 的并行流(parallel stream)是一种特别强大的工具,它可以显著提高数据处理的效率,特别是在处理大型数据集时。
顺序模式的数据处理很简单,就是按照管道流来依顺序执行。
Stream 怎么实现并行流
List<String> list = Arrays.asList("Apple", "Banana", "Cherry", "Date");
// 创建一个串行流
Stream<String> stream = list.stream();
// 创建一个并行流
Stream<String> parallelStream = list.parallelStream();
使用 parallelStream 方法就能获取到一个并行流。通过并发运行的方式执行流的迭代及操作。并行流底层使用了Java 7中引入的 Fork/Join 框架。这个框架旨在帮助开发者利用多核处理器的并行处理能力。它工作的方式是将一个大任务分割(fork)成多个小任务,这些小任务可以并行执行,然后再将这些小任务的结果合并(join)成最终结果。
具体实现方式是 Stream 的 reduce 方法用来遍历这个 Stream,看下他的实现,这个方法是在 ReferencePipeline 这个实现类中的:
@Override
public final Optional<P_OUT> reduce(BinaryOperator<P_OUT> accumulator) {
return evaluate(ReduceOps.makeRef(accumulator));
}
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()));}
可以看到,这里调用了一个 evaluate 方法,然后再方法中有一个是否并行流的判断——isParallel(),如果是并行流,那么执行的是 terminalOp.evaluateParallel() 方法,该方法的具体实现类有很多个,不同的实现类只有返回的类型是不同的,其他都是一样的。
@Override
public <P_IN> O evaluateParallel(PipelineHelper<T> helper, Spliterator<P_IN> spliterator){
return new FindTask<>(this, helper, spliterator).invoke();
}
MatchOp 实现类返回的是 MatchTask,FinOp 实现类返回的是 FindTask,ForEachOp 实现类返回的是 ForEachTask,ReduceOp 实现类返回的是 ReduceTask。这几个 Task 其实都是 CountedCompleter 的子类,而 CountedCompleter 其实就是一个 ForkJoinTask 这几个 Task 都是 CountedCompleter 的子类,而 CountedCompleter 其实就是一个 ForkJoinTask。
所以其实 Stream 的并行流就是通过 ForkJoinPool 线程池来实现的。
Stream 的并行流一定比串行流快吗?
不一定。
Stream底层使用了ForkJoin进行并发处理,但是,并不代表着用了并发处理就一定比串行处理更快。
有以下几个因素影响着并行流的性能:
- 线程管理的开销:并行流使用了多线程,而用了多线程就会带来线程管理和任务分配的开销。
- 任务分割:并行流的性能提升依赖于任务能够有效地分割和分配。如果任务分割不均衡,一些线程可能空闲或等待,从而影响性能。
- 线程争用:并行流使用公共的 ForkJoinPool,如果系统中有其他并行任务,这些任务会争用线程资源,可能导致性能下降。
- 数据依赖性:并行流适用于没有数据依赖性的操作。如果操作之间存在依赖关系,并行流可能无法有效地提升性能,甚至可能导致错误。
- 环境配置:机器的硬件配置(例如 CPU 核心数)和当前系统负载也会影响并行流的性能。如果 CPU 核心数较少或负载较高,并行流的性能可能不如串行流。
GitHub - nickliuchao/stream
在上面这个链接中,有人做过测试,测试了在各种情况下常规迭代、Stream 串行化迭代和 Stream 并行化迭代的快慢。
也可看下面这篇,自行测试:
Java 8 Stream的性能到底如何?
直接给出结论:
在单核 CPU 的情况下,Stream 的串行迭代的效率是要高于 Stream 的并行迭代的效率的。而在多核 CPU 的情况下,Stream 的并行迭代速度要比 Stream 的串行迭代效率要高。但是,如果元素数量比较少的话,直接用常规迭代反而性能更好。
补充
函数式编程
函数式接口是实现函数式编程的基础,但Java中的函数式编程并不限于函数式接口。它是一个更广泛的概念,涵盖了Lambda表达式、方法引用、Stream API等特性,这些特性共同提供了一种新的编程模式,使得代码更加简洁、声明式,并且易于并发处理。
以下是Java中支持函数式编程的几个关键特性:
- 函数式接口(Functional Interface):函数式接口是一个只包含一个抽象方法的接口。Java 8 通过这种方式允许使用Lambda表达式来简洁地实现这些接口。例如,Runnable、Callable、Comparator等都是函数式接口。
- Lambda表达式:Lambda表达式提供了一种无需编写类定义的方式来实现函数式接口。它们是匿名函数的一种形式,可以被赋值给变量或作为参数传递给方法。
- 方法引用(Method References):方法引用是Lambda表达式的一个特化形式,它允许你直接引用已有的方法或构造函数。这使得代码更加简洁和清晰。
- 构造器引用(Constructor References):类似于方法引用,构造器引用允许你引用类的构造器,这在使用像Arrays类的stream()方法时特别有用。
- 数组和集合的Stream API:Stream API提供了一种声明式的处理集合数据的方式,支持过滤、映射、聚合等操作。它支持并行处理,可以提高性能。
- Optional类:Optional类用于封装可能为null的对象,从而避免直接使用null值,减少空指针异常的风险。
- 默认方法(Default Methods):接口中的默认方法允许为接口方法提供一个默认实现,这使得可以在不破坏现有实现的情况下向接口添加新方法。
- 并发增强:Java 8 通过CompletableFuture等类增强了并发编程的能力,支持异步编程和更复杂的异步逻辑。
函数式接口
函数式接口是只包含一个抽象方法的接口,它可以包含多个默认方法或静态方法。Java 8 之前,我们通常使用匿名内部类来实现这样的接口。但是,使用 Lambda 表达式,我们可以以更简洁、更易读的方式来实现它们。Java 8 在 java.util.function 包下提供了一些内置函数式接口,以支持常见的函数式编程任务。
lambda 表达式
提供了一种简洁的方式来表示匿名方法。Lambda 表达式可以用来创建仅有一个抽象方法的接口的实例,这种接口被称为函数式接口。
简化代码:Lambda表达式允许我们以更简洁的方式表示匿名函数,减少了代码的冗余和复杂性。
函数式编程风格:支持函数式编程,使得代码更加易于理解和维护。
提高可读性:通过减少样板代码,提高了代码的可读性和可维护性。
方法引用
ForkJoinPool 和 ThreadPoolExecutor 的区别
ForkJoinPool 和 ThreadPoolExecutor 是 Java 中常用的两个线程池实现,他们主要在实现方式上有一定的区别,所以在使用场景上也有不同。
ForkJoinPool 是基于工作窃取(Work-Stealing)算法实现的线程池,ForkJoinPool 中每个线程都有自己的工作队列,用于存储待执行的任务。当一个线程执行完自己的任务之后,会从其他线程的工作队列中窃取任务执行,以此来实现任务的动态均衡和线程的利用率最大化。
ThreadPoolExecutor 是基于任务分配(Task-Assignment)算法实现的线程池,ThreadPoolExecutor 中线程池中有一个共享的工作队列,所有任务都将提交到这个队列中。线程池中的线程会从队列中获取任务执行,如果队列为空,则线程会等待,直到队列中有任务为止。
ForkJoinPool 中的任务通常是一些可以分割成多个子任务的任务,例如快速排序。每个任务都可以分成两个或多个子任务,然后由不同的线程来执行这些子任务。在这个过程中,ForkJoinPool 会自动管理任务的执行、分割和合并,从而实现任务的动态分配和最优化执行。
ForkJoinPool 中的工作线程是一种特殊的线程,与普通线程池中的工作线程有所不同。它们会自动地创建和销毁,以及自动地管理线程的数量和调度。这种方式可以降低线程池的管理成本,提高线程的利用率和并行度。
ThreadPoolExecutor 中线程的创建和销毁是静态的,线程池创建后会预先创建一定数量的线程,根据任务的数量动态调整线程的利用率,不会销毁核心线程,非核心线程长时间 空闲会被销毁 。如果线程长时间处于空闲状态,可能会占用过多的资源。
在使用场景上
ExecutorService 适用于处理较小的、相对独立的任务,任务之间存在一定的依赖关系。例如,处理网络请求、读取文件、执行数据库操作等任务。
ForkJoinPool 适用于于以下场景:
- 大任务分解为小任务,分解出来的小任务互不干扰。
- 计算密集型任务。对于需要大量计算且能够并行化的任务,ForkJoinPool 能够有效利用多核处理器的优势来加速处理过程。
- 任务之间没有或很少有依赖性时,ForkJoinPool 可以帮助并行执行这些任务,从而提高效率。
- 数据聚合任务,在处理需要聚合多个数据源结果的任务时(例如,遍历树结构并聚合结果)。
Stream 可以使用自定义线程池来提升效率
默认情况下,所有的并行流操作都共享一个公共的 ForkJoinPool,它的线程数量通常等于处理器的核心数减一。
如果需要,可以使用自定义的 ForkJoinPool 来执行操作。
自定义线程池可以帮助我们:
- 避免资源竞争:使用公共的 ForkJoinPool 可能会与其他并行任务竞争资源,因为其他并行的任务也会调用默认的 ForkJoinPool 线程池中的线程。
- 调整性能:自定义线程池可以根据应用程序的需求调整线程池的大小,优化性能。
- 我们可以自定义错误处理策略和监控:自定义线程池可以提供更多的错误处理和监控机制。
import java.util.concurrent.ForkJoinPool;
import java.util.stream.Stream;
public class CustomThreadPoolExample {
public static void main(String[] args) {
// 创建具有特定线程数的ForkJoinPool
ForkJoinPool customThreadPool = new ForkJoinPool(4);
try { customThreadPool.submit(() -> {
// 在自定义线程池中执行并行流操作
Stream.of("Apple", "Banana", "Cherry", "Date")
.parallel()
.forEach(System.out::println);
}).get(); // 等待操作完成
} catch (Exception e) {
e.printStackTrace();
} finally {
customThreadPool.shutdown(); // 关闭线程池
}
}
}