头图

Java Stream API:每个开发者都应该知道的 3 件事

Java Stream API 通过惰性求值并行处理函数式编程简化了集合处理。使用它可以编写更简洁、高效和可扩展的代码。

时间飞逝!我记得 Java 8 曾经是一个标杆,每个人都把它当作一种全新且革命性的东西来谈论。老实说,它确实是全新且革命性的。但现在,使用 Java 8 的项目可能被称为“遗留”项目。即使 Java 8 本身已经成为遗留版本,它引入的特性仍然具有实际意义。今天,我们来聊聊其中一个特性——Stream API

如果你还不了解,Java Stream API 是一个强大的工具,它允许程序员以函数式编程风格编写 Java 代码。它通过支持过滤、转换和聚合操作,使得集合的处理更加简单。

尽管 Stream API 被广泛使用,但我仍然发现许多开发者对其深层知识的掌握存在不足。在本文中,我将探讨 Stream API 的三个关键方面,这些方面对于深入理解它至关重要:

  1. 惰性求值:帮助我们优化操作链的执行。
  2. 并行流:通过利用多核处理器,深入探讨如何增加数据处理的并行性。
  3. Lambda 变量作用域:了解在使用 Stream 时如何正确地将变量传递给 Lambda。

我希望通过本文,你能更好地理解这些概念。


1. Java Stream API 中的惰性求值

惰性求值是理解如何有效使用流的核心概念。但在深入探讨惰性求值之前,我们先来了解流管道中的两种主要操作类型:中间操作终端操作

  1. 中间操作:中间操作是将输入流转换为另一个流的操作,但不会产生不可变的结果。例如,filter()map()flatMap() 都是中间操作,因为它们接受一个输入流并返回另一个流。这些操作不会立即消耗输入流中的所有元素,而是创建一个包含所需元素的新流。
  2. 终端操作:终端操作是消耗流中元素的操作,它们要么返回一个结果,要么通过副作用修改某些状态。例如,forEach()findFirst()collect() 都是终端操作,因为它们最终会消耗流中的所有元素以产生结果。

什么是惰性求值?它是如何工作的?

在 Stream API 中,惰性求值意味着中间操作不会立即执行,直到我们调用一个终端操作。这意味着我们可以在代码的任何地方定义一个流及其所有操作,但只有在调用终端操作时才会执行。

当我们调用终端操作时,流会逐个处理数据元素,依次应用所有中间操作。这种方法通过避免不必要的计算来优化性能。

让我们通过一个实际例子来看看惰性求值如何影响执行:

import java.util.stream.Stream;

public class LazyEvaluationExample {
    public static void main(String[] args) {
        Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5)
            .filter(num -> {
                System.out.println("Filtering: " + num);
                return num % 2 == 0;
            })
            .map(num -> {
                System.out.println("Mapping: " + num);
                return num * 2;
            });
      
        System.out.println("Stream pipeline defined, no execution yet.");
        
        // 终端操作触发执行
        stream.forEach(System.out::println);
    }
}

输出:

Stream pipeline defined, no execution yet.
Filtering: 1
Filtering: 2
Mapping: 2
4
Filtering: 3
Filtering: 4
Mapping: 4
8
Filtering: 5

让我们试着理解为什么会有这样的输出。我们可以看到,filter()map() 操作是惰性的。这意味着即使我们编写了这些代码,它们也不会在调用终端操作 forEach() 之前执行。这解释了为什么我们首先看到输出 Stream pipeline defined, no execution yet

只有当调用 forEach() 终端操作时,流才会开始逐个处理元素。


2. 并行流

Java Stream API 最有用和最强大的功能之一是对并行流的支持。并行性是指通过利用多个 CPU 核心同时处理两个或多个操作的能力。在 Stream API 中,这意味着我们可以同时处理流中的多个元素的中间或终端操作。

这种功能可以显著提高计算密集型任务的性能,但为了更好地理解它以达到最佳效果,我们需要深入了解它。

什么是并行流?

并行流是一种将其元素分成多个块,然后通过不同线程并行处理的流。与普通流(逐个处理元素)不同,并行流在底层使用 ForkJoinPool 来实现并行性。

创建并行流非常简单。你可以使用以下两种方法之一:

  • 对于现有集合,可以使用 parallelStream() 方法。
  • 你可以通过在现有流上调用 parallel() 方法来使其并行。

何时使用并行流?

并行流可以在特定场景中提升性能,但它们并不总是最佳选择。以下是一些关键考虑因素:

  1. 适合的场景

    • 大数据集:当处理大量数据时,并行性效果最好。
    • CPU 密集型任务:对于大量使用 CPU 的计算任务(如数学运算或数据转换),并行流是理想选择。
  2. 避免使用并行流的场景

    • IO 密集型任务:如果你的任务涉及大量读写操作(如磁盘/网络操作),并行流可能不是最佳选择。
    • 小数据集:你需要记住,Java 虚拟机在底层仍然需要管理线程切换等操作。因此,在处理小数据集时,管理线程的开销可能会超过性能提升。

