跳到主要内容
约 11 分钟3 张卡

Stream

Java Stream 用流水线描述数据怎样被筛选、转换和聚合。中间操作先记录处理规则,终止操作再按结果需要求值。

本文从流水线结构入手,解释惰性求值和短路,再说明一次性消费、数据映射与副作用。最后通过空流和无限流,判断操作是否执行、何时结束。

1. 面试核心​

先记住这几条,再读正文
  • 流水线与求值。 Stream 流水线由数据源、中间操作和终止操作组成,中间操作记录规则,终止操作触发求值。卡 · 流水线结构
  • 有状态操作。 串行无状态操作可逐元素衔接,sorted 和 distinct 等有状态操作则需缓存元素或记录已见的值。
  • 短路边界。 短路操作能在结果确定后停止读取,但无限流先排序再 limit 时,排序仍会等待输入结束。卡 · 空流与无限流的终止边界
  • 消费与关闭。 Stream 只允许消费一次,Files.lines 等 I/O 来源需用 try-with-resources 显式关闭。
  • 副作用边界。 数据源大小已知且操作不改变元素数量时,count 可直接返回计数,跳过 peek 中的动作。

2. 流水线结构​

要理解 Stream 何时处理数据,需要先区分流水线的三个部分:

  1. 数据源提供元素,可以是集合、数组、文件或生成函数。
  2. 中间操作记录怎样筛选或转换元素,例如 filter、map 和 sorted。
  3. 终止操作触发求值,产生结果或执行动作,例如 toList、collect、reduce 和 findFirst。
List<String> names = users.stream()
.filter(User::active)
.map(User::displayName)
.toList();

结果 names 只包含活跃用户的显示名。用户数据保存在 users 中,创建 Stream 时只记录从这个数据源开始处理的规则,无需复制一份用户列表。

Stream 采用内部迭代,也就是调用方提供每个元素的处理规则,由框架控制遍历时机和方式。因此,框架可以让一个元素连续经过多个阶段,也可以提前停止遍历或在满足条件时并行处理。

3. 惰性求值​

创建中间操作时,Stream 只保存待执行的函数。这种把计算推迟到需要结果时再进行的方式,称为惰性求值。因此,下面的代码不会打印内容:

Stream<String> pipeline = users.stream()
.filter(user -> {
System.out.println("filter " + user.id());
return user.active();
})
.map(User::displayName);

filter 和 map 返回表示后续阶段的新 Stream,但此时还没有遍历用户。加入 toList() 后,框架才需要计算最终列表,用户开始进入流水线:

List<String> names = pipeline.toList();

这时会执行筛选并打印经过筛选阶段的用户 ID,最后返回显示名列表。惰性求值带来两个直接结果:

  • 没有终止操作,流水线不会遍历数据源。
  • 终止操作不需要全部元素时,前面的操作也可以提前停止。

3.1 元素的处理顺序​

无状态操作可以逐个处理元素,也就是处理当前元素时无需记住其他元素。串行执行时,一个用户可以先经过 filter,符合条件后立即经过 map,再处理下一个用户:

Optional<String> first = users.stream()
.filter(User::active)
.map(User::displayName)
.findFirst();

这里的 findFirst 只需要第一个活跃用户的显示名。找到结果后,它就可以请求停止后续遍历,因此无需先构造所有活跃用户的临时列表。

逐元素衔接省去了中间集合,但排序、去重和复杂 Lambda 仍然有计算成本。实际性能要结合数据源、操作顺序和终止条件判断。

4. 无状态操作与有状态操作​

能否逐元素连续处理,取决于操作是否需要记住其他输入。以下无状态操作可以独立处理当前元素:

  • filter 根据条件决定是否保留当前元素。
  • map 将当前元素转换为一个结果。
  • flatMap 将当前元素转换为一个流,再展开其中的元素。
  • peek 在元素经过时执行观察动作。

以下有状态操作则需要记录已处理内容,或等待更多输入:

  • distinct 需要记录已出现的值。
  • 无排序信息可复用时,sorted 先取得全部输入再输出。
  • limit、skip 在有序并行流中需要维护位置关系。
List<String> topNames = users.stream()
.filter(User::active)
.map(User::displayName)
.sorted()
.limit(10)
.toList();

示例先筛选活跃用户,再对显示名排序,最后取前 10 个。若过滤后只剩少量用户,排序需要处理的数据就更少。将等价的纯过滤放在排序后,筛选结果可以相同,但会增加进入排序阶段的数据量。

4.1 短路操作​

短路操作在已经确定结果时,可以停止读取后续元素。例如,anyMatch 找到一个匹配元素后,就能返回 true。

匹配与查找操作的短路条件各不相同:

  • anyMatch 找到匹配元素即可返回 true,allMatch 找到不匹配元素即可返回 false。
  • noneMatch 找到匹配元素即可返回 false。
  • findFirst 和 findAny 找到符合各自顺序要求的结果即可返回。
  • limit 是短路中间操作,限制后续阶段最多接收多少元素。

