一文入门 Java Stream

在 Java 8 引入的众多特性中,Stream(流)无疑是最具变革性的之一。它让我们能以声明式、函数式的方式处理集合数据------无需显式循环,就能完成过滤、映射、聚合等操作。

我们可以将 Java 流看作是一个数据流经的管道。无需手动编写循环和条件语句来处理列表,只需告诉 Java 对每个元素应执行什么操作,Java 流 API 会负责处理内部的实现方式。

Java 流并不存储数据。相反,它对诸如 ListSetMap 或数组等现有的数据源进行操作。流会对数据源应用一系列操作。

我们将向你介绍 Java 流,学习如何从 Java 集合创建流,首次了解流管道,并了解 Lambda 表达式、方法引用和其他函数式编程元素如何与 Java 流协同工作。还将学习如何将收集器和可选链与 Java 流结合使用,以及在程序中何时应该使用或不应该使用流。

总之,本文将带你轻松入门 Java 流,从创建到组合,用简洁优雅的代码释放数据处理的真正潜力。

流 VS 集合

许多开发者会被 Java 流和 Java 集合之间的区别所困扰:

  • 集合(如 ArrayListHashSet)用于存储。它们将数据保存在内存中以供你访问。
  • 流关注的是行为。它们描述对数据要做什么,而不是如何存储数据。

打个比方,可以把集合想象成存放食材的橱柜,而流则是将这些食材做成一顿饭的食谱。

流通过描述要做什么而不是如何去做,赋予 Java 一种函数式和声明式的特性。

为什么使用流

Java 开发者会出于多种原因偏向并使用流:

  • 代码更简洁,可替代嵌套循环和条件语句。
  • 样板代码更少,无需再编写手动的 for 循环。
  • 逻辑更具可读性,流管道读起来就像自然语言。

通过比较循环和流,我们就能初步看出这些差异。

在 Java 中,流常常会取代传统的循环,一旦你开始使用流,就很难再回头使用传统方式了。下面是一个典型的 for 循环示例:

java 复制代码
List<String> names = List.of("patrick", "mike", "james", "bill");

List<String> result = new ArrayList<>();
for (String name : names) {
    if (name.length() > 4) {
        result.add(name.toUpperCase());
    }
}
Collections.sort(result);
System.out.println(result);

如果使用流呢?

java 复制代码
List<String> names = List.of("patrick", "mike", "james", "bill");

List<String> result = names.stream()
        .filter(name -> name.length() > 4)
        .map(String::toUpperCase)
        .sorted()
        .toList();

System.out.println(result);

与循环不同,流的操作语句读起来几乎就像英语:"取出名字,按长度过滤,转换为大写,进行排序,然后收集到一个列表中"。 操作完成后,输出将是:[james, patrick]

从集合创建流

流可以从多种来源开始。把下面所有的示例都看作是 "打开水龙头" 的方式。

以下是如何从集合(在这个例子中是一个包含名字的列表)创建流的方法:

java 复制代码
List<String> names = List.of("James", "Bill", "Patrick");
Stream<String> nameStream = names.stream();

以下是如何从 Map 创建流的方法:

java 复制代码
Map<Integer, String> idToName = Map.of(1, "James", 2, "Bill");
Stream<Map.Entry<Integer, String>> entryStream = idToName.entrySet().stream();

从数组创建流:

java 复制代码
String[] names = {"James", "Bill", "Patrick"};
Stream<String> nameStream = Arrays.stream(names);

当然,也可以使用 Stream.of() 创建流:

java 复制代码
Stream<Integer> numberStream = Stream.of(1, 2, 3, 4, 5);

使用 Stream.of(),你可以传入任何类型的值或对象来创建一个流。

当你手头没有集合或数组时,这是一种快速创建流的简便方法。对于小型且固定的数据集合或快速测试来说十分适用。

Stream.generate() 创建流

