Stream 中间操作 | JavaSE

Stream 中间操作

一、学习目标

完成本章后,你应该能够:

  • 能够解释什么是 Stream 中间操作(Intermediate Operation)。
  • 能够使用 filter() 根据条件过滤数据。
  • 能够使用 map() 完成元素转换。
  • 能够使用 distinct() 去除重复元素。
  • 能够使用 sorted() 和 Comparator 完成排序。
  • 能够使用 limit() 获取前若干元素。
  • 能够使用 skip() 跳过前若干元素。
  • 能够将多个中间操作组合成完整 Stream Pipeline。
  • 能够理解无状态中间操作与有状态中间操作的基本区别。
  • 能够理解 Stream.concat() 是流组合能力,而不是普通实例中间操作。
  • 能够认识 Java 21 中 flatMap()peek()takeWhile()dropWhile() 等补充能力。

二、核心知识

2.1 什么是中间操作

上一章可以得到:

Stream<String> stream =
        names.stream();

现在我们需要开始处理数据。

例如:

names.stream()
        .filter(...)
        .map(...)
        .sorted(...)
        .limit(...)

这些操作的共同特点是:

接收一个 Stream,并继续产生新的 Stream,从而能够继续组成流水线。

例如:

Stream<String>
      ↓
filter()
      ↓
Stream<String>
      ↓
map()
      ↓
Stream<Integer>
      ↓
sorted()
      ↓
Stream<Integer>

这种处于:

数据源
和
终结操作

之间的 Stream 处理步骤,就叫:

中间操作(Intermediate Operation)。


2.2 中间操作不会结束流水线

例如:

Stream<String> result =
        names.stream()
                .filter(
                        name ->
                                name.startsWith("张")
                );

filter() 之后仍然得到:

Stream<String>

因此还可以:

result
        .filter(...)
        .map(...)
        .sorted(...);

这就是 Stream 可以进行链式调用的基础。


2.3 原课程核心中间操作

本章主线:

| 方法 | 核心作用 | | ------------ | ------------ | | filter() | 过滤 | | map() | 映射 / 转换 | | distinct() | 去重 | | sorted() | 排序 | | limit() | 取前若干个 | | skip() | 跳过前若干个 |

原课程另外给出:

Stream.concat(a, b)

用于合并两个 Stream。

后面单独说明。


三、filter:过滤

3.1 基本作用

filter()

根据条件决定元素是否继续留在 Stream 中。

基本形式:

stream.filter(predicate)

其参数是:

Predicate<T>

上一阶段已经学习:

Predicate<T>

T → boolean

所以:

filter()

正好需要一个:

对元素进行真假判断的行为。


3.2 姓张的名字

例如:

List<String> names =
        List.of(
                "张无忌",
                "周芷若",
                "赵敏",
                "张强",
                "张三丰",
                "张翠山"
        );

筛选:

names.stream()
        .filter(
                name ->
                        name.startsWith("张")
        );

逻辑:

张无忌 → true  → 保留
周芷若 → false → 丢弃
赵敏   → false → 丢弃
张强   → true  → 保留
...

所以:

Predicate 返回 true 的元素保留下来。


3.3 多条件过滤

可以:

names.stream()
        .filter(
                name ->
                        name.startsWith("张")
                        && name.length() == 3
        );

也可以拆成:

names.stream()
        .filter(
                name ->
                        name.startsWith("张")
        )
        .filter(
                name ->
                        name.length() == 3
        );

两种方式在简单场景都可以。

后者往往更容易表达:

条件 1
 ↓
条件 2

的流水线思维。


3.4 对象过滤

例如:

students.stream()
        .filter(
                student ->
                        student.getScore() >= 60
        );

表达:

只让及格学生继续向下游流动。


四、map:映射与转换

4.1 filter 与 map 的根本区别

filter()

决定一个元素“留还是不留”。

map()

决定一个元素“变成什么”。

例如:

filter

Student
  ↓
满足条件?
  ↓
仍然是 Student

而:

map

Student
  ↓
getName()
  ↓
String

所以 map() 非常重要。


4.2 基本形式

stream.map(mapper)

它使用:

Function<T, R>

也就是:

T → R

4.3 字符串转长度

例如:

Stream<String> names =
        Stream.of(
                "Java",
                "MySQL",
                "Spring"
        );