性能对比代码的解释

下面的代码将帮助我们通过求和操作来比较 Java 中顺序流和并行流的性能。我们将运行两个测试:

  • 第一个测试:范围从 1 到 1,000,000。
  • 第二个测试:范围从 1 到 100,000,000。

我们的主要目标是比较顺序流和并行流的处理时间,从而帮助我们理解使用并行流的优缺点。

int rangeLimit = 1_000_000;

long start = System.currentTimeMillis();
LongStream.rangeClosed(1, rangeLimit)
    .reduce(0L, Long::sum);
long end = System.currentTimeMillis();

System.out.println("Sequential Stream Time: " + (end - start) + " ms");

start = System.currentTimeMillis();
LongStream.rangeClosed(1, rangeLimit)
    .parallel()
    .reduce(0L, Long::sum);
end = System.currentTimeMillis();

System.out.println("Parallel Stream Time: " + (end - start) + " ms");

首先,我们创建了两个流:顺序流和并行流。并行流是通过在现有流上调用 .parallel() 方法创建的。两者都包含从 1 到 1,000,000 的数字,使用 LongStream.rangeClosed() 方法生成。

其次,我们对两个流执行了 .reduce(0L, Long::sum) 方法,该方法对输入流中的所有元素求和。由于 reduce 是一个终端操作,流会在调用该方法时立即开始处理。

我们能够测量此操作所花费的时间。这些信息通过 System.currentTimeMillis() 命令记录并存储在变量 startend 中。结果以毫秒为单位打印出来。

让我们执行代码两次,更新 rangeLimit 变量。第一次执行时,将其设置为 1,000,000,如代码所示。第二次执行时,将其设置为 100,000,000。

对于范围从 1 到 1,000,000:

Sequential Stream Time: 9 ms
Parallel Stream Time: 12 ms

我们可以看到,在这种情况下,并行流比顺序流稍慢。这是一个很好的例子,展示了对于像我们示例中使用的小数据集,管理多个线程可能会导致性能损失。

接下来,我们将范围增加到 100,000,000,结果如下:

Sequential Stream Time: 57 ms
Parallel Stream Time: 12 ms

最终,我们可以看到并行流的优势。在这里,并行流明显优于顺序流。较大的数据集能够通过利用多个 CPU 核心来加速计算过程。

重要注意事项:处理大数据集

我们需要记住一点:Java 中的 Long 类型的最大值是 2^63-1。因此,在我们的示例中,当我们测试较大的范围时,求和结果可能会超过此限制,从而导致不正确的结果。

由于本示例的主要目的是展示并行流的行为并比较效率,我们可以忽略结果可能不正确的事实。如果需要精确求和,你可能需要使用更大范围的类型,例如 BigInteger


3. Lambda 中的变量作用域

让我们简单讨论一下 Lambda。Lambda 表达式在 Stream API 中被广泛使用。老实说,我认为有很多开发者只在流中使用 Lambda。因此,我认为在本文中讨论一些与 Lambda 相关的点也是合理的。

我们应该意识到,Lambda 与变量的交互方式有其特殊性,作为 Java 开发者,理解 Lambda 如何捕获和使用变量至关重要。

让我们探讨一下变量作用域在 Lambda 表达式中是如何工作的,以及它与传统方法的区别。

在 Lambda 中捕获变量

假设你在 Lambda 的外部作用域中初始化了一个变量,并计划在 Lambda 函数中使用这个变量。你能这样做吗?

这取决于情况。我们只能使用从外部作用域捕获的变量,前提是它们是 final有效 final 的。那么,“有效 final”是什么意思呢?

简而言之,如果一个变量在初始化后其值从未改变,则它被认为是有效 final 的。因此,要在 Lambda 中使用变量,你有两种方法:

  1. 像往常一样初始化变量,并确保其值在初始化后不会改变。
  2. 在初始化时通过添加 final 关键字使变量成为 final
int factor = 2;  

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

numbers.stream()
    .map(n -> n * factor)  
    .forEach(System.out::println);

在上面的示例中,我们可以看到 factor 变量是有效 final 的,因为我们在初始化后没有更新它。这意味着这个变量可以在我们的 Lambda 中使用。你可以尝试在初始化后重新赋值 factor,看看会发生什么。


结论

Java Stream API 是一套强大且易于理解的工具,用于处理元素序列。如果正确使用,它可以帮助减少大量不必要的代码,使程序更具可读性,并提高应用程序的性能。但正如我所提到的,正确使用它以从性能和代码简洁性方面获得最佳结果至关重要。


[注]本文译自:Java Stream API: 3 Things Every Developer Should Know About

信码由缰
65 声望8 粉丝

“码”界老兵,分享程序人生。