Stream.generate() 方法创建一个无限流。只要管道需要,它就会持续生成值:

java 复制代码
Stream.generate(() -> "hello").forEach(System.out::println);

这个流永远不会停止。使用 limit() 方法来控制它:

java 复制代码
Stream.generate(Math::random)
      .limit(5)
      .forEach(System.out::println);

Stream.generate()Stream.iterate() 都可以生成无限序列。一定要使用限制操作或短路操作,避免无限执行。

如果你需要安全地返回一个空流而不是 null,可以使用 Stream.empty()

java 复制代码
Stream<String> emptyStream = Stream.empty();

这样做避免了空指针检查,并使返回流的方法更安全、更简洁。

流操作

流有中间(延迟)操作和终端(执行)操作。这两种类型的操作共同构成了数据管道。

中间操作(途中转换)

中间流操作不会立即触发执行。它们只是在过程中添加处理步骤:

  • map:转换每个元素。
  • filter:仅保留符合条件的元素。
  • sorted:对元素进行排序。
  • distinct:去除重复项。
  • limit/skip:裁剪流。
  • flatMap:将嵌套结构(例如列表的列表)扁平化为一个流。
  • peek:让你在元素流过时查看它们(非常适合调试以及记录日志)。
  • takeWhile:持续提取元素,直到条件为假(类似于有条件的限制)。
  • dropWhile:在条件为真时跳过元素,然后保留其余元素。

流是惰性的

流首先会准备好所有步骤(过滤、映射、排序),但在终端操作触发处理之前,不会有任何实际操作发生。这种惰性求值机制通过仅处理所需的数据,提高了流操作的效率。

以这个流管道为例:

java 复制代码
List<String> names = List.of("james", "bill", "patrick", "guy", "bob");
names.stream()
     .filter(n -> n.length() > 3)  // 保留长度超过3个字符的名字
     .map(String::toUpperCase)     // 转换为大写
     .sorted();                    // 按照字母顺序排序

System.out.println("List result: " + names);

结果是: [james, bill, patrick, guy, bob]

乍一看,这个流管道似乎应该:

  1. 过滤掉 "guy""bob"(因为它们的长度不大于 3)。
  2. 将其余的转换为大写。
  3. 对它们进行排序。

但实际上,这个管道什么都没有做。

原因是 Java 中的流是惰性的

  • 所有这些调用(filtermapsorted)都是中间操作。
  • 它们不会立即执行。相反,它们只记录操作计划。
  • 只有当你添加一个终端操作,如.toList()forEach()count() 时,这个计划才会运行。

由于上述代码中没有终端操作,该流管道被丢弃,原始列表将保持不变地打印出来。

终端操作(上菜)

现在我们可以来看看流的第二类操作。终端操作会触发流运行并产生一个结果:

  • forEach():对每个元素执行某些操作。
  • collect():将元素收集到一个集合中。
  • toList():将所有元素收集到一个不可变的列表中(Java 16 及以上版本)。
  • reduce():将多个元素合并为单个结果(如求和、求积等)。
  • count():元素有多少个?
  • findFirst():返回第一个符合过滤条件的元素(在顺序很重要的情况下很有用)。
  • findAny():返回任何一个匹配的元素(在不保证顺序的并行流中特别有用)。
  • toArray():将结果收集到一个数组中。
  • min(Comparator) / max(Comparator):根据一个比较器找到最小或最大的元素。
  • anyMatch(predicate):是否有任何元素匹配?
  • allMatch(predicate):所有元素都匹配吗?
  • noneMatch(predicate):没有元素匹配吗?

下面是一个使用终端操作的流的示例:

java 复制代码
List<String> names = List.of("james", "bill", "patrick", "guy");
List<String> result = names.stream()
     .filter(n -> n.length() > 3)
     .map(String::toUpperCase)
     .sorted()
     .toList();   // 终端操作方法在这里触发执行

System.out.println(result);