可以:

Stream<Integer> lengths =
        names.map(
                name ->
                        name.length()
        );

发生了类型变化:

Stream<String>
      ↓
map(String::length)
      ↓
Stream<Integer>

4.4 方法引用

前一章已经学习方法引用:

name -> name.length()

可以:

String::length

因此:

Stream<Integer> lengths =
        names.map(
                String::length
        );

4.5 Student 映射为姓名

非常典型:

Stream<String> names =
        students.stream()
                .map(
                        Student::getName
                );

过程:

Student
   ↓
getName()
   ↓
String

注意:

map() 可以改变 Stream 的元素类型。


4.6 map 不是修改原对象的同义词

例如:

students.stream()
        .map(Student::getName);

不是:

把原来的 Student 对象修改成 String。

而是:

根据每个 Student 计算一个新结果,组成新的 Stream。

原来的:

List<Student>

依然还是:

List<Student>

五、distinct:去重

5.1 基本作用

stream.distinct()

用于:

去掉 Stream 中重复的元素。

例如:

Stream.of(
        "Java",
        "MySQL",
        "Java",
        "Spring",
        "MySQL"
)
.distinct();

结果逻辑上:

Java
MySQL
Spring

5.2 distinct 根据什么判断重复

对于对象 Stream,distinct() 的重复语义基于:

Object.equals()

例如字符串已经正确重写了:

equals()

所以相同内容 String 能正确去重。


5.3 自定义对象

假设:

Student a =
        new Student(
                "张三",
                20
        );

Student b =
        new Student(
                "张三",
                20
        );

如果 Student 没有根据业务属性重写:

equals()

那么:

a.equals(b)

默认可能仍为:

false

此时:

Stream.of(a, b)
        .distinct()

不一定得到你业务上期待的:

“只剩一个张三”。

因此自定义对象去重的核心仍然回到前面学过的:

对象相等语义。


5.4 有序 Stream 的稳定性

对于具有 encounter order 的 Stream:

A
B
A
C
B

执行:

distinct()

会保留第一次遇到的重复元素:

A
B
C

所以在有序 Stream 中,去重保持稳定的遇见顺序。


六、sorted:排序

6.1 自然排序

stream.sorted()

根据元素的:

自然顺序(Natural Order)

进行排序。

例如:

Stream.of(
        88.6,
        66.6,
        99.6,
        77.6
)
.sorted();

得到升序:

66.6
77.6
88.6
99.6

6.2 sorted() 的前提

自然排序要求元素能够比较。

通常意味着对象需要实现:

Comparable

例如:

  • Integer
  • Double
  • String

都已经拥有自然排序能力。

如果元素没有可用的自然顺序,直接:

sorted()

可能在流水线真正执行时产生:

ClassCastException

6.3 Comparator 自定义排序

更灵活:

sorted(Comparator)

例如成绩降序:

scores.stream()
        .sorted(
                (s1, s2) ->
                        Double.compare(
                                s2,
                                s1
                        )
        );

因为:

s2 与 s1
反过来比较

得到降序。


6.4 Comparator 方法

还可以:

scores.stream()
        .sorted(
                Comparator.reverseOrder()
        );

对象:

students.stream()
        .sorted(
                Comparator.comparingDouble(
                        Student::getScore
                )
        );

成绩降序:

students.stream()
        .sorted(
                Comparator
                        .comparingDouble(
                                Student::getScore
                        )
                        .reversed()
        );

这与前面 Comparator 章节已经形成完整连接。


七、limit:限制数量

7.1 基本作用

limit(n)

表示:

Stream 最多只保留前 n 个元素。

例如:

Stream.of(
        "A",
        "B",
        "C",
        "D"
)
.limit(2);

结果:

A
B

7.2 排名前两名

这是非常典型的组合:

scores.stream()
        .sorted(
                Comparator.reverseOrder()
        )
        .limit(2);

语义:

成绩
 ↓
从高到低排序
 ↓
只取前 2

这已经非常接近自然语言:

获取成绩最高的两个人。


7.3 limit(0)

limit(0)

得到空 Stream。


7.4 负数

不要写:

limit(-1)

maxSize 为负数属于非法参数。


八、skip:跳过元素

8.1 基本作用

skip(n)

表示:

丢掉前 n 个元素,保留剩余元素。

例如:

Stream.of(
        "A",
        "B",
        "C",
        "D"
)
.skip(2);

剩余:

C
D

8.2 排名第三名以后

例如:

scores.stream()
        .sorted(
                Comparator.reverseOrder()
        )
        .skip(2);

语义:

成绩降序
 ↓
跳过前两名
 ↓
得到第三名及之后

8.3 skip 大于元素数量

例如原 Stream 只有:

3 个元素

执行:

skip(100)

结果就是:

空 Stream

8.4 skip 负数

skip(-1)

同样属于非法参数。


九、limit 与 skip 组合

9.1 分页思想

假设一页:

10 条数据

第 3 页需要:

跳过前 20 条
取 10 条

可以建立:

stream
        .skip(20)
        .limit(10);

一般分页模型:

pageSize = 每页数量
pageNum  = 页码

skip:
(pageNum - 1) * pageSize

limit:
pageSize

例如:

students.stream()
        .skip(
                (long) (pageNum - 1)
                        * pageSize
        )
        .limit(pageSize);

这里帮助理解:

skip
+
limit

可以表达“区间截取”。

实际数据库分页通常应该由数据库查询完成,而不是把全部数据加载进 Java 后再 Stream 分页。


十、多个中间操作组成 Pipeline

现在可以完整组合:

students.stream()
        .filter(
                student ->
                        student.getScore() >= 60
        )
        .sorted(
                Comparator
                        .comparingDouble(
                                Student::getScore
                        )
                        .reversed()
        )
        .limit(3)
        .map(
                Student::getName
        );

从上向下读:

学生
 ↓
只要及格的
 ↓
按成绩从高到低
 ↓
只取前三
 ↓
把 Student 转为姓名 String

最终类型变化:

List<Student>
    ↓
Stream<Student>
    ↓ filter
Stream<Student>
    ↓ sorted
Stream<Student>
    ↓ limit
Stream<Student>
    ↓ map
Stream<String>

这是掌握 Stream 最重要的能力之一:

看懂每一步以后 Stream 中到底装的是什么类型。


十一、无状态与有状态中间操作

11.1 filter / map

例如:

filter()
map()

处理当前元素时,通常不需要先知道 Stream 中其他所有元素。

例如:

name -> name.length()

只需要当前 name

这类操作可以理解为:

无状态中间操作(Stateless Intermediate Operation)。


11.2 sorted / distinct

排序:

sorted()

如果不知道其他元素,无法判断当前元素最终应该放在哪里。

去重:

distinct()

至少需要知道:

之前是否已经见过等价元素。

因此属于:

有状态中间操作(Stateful Intermediate Operation)。


11.3 limit / skip

limit()skip() 同样需要知道元素处于流水线中的位置或数量状态。

因此官方也将它们归为有状态中间操作。

其中:

limit()

还是一个:

短路型有状态中间操作(Short-circuiting Stateful Intermediate Operation)

因为满足数量以后,某些流水线不需要继续生产更多元素。


十二、Stream.concat:合并两个 Stream

原课程将:

Stream.concat(a, b)

与中间方法放在同一组进行教学。

它的作用确实与 Stream 处理密切相关:

Stream<String> a =
        Stream.of(
                "A",
                "B"
        );

Stream<String> b =
        Stream.of(
                "C",
                "D"
        );

Stream<String> result =
        Stream.concat(a, b);

结果顺序:

A
B
C
D

即:

第一个 Stream
      ↓
完整元素

然后
      ↓

第二个 Stream

但是从 API 分类上需要更准确:

concat()Stream 的静态方法,用于创建一个惰性拼接的新 Stream,而不是 stream.filter() 这种实例中间操作。

所以不要写:

// stream.concat(other)

而应该:

Stream.concat(
        stream1,
        stream2
);

十三、JDK 21 官方补充:flatMap

13.1 为什么 map 有时不够

假设:

List<List<String>> groups;

如果:

groups.stream()
        .map(
                List::stream
        );

得到的是:

Stream<Stream<String>>

也就是:

一条 Stream
里面每个元素
又是一条 Stream

13.2 flatMap 的作用

flatMap() 可以理解:

map
+
flatten

也就是:

先映射,再把多层 Stream 展平。

例如:

groups.stream()
        .flatMap(
                List::stream
        );

结果:

Stream<String>

示意:

[
 [A, B],
 [C, D]
]

     ↓ flatMap

A
B
C
D

这是处理嵌套集合非常重要的 Stream 能力。


十四、JDK 21 官方补充:peek

14.1 作用

peek(action)

会在元素经过流水线时执行一个附加动作,并继续返回 Stream。

例如调试:

students.stream()
        .filter(
                s ->
                        s.getScore() >= 60
        )
        .peek(
                s ->
                        System.out.println(
                                "过滤后:" + s
                        )
        )
        .map(
                Student::getName
        );

14.2 peek 的正确定位

peek() 最典型的用途是:

观察和 Debug Stream Pipeline。

不要把:

peek()

当作执行复杂业务副作用的主要工具。

因为 Stream 可能存在:

  • 惰性
  • 短路
  • 优化
  • 并行

某些元素甚至可能不会实际经过某个 peek()

所以:

peek() 更适合看数据流经某一步时是什么状态。


十五、JDK 21 官方补充:takeWhile 与 dropWhile

这两个方法从 Java 9 开始存在。

15.1 takeWhile

对于有序 Stream

takeWhile(predicate)

表示:

从开头连续获取满足条件的最长前缀,一旦遇到第一个不满足条件的元素就停止。

例如:

Stream.of(
        2,
        4,
        6,
        7,
        8,
        10
)
.takeWhile(
        number ->
                number % 2 == 0
);

得到:

2
4
6

注意:

7

不满足以后就停止了。

后面的:

8
10

虽然也是偶数,也不会再取。


15.2 takeWhile 与 filter 的区别

filter()

2 4 6 7 8 10
↓
所有偶数
↓
2 4 6 8 10

takeWhile()

2 4 6 7 8 10
      ↑
遇到第一个不满足
停止
↓
2 4 6

这是完全不同的语义。


15.3 dropWhile

对于有序 Stream:

dropWhile(predicate)

表示:

从开头连续丢弃满足条件的最长前缀,然后保留后面的内容。

例如:

Stream.of(
        2,
        4,
        6,
        7,
        8,
        10
)
.dropWhile(
        number ->
                number % 2 == 0
);

得到:

7
8
10

它不是:

删除所有偶数。

删除所有满足条件的元素应该考虑:

filter(...)

十六、中间操作的执行顺序很重要

假设:

stream
        .sorted(...)
        .limit(3);

含义:

先对全部候选数据排序
 ↓
再拿前三名

这适合:

Top 3。

但:

stream
        .limit(3)
        .sorted(...);

含义:

先拿原 Stream 前三个
 ↓
只对这三个排序

二者完全不同。

再例如:

stream
        .filter(...)
        .limit(10);

表示:

满足条件的数据中取前 10 条。

而:

stream
        .limit(10)
        .filter(...);

表示:

原数据前 10 条里面再过滤。

所以:

Stream 的链式代码虽然很短,但操作顺序就是业务语义。


十七、实践应用:星雨笔录文章处理

假设:

List<Article> articles;

Article:

title
category
viewCount
published

需求:

找到已经发布的 Java 文章,按浏览量降序取前 5 篇,只保留标题。

可以构建:

articles.stream()
        .filter(
                Article::isPublished
        )
        .filter(
                article ->
                        "Java".equals(
                                article.getCategory()
                        )
        )
        .sorted(
                Comparator
                        .comparingLong(
                                Article::getViewCount
                        )
                        .reversed()
        )
        .limit(5)
        .map(
                Article::getTitle
        );

完整逻辑:

List<Article>
      ↓
stream
      ↓
已发布
      ↓
Java 分类
      ↓
浏览量降序
      ↓
前 5
      ↓
提取标题
      ↓
Stream<String>

这就是 Stream Pipeline 在真实业务中的典型形态。


十八、常见问题

18.1 filter 会修改原集合吗?

不会因为调用:

filter()

就把不满足条件的元素从原集合删除。

它返回:

由满足条件的元素组成的新 Stream。


18.2 map 会修改原来的元素吗?

map() 的语义是:

根据映射函数产生新的元素结果。

例如:

Student → String

不是把原集合的数据结构直接改成 String 集合。


18.3 distinct 根据什么去重?

语义上依据:

Object.equals()