处理无限流时,需要给终止操作一个能实际到达的结束条件:

List<Integer> powers = Stream.iterate(1, value -> value * 2)
.limit(10)
.toList();

这里得到从 1 到 512 的 10 个整数,每项是上一项的两倍。删除 limit 后,源会持续生成元素,toList 等不到结束,也会持续消耗内存。

5. 一次性消费​

一个 Stream 只允许消费一次,终止操作执行后就结束了这次流水线。再次调用终止操作会违反使用约定:

Stream<User> activeUsers = users.stream().filter(User::active);

long count = activeUsers.count();
// List<User> result = activeUsers.toList();
// 运行时抛出 IllegalStateException

示例先完成计数,再取消注释调用 toList() 时,会抛出 IllegalStateException。Stream 可以持有遍历位置、短路状态和资源,第二次调用无法保证回到相同的起点。需要重复计算时,应从可重复读取的数据源创建新流水线:

Supplier<Stream<User>> activeUsers =
() -> users.stream().filter(User::active);

long count = activeUsers.get().count();
List<User> result = activeUsers.get().toList();

Supplier 每次都创建新 Stream,所以计数和收集分别执行一次遍历。两次调用读取的是各自遍历时的数据,若源集合中途改变,结果也会随之变化。

5.1 I/O 数据源关闭​

集合流无需释放文件句柄,而 Files.lines 返回的 Stream 持有打开的文件。因此,读取文件的流水线应放进 try-with-resources,由它在结束时关闭资源:

try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
long nonBlankLines = lines.filter(line -> !line.isBlank()).count();
}

示例统计非空白行,退出代码块时关闭文件。即使 count() 已经完成,关闭责任仍属于创建这个 I/O 流的调用方。

6. map、flatMap 与归约​

数据转换时,要先判断一个输入会产生一个结果,还是一组结果。map 让一个输入对应一个输出,例如把用户转换为 ID:

List<Long> ids = users.stream()
.map(User::id)
.toList();

这里的 ids 保存每个用户的 ID。一个用户若拥有多个角色,就需要把各用户的角色放进同一条流。flatMap 要求映射函数返回 Stream,再将这些流逐个展开:

List<String> roles = users.stream()
.flatMap(user -> user.roles().stream())
.distinct()
.toList();

roles 因此包含所有用户的角色,并由 distinct() 去重。假设 user.roles() 返回 List<String>,map(User::roles) 会得到 Stream<List<String>>。上面的 flatMap 则得到 Stream<String>,后续处理的是每个角色字符串。

把多个元素合成一个结果称为归约,求和就是一种归约。数值流提供了专门的 sum():

int total = orders.stream()
.mapToInt(Order::quantity)
.sum();

mapToInt 把订单数量转换为基本类型 int 流,sum() 再计算总数,从而减少包装对象开销。求和、计数和最值可以使用专门的终止操作。

合成不可变值可用 reduce,累积可变容器用 collect,两者的区别见 Collector。

7. 副作用​

流水线中的函数若修改外部对象,就产生了副作用。下面的写法把显示名放进外部列表,使结果收集依赖列表的共享状态:

List<String> names = new ArrayList<>();

users.stream()
.filter(User::active)
.map(User::displayName)
.forEach(names::add);

串行执行时,这段代码能收集活跃用户的显示名。改成并行流后,多个任务却会同时修改同一个 ArrayList,产生数据竞争。用 toList() 表达结果需求,可以让框架安排各部分结果的合并:

List<String> names = users.stream()
.filter(User::active)
.map(User::displayName)
.toList();

这段代码直接返回显示名列表,调用方无需管理累积过程中的共享列表。

peek 适合调试时观察元素经过流水线的过程。短路会让部分元素停止进入后续阶段,优化也可以跳过不影响结果的调用。因此,保存数据库、发送消息和修改业务状态等必要动作,应放在明确执行的业务流程中。

8. 适用边界​

Stream 适合方向明确的数据转换链,例如筛选后映射,再分组或聚合。需要决定是否使用 Stream 时,先判断能否清楚地说出每个阶段做什么。遇到以下控制流程或状态处理需求,普通循环更容易维护:

  • 每一步包含多处分支和提前返回。
  • 处理过程需要频繁修改多个外部状态。
  • 异常恢复与重试逻辑比数据转换更重要。
  • 调试时必须观察细粒度的状态变化。

每个阶段都能用明确动词命名时,读者更容易沿着数据流理解代码。写法是否合适还取决于副作用、异常处理和数据规模,代码行数只是表面差异。

性能敏感时,再围绕遍历与计算成本检查下面几项:

  1. 数据源是否容易遍历或拆分。
  2. sorted、distinct 等有状态操作放在什么位置。
  3. 是否因装箱产生大量临时对象。
  4. 短路操作能否实际提前结束。
  5. 普通循环、串行 Stream 和并行 Stream 的基准结果有何差异。