上述代码输出将是:[BILL, JAMES, PATRICK]

流是一次性的

一旦流被处理,它就会被消耗掉且不能再被重复使用。终端操作会关闭流:

java 复制代码
List<String> names = List.of("James", "Bill", "Patrick");

Stream<String> s = names.stream();
s.forEach(System.out::println); // OK
s.count(); // IllegalStateException  ------ 已被处理

在这段代码中,第一次调用会使所有数据通过流水线,之后流就会被关闭。如果需要,请创建一个新的流:

java 复制代码
long count = names.stream().count(); // 正确做法:新的流实例

流管道

我们给出一个包含中间操作和终态操作的流管道示例:

java 复制代码
List<String> result = names.stream() // 数据源 
    .filter(n -> n.length() > 3) // 中间操作 
    .map(String::toUpperCase) // 中间操作 
    .sorted() // 中间操作 
    .toList(); // 终端操作

与集合共舞

除了流,Java 8 还引入了收集器,可用于描述如何收集处理后的数据。

收集到列表会创建一个新的、不可修改的、包含长度超过三个字符的名称的列表。不可变的结果使流代码更安全且更具函数式编程风格:

java 复制代码
List<String> list = names.stream ()  
    .filter (n -> n.length () > 3)  
    .toList (); // Java 16 及以上版本可用

在这里,我们将结果收集到一个集合中,这样会自动去除重复项。当唯一性比顺序更重要时,就使用集合:

java 复制代码
Set<String> set = names.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toSet());

在这里,我们将数据收集到一个 Map 中,其中键是字符串的长度,值是 name 本身:

java 复制代码
Map<Integer, String> map = names.stream()
    .collect(Collectors.toMap(
        String::length,
        n -> n
    ));

如果多个名称的长度相同,就会发生冲突。可以使用合并函数来处理这种情况:

java 复制代码
Map<Integer, String> safeMap = names.stream()
    .collect(Collectors.toMap(
        String::length,
        n -> n,
        (a, b) -> a   // 如果键发生冲突,保留第一个值。
    ));

连接字符串

Collectors.joining() 可以使用你选择的任何分隔符将流中的所有元素合并为一个字符串。你可以使用 |;,甚至 \n

java 复制代码
List<String> names = List.of("Bill", "James", "Patrick");

String result = names.stream()
    .map(String::toUpperCase)
    .collect(Collectors.joining(", "));

System.out.println(result);

输出为:BILL, JAMES, PATRICK

数据分组

Collectors.groupingBy() 按键(这里是字符串长度)对元素进行分组,并返回一个 Map<Key,List<Value>>

java 复制代码
List<String> names = List.of("james", "linus", "john", "bill", "patrick");

Map<Integer, List<String>> grouped = names.stream()
    .collect(Collectors.groupingBy(String::length));

输出为:{4=[john, bill], 5=[james, linus], 7=[patrick]}

数字汇总

你也可以使用集合进行汇总:

ini 复制代码
List<Integer> numbers = List.of(3, 5, 7, 2, 10);

IntSummaryStatistics stats = numbers.stream()
    .collect(Collectors.summarizingInt(n -> n));

System.out.println(stats);

输出为:IntSummaryStatistics {count=5, sum=27, min=2, average=5.4, max=10}

如果你只想要平均值,可以这样做:

java 复制代码
double avg = numbers.stream()
    .collect(Collectors.averagingDouble(n -> n));

函数式编程

前面提到过,流融合了函数式和声明式的元素。下面我们来看看流中的一些函数式编程元素。

Lambda 表达式与方法引用

Lambda 表达式可以在内部定义行为,而方法引用则可以重用现有的方法:

java 复制代码
names.stream()
    .filter(name -> name.length() > 3)
    .map(String::toUpperCase)
    .forEach(System.out::println);

map() vs flatMap()