判断元素是否相等。

自定义类型必须特别注意自己的对象相等语义。


18.4 sorted() 一定能排序吗?

不是。

无 Comparator 的:

sorted()

要求元素具有可用的自然顺序。

自定义对象通常应该:

  • 实现 Comparable
  • 或直接提供 Comparator

18.5 limit 和 skip 谁先谁后无所谓吗?

错误。

Stream 操作顺序就是业务语义。


18.6 中间操作一定会马上执行吗?

不一定。

Stream 中间操作通常具有惰性。

只有整个 Pipeline 被终结操作消费时,才真正推动数据处理。


18.7 Stream.concat 是普通中间操作吗?

严格说不是。

它是:

Stream.concat(a, b)

静态方法,用于创建一个惰性拼接 Stream。


18.8 peek 可以代替 forEach 做业务吗?

一般不推荐这样理解。

peek() 主要用于:

调试和观察流水线中的元素。

终结阶段真正处理元素将在下一章学习:

forEach()

等终结操作。


十九、练习与验收

19.1 知识问答

  1. 什么是 Stream 中间操作?
  2. 中间操作与终结操作最核心的区别是什么?
  3. filter() 接收哪个函数式接口?
  4. filter() 中返回 true 表示什么?
  5. map() 的作用是什么?
  6. map() 能否改变 Stream 元素类型?
  7. distinct() 根据什么语义判断重复?
  8. sorted()sorted(Comparator) 有什么区别?
  9. limit(3) 表示什么?
  10. skip(3) 表示什么?
  11. limitskip 如何组合表达分页?
  12. 哪些中间操作需要记忆其他元素或位置状态?
  13. flatMap() 主要解决什么问题?
  14. peek() 最适合什么场景?
  15. takeWhile()filter() 有什么本质区别?
  16. Stream.concat() 为什么不属于普通实例中间操作?

19.2 代码阅读

分析:

Stream.of(
        10,
        30,
        20,
        40
)
.sorted()
.limit(2);

流水线逻辑是什么?


分析:

Stream.of(
        10,
        30,
        20,
        40
)
.limit(2)
.sorted();

与上一段得到的业务语义是否相同?

说明原因。


判断:

students.stream()
        .map(
                Student::getName
        );

map() 前后的 Stream 类型分别是什么?

19.3 手写代码

给出:

List<String> names =
        List.of(
                "张无忌",
                "赵敏",
                "张三丰",
                "张翠山",
                "周芷若"
        );

完成:

  1. 筛选姓张。
  2. 筛选三个字姓名。
  3. 去重。
  4. 按字符串自然顺序排序。
  5. 只取前两个。
  6. 跳过第一个。
  7. 将姓名映射为长度。

暂时只写 Stream Pipeline,不要求收集结果。

19.4 Debug

下面代码业务目标是:

所有及格学生中成绩最高的 3 人。

判断:

students.stream()
        .limit(3)
        .filter(
                s ->
                        s.getScore() >= 60
        )
        .sorted(
                Comparator
                        .comparingDouble(
                                Student::getScore
                        )
                        .reversed()
        );

操作顺序有什么问题?

重新设计正确 Pipeline。


分析:

students.stream()
        .map(
                Student::getName
        )
        .filter(
                student ->
                        student.getScore() > 60
        );

哪里存在类型错误?

为什么?

19.5 综合训练

假设:

List<Student> students;

Student:

name
age
score
city

要求设计 Stream Pipeline:

  1. 只保留北京学生。
  2. 只保留及格学生。
  3. 按成绩降序。
  4. 取前 5。
  5. 映射为姓名。

要求逐行写出每一步之后的类型:

Stream<Student>
...
...
Stream<String>

19.6 本章验收

关闭资料,能够从零写出并解释:

students.stream()
        .filter(...)
        .distinct()
        .sorted(...)
        .skip(...)
        .limit(...)
        .map(...);

并能准确回答:

filter:留谁?

map:变成什么?

distinct:谁算重复?

sorted:按什么顺序?

skip:前面丢多少?

limit:最多留多少?

还必须能够解释:

为什么 sorted().limit(3)

与

limit(3).sorted()

不等价?

如果能够从业务需求直接拆出正确的 Stream 操作顺序,而不是只会背 API 名称,本章才算真正掌握。