9. 空流与无限流的终止边界​

判断流水线的结果,需要同时考虑输入是否为空,以及上游是否能提供下一项。空流没有可供检查的元素,因此匹配操作直接采用各自的空集合语义。

空流的匹配与查找结果如下:

  • anyMatch 返回 false,因为没有任何元素满足条件。
  • allMatch 返回 true,因为没有元素违反条件。
  • noneMatch 返回 true,因为没有元素满足条件。
  • 无初值的 reduce 和 findFirst 返回空 Optional,表示缺少结果。

这些匹配操作都不会调用谓词,也就是传入的判断函数。此外,即使流有元素,实现也可以省去不影响结果的遍历:

boolean all = Stream.<Integer>empty().allMatch(n -> n > 0); // true
long count = Stream.of(1, 2, 3).peek(System.out::println).count();
// count 为 3;实现可直接使用已知大小,跳过 peek。

第一个表达式因输入为空得到 true。第二个表达式的数据源大小已知,peek 又不改变元素数量。因此,实现可以直接返回 3,省去遍历和打印。

短路能否结束,还受操作顺序影响。无限递增流先 sorted() 再 limit(10) 时,排序会等待全部输入到齐,始终无法交出第一个结果。先 limit(10) 再排序,则只需取得并排序十项。

去重还要注意元素的比较方式。distinct() 按 equals 判断重复,数组沿用对象身份比较。因此,两个内容相同但分别创建的数组仍会被视为不同元素。

10. 常见问题​

下面说明源数据修改、查找顺序和结果可修改性,帮助区分 Stream 的约定与 Lambda 自己的行为。

10.1 Stream 会修改源集合或其中的元素吗?​

filter 和 map 本身只描述转换,传入的 Lambda 则可以主动修改元素或外部对象。执行期间修改普通源集合,会违反不干扰约束,也就是遍历期间保持源数据稳定的要求。这会导致 ConcurrentModificationException 或错误的遍历结果。

10.2 findFirst 与 findAny 有什么区别?​

10.3 Stream.toList() 返回的列表可以修改吗?​

10.4 为什么 peek 有时没有执行?​

11 · 面试题卡片​

11.1 Stream 的中间操作和终止操作有什么区别,惰性求值怎样工作​

出现公司:阿里巴巴、腾讯、京东、美团、经纬恒润

考察重点

  • 区分数据源、中间操作和终止操作在流水线中的职责。
  • 说明终止操作怎样触发求值,短路操作怎样提前停止。
  • 解释有状态操作为何需要缓存数据,以及怎样影响逐元素衔接和并行效率。

相关内容:流水线结构、惰性求值、无状态操作与有状态操作。

参考回答

中间操作记录过滤、映射或排序等规则,终止操作触发求值,产生结果或执行动作。

创建 filter 和 map 时,只形成新的处理阶段。执行 toList() 后,才需要从源头读取元素。串行无状态阶段可以让一个元素连续通过筛选和映射,因此省去完整的中间集合。

findFirst 或 anyMatch 确定结果后,可以提前停止后续遍历。排序需要等待更多输入,去重需要记录已经出现的值,所以这些有状态阶段会影响短路时机和缓存开销。是否更快仍需结合数据源和计算成本判断。

11.2 如何把 List<Person> 转成以姓名为键的 Map,姓名重复时怎么办​

出现公司:飞猪

考察重点

  • 区分逐元素转换的 map 与累积结果的 collect。
  • 说明 Collectors.toMap 遇到重复键时怎样处理。
  • 根据姓名是否唯一,选择保留单条记录还是按姓名分组。

相关内容:Collector:toMap 与键冲突。

参考回答

可以用 Collectors.toMap(Person::name, Function.identity(), mergeFunction) 建立映射,由合并函数处理重复姓名。

姓名作为键,人员对象本身作为值。省略合并函数时,重复键会抛出 IllegalStateException。若业务只保留一人,合并函数应按更新时间或明确优先级选择,而不是随意保留第一条。若每个姓名需要保留多人,就使用 groupingBy(Person::name)。

具体实现见 Collector。

11.3 空流的 allMatch 为何返回 true,limit 又为何不保证无限流结束?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释空流匹配的结果,并根据上游是否能产生元素判断短路能否结束。理解已知大小怎样让 count 跳过遍历。

相关内容:空流与无限流的终止边界。

参考回答

空流没有违反条件的元素,所以 allMatch 返回 true。

limit 能限制向下游提供的数量,但仍需要上游先产出元素。

无限流若先经过 sorted(),排序会持续等待输入结束,下游的 limit 就一直拿不到结果。先 limit(10) 再排序,则把排序范围限制为十项,流水线可以结束。

12. 参考资料​