根据经验来说:

  • 当你有一个输入并且想要一个输出时,使用 map() 方法。
  • 当你有一个输入并且想要多个输出(展平后的)时,使用 flatMap() 方法。

以下是一个在流中使用 map() 方法的示例:

java 复制代码
List<List<String>> nested = List.of(
    List.of("james", "bill"),
    List.of("patrick")
);

nested.stream()
      .map(list -> list.stream())
      .forEach(System.out::println);

输出会是:

plain 复制代码
java.util.stream.ReferencePipeline$Head@5ca881b5
java.util.stream.ReferencePipeline$Head@24d46ca6

有两行是因为有两个内部列表,所以你需要两个 Stream 对象。另外要注意哈希值会有所不同。

如果使用 flatMap() 呢:

java 复制代码
nested.stream()
      .flatMap(List::stream)
      .forEach(System.out::println);

输出如下:

plain 复制代码
 james
 bill
 patrick

对于更深层次的嵌套:

java 复制代码
List<List<List<String>>> deep = List.of(
    List.of(List.of("James", "Bill")),
    List.of(List.of("Patrick"))
);

List<String> flattened = deep.stream()
    .flatMap(List::stream)
    .flatMap(List::stream)
    .toList();

System.out.println(flattened);

输出为:[James, Bill, Patrick]

Optional 操作符

Optional 操作符是另一种可与流结合使用的有用操作:

java 复制代码
List<String> names = List.of("James", "Bill", "Patrick");

String found = names.stream()
    .filter(n -> n.length() > 6)
    .findFirst()
    .map(String::toUpperCase)
    .orElse("NOT FOUND");

System.out.println(found);

输出会是:NOT FOUND

findFirst() 会返回一个 Optional 对象,它可以安全地表示一个可能不存在的值。如果没有匹配项,.orElse() 会提供一个备用值。出于同样的原因,诸如 findAny()min()max() 等方法也会返回 Optional 对象。

总结

Java 的 Stream API 彻底改变了我们处理数据的方式。你只需声明"要做什么"------比如过滤、映射或排序------具体的实现细节就交给 Java 高效地去完成。

StreamCollectorOptional 结合使用,能让现代 Java 代码变得更简洁、清晰,也更健壮。当你需要对集合进行转换或分析时,Stream 是理想的选择;但如果是涉及索引操作或频繁变动的任务,传统方式可能更合适。

一旦你真正掌握了 Stream 的用法,恐怕就很难再回到手写循环的老路了。在熟悉本文介绍的基础知识后,不妨进一步探索并行流、基本类型专用流(如 IntStream)以及自定义 Collector 等高级特性。最重要的是多动手实践------试着运行和修改文中的示例代码,只有通过实际操作,才能真正内化这些技能。

相关推荐
optimistic_chen2 小时前
【Java EE进阶 --- SpringBoot】Spring事务
java·spring boot·笔记·spring·java-ee·事务
leonardee2 小时前
【玩转全栈】----Django基本配置和介绍
java·后端
Slow菜鸟2 小时前
Java 开发环境安装指南(一) | 目录设计规范
java
BS_Li2 小时前
【Linux系统编程】进程控制
java·linux·数据库
多多*2 小时前
分布式中间件 消息队列Rocketmq 详解
java·开发语言·jvm·数据库·mysql·maven·java-rocketmq
從南走到北2 小时前
JAVA外卖霸王餐CPS优惠CPS平台自主发布小程序+公众号霸王餐源码
java·开发语言·小程序
麦兜*2 小时前
Redis在Web3中的应用探索:作为链下状态缓存与索引层
java·spring boot·redis·spring cloud·缓存·docker·web3
迦蓝叶2 小时前
从繁琐到优雅:用 Project Panama 改变 Java 原生交互
java·jni·native·java新特性·原生接口·跨语言开发·projectpanama
Yue丶越2 小时前
【C语言】深入理解指针(四)
java·c语言·算法