系列:Java 后端实战精通营 · 作者:鱼宵
配套课程仓库:https://gitee.com/j67mk2/stream-journey (第 4 课)
先看一个面试现场翻车的场景
面试官随手一题:"集合里十万条记录,你怎么判断里面有没有符合条件的?"
我一个朋友张口就是:"先 stream(),filter 完再 count(),count 大于 0 就说明有。"面试官又问:"那如果答案在第一条数据上,你把十万条全跑一遍,图啥呢?"
朋友当场卡住。图啥?图把 anyMatch 这个"查到就走"的按钮忘掉了。十万条数据,命中第一条就该收工的事,硬生生数完全程------这就是不懂"短路"交的学费。
如果你也写过"filter 完再 count 大于 0"这种代码,或者被问过"终端操作有哪些、哪些会短路"答不上来,这篇就是给你补的。代码全在 stream-journey 第 4 课工程里,clone 下来跑一遍,10 万元素的计时对比当场就出来。
学完本文你能回答:终端操作全家桶到底有哪些?什么是短路操作、它为什么能省时间?为什么 max/min 返回的不是 Student 而是一个"盒子"?count 返回 int 还是 long?
一句话原理:终端操作是点火按钮,短路操作是"查到就走"
先摆结论:终端操作(terminal operation)是管道的收尾动作,一条流有且只有一个,它一执行,整条管道才真正被点着。
回忆第 1 课说的三件套:源、中间操作、终端操作。filter、map 这些中间操作都是"排工位",工位排得再长,没人按启动按钮就没人干活。终端操作就是那个按钮------collect、forEach、count、max、min、findFirst、anyMatch、reduce,一按到底,最后吐出一个结果(一个 List、一个数字、一次打印)。
| 终端操作 | 干什么 | 返回什么 | 短不短路 |
|---|---|---|---|
| collect | 收进 List/Set/Map | 容器 | 不短路 |
| forEach | 挨个拜访元素 | void | 不短路 |
| count | 数个数 | long(不是 int!) | 不短路 |
| max / min | 找最大最小 | Optional | 不短路 |
| findFirst / findAny | 找第一个/随便一个 | Optional | 短路 |
| anyMatch / allMatch / noneMatch | 三个判官 | boolean | 短路 |
| reduce | 手动归约汇总 | 你给的类型 | 不短路 |
类比:终端操作像食堂流水线的结算工位 + 启动按钮 ;短路操作像老师查考勤------任务是"看看班里有没有人没来",查到第一个空位就记下来走了,不用把全班 50 人查完。anyMatch、allMatch、noneMatch、findFirst、findAny 全是这种"查到就走"的主。
另外两个新面孔:
- Optional("可能没有"的盒子) :max/min/findFirst 的返回值类型。流可能是空的------"最高分"可能根本不存在。它不会直接给你一个吓人的 null,而是先告诉你"有没有",取值用
orElseThrow()(一定有才这么写)或orElse(默认值)。 类比:快递柜的格口------柜子可能开着、也可能是空的,但它先告诉你状态,而不是直接吐一个空气出来。 - reduce(归约) :把流里的元素逐个"叠"成一个值。写法
.reduce(种子值, (当前结果, 新元素) -> 合并规则),求和就是.reduce(0, Integer::sum)。 类比:滚雪球------从一片小雪花(种子 0)开始,每碰到一个新元素就揉上去,最后滚成一个大雪球。
顺带一个"错误写法 vs 正确写法"对照,这是面试高频:
| 错误写法 | 正确写法 | 差在哪 |
|---|---|---|
filter(x).count() > 0 判断"有没有" |
anyMatch(条件) |
前者数完全部,后者命中即停 |
int n = list.stream().count() |
long n = list.stream().count() |
count 返回 long,接 int 编译报错 |
Student top = stream().max(...) |
Student top = stream().max(...).orElseThrow() |
max 返回 Optional,要拆盒 |
只写 filter(...).map(...) 不接终端 |
链尾补 toList() / forEach(...) |
没有终端 = 白写,一行不执行 |
关键代码:终端操作全家桶逐个上手
铺垫一句:下面这段代码用 5 个学生(两个故意同分 59)当小数据集,把 collect、forEach、count、max/min、findFirst/findAny、三个 match、reduce 挨个跑一遍,最后接一段 10 万元素的短路计时实测。完整可运行(JDK 21+,直接复制就能跑):
java
package com.stream.lesson04;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
/**
* Stream 实战精通营 · 第 4 课:终端操作与短路
*
* 终端操作逐个上手(每个都是"点火按钮",一按整条管道才真正跑起来):
* collect(toList / toSet / toMap)、forEach、count、max / min、
* findFirst / findAny、anyMatch / allMatch / noneMatch、reduce 求和。
* 最后做短路实测:10 万元素,anyMatch 命中即停 vs count 全量数完,计时对比。
*
* 运行:java -cp target/classes com.stream.lesson04.Main
*/
public class Main {
/** 学生:姓名 + 分数(record 一行就是一个数据类,第 1 课已用过) */
record Student(String name, int score) {
@Override
public String toString() {
return name + "(" + score + "分)";
}
}
public static void main(String[] args) {
// 本课的小数据集:5 个学生,两个 59 方便后面演示 toSet 去重
List<Student> students = List.of(
new Student("张三", 86),
new Student("李四", 59),
new Student("王五", 92),
new Student("赵六", 74),
new Student("孙七", 59)
);
System.out.println("========== 1. collect(toList / toSet / toMap) ==========");
// collect:终端操作里的"收纳员",把流过的元素收进你指定的容器
List<String> nameList = students.stream()
.map(Student::name)
.collect(Collectors.toList()); // 收成 List(有序、可重复)
System.out.println("toList 收名字:" + nameList);
Set<Integer> scoreSet = students.stream()
.map(Student::score)
.collect(Collectors.toSet()); // 收成 Set(自动去重,两个 59 只剩一个)
System.out.println("toSet 收分数(去重):" + scoreSet);
// toMap:收成 Map,两个参数分别是"键怎么取"和"值怎么取"
Map<String, Integer> nameToScore = students.stream()
.collect(Collectors.toMap(Student::name, Student::score));
System.out.println("toMap 姓名->分数:" + nameToScore);
System.out.println();
System.out.println("========== 2. forEach:逐个上门 ==========");
// forEach:拿到流里的每个元素挨个"拜访"一遍,返回 void,什么结果都不装回来
System.out.println("不及格的同学(分数 < 60):");
students.stream()
.filter(s -> s.score() < 60)
.forEach(s -> System.out.println(" -> " + s));
System.out.println();
System.out.println("========== 3. count:数一数 ==========");
long failedCount = students.stream()
.filter(s -> s.score() < 60)
.count(); // 注意返回的是 long,不是 int
System.out.println("不及格人数:" + failedCount + " 人");
System.out.println();
System.out.println("========== 4. max / min:找极值 ==========");
// max/min 返回 Optional:流可能是空的,"可能没有"就装在 Optional 里(第 8 课专门讲)
Student top = students.stream()
.max(Comparator.comparingInt(Student::score)) // 按分数定大小
.orElseThrow(); // 这里一定有数据,没有就抛异常
Student low = students.stream()
.min(Comparator.comparingInt(Student::score))
.orElseThrow();
System.out.println("最高分:" + top);
System.out.println("最低分:" + low);
System.out.println();
System.out.println("========== 5. findFirst / findAny ==========");
// findFirst:拿第一个符合条件的;findAny:随便拿一个。
// 串行流里结果一样,并行流才可能不同(第 6 课并行流会专门讲为什么)
Student firstFail = students.stream()
.filter(s -> s.score() < 60)
.findFirst()
.orElseThrow();
Student anyFail = students.stream()
.filter(s -> s.score() < 60)
.findAny()
.orElseThrow();
System.out.println("第一个不及格的:" + firstFail);
System.out.println("随便一个不及格的:" + anyFail);
System.out.println();
System.out.println("========== 6. anyMatch / allMatch / noneMatch ==========");
// 三个"判官",都返回 boolean;anyMatch / allMatch / noneMatch 全是短路操作
boolean anyFailExists = students.stream().anyMatch(s -> s.score() < 60);
boolean allPass = students.stream().allMatch(s -> s.score() >= 60);
boolean noneFullScore = students.stream().noneMatch(s -> s.score() >= 100);
System.out.println("有人不及格吗(anyMatch):" + anyFailExists);
System.out.println("全都及格了吗(allMatch):" + allPass);
System.out.println("没有一个满分吗(noneMatch,满分=100):" + noneFullScore);
System.out.println();
System.out.println("========== 7. reduce:自己动手汇总 ==========");
// reduce("种子值", "累加规则"):把流里的元素挨个"叠"成一个结果
int totalScore = students.stream()
.map(Student::score)
.reduce(0, Integer::sum); // 从 0 开始,逐个相加
long studentCount = students.stream().count();
System.out.println("总分:" + totalScore + ",人数:" + studentCount
+ ",平均分:" + totalScore / studentCount);
System.out.println();
System.out.println("========== 8. 短路实测:10 万元素,计时对比 ==========");
// 造一个 0~99999 的大列表
List<Integer> big = IntStream.range(0, 100_000).boxed().toList();
// 预热:JVM 刚启动是"解释执行"(冷启动慢),先白跑几轮把 JIT 叫醒,计时才公平
for (int i = 0; i < 20; i++) {
big.stream().filter(x -> x >= 0).anyMatch(x -> x == 0);
big.stream().filter(x -> x >= 0).count();
}
// 测 1:anyMatch 要找的 0 就在队首------命中第一个就"短路"收工
long t1 = System.nanoTime();
boolean hitHead = big.stream().filter(x -> x >= 0).anyMatch(x -> x == 0);
long t2 = System.nanoTime();
// 测 2:count 必须把 10 万个元素数完,一个都不能少
long counted = big.stream().filter(x -> x >= 0).count();
long t3 = System.nanoTime();
// 测 3:同样是 anyMatch,但要找的 99999 在队尾------短路也救不了,几乎数了个遍
boolean hitTail = big.stream().filter(x -> x >= 0).anyMatch(x -> x == 99_999);
long t4 = System.nanoTime();
System.out.println("anyMatch(找队首 0),命中=" + hitHead + ",耗时 " + (t2 - t1) + " 纳秒");
System.out.println("count(全量数完),数到=" + counted + ",耗时 " + (t3 - t2) + " 纳秒");
System.out.println("anyMatch(找队尾 99999),命中=" + hitTail + ",耗时 " + (t4 - t3) + " 纳秒");
System.out.println("(数字随机器波动,看相对关系:队首命中秒停,count 必须数完;命中在队尾的 anyMatch 也快不起来)");
}
}
这段代码讲什么:
- 两个 59 是故意的:李四、孙七同分,就是为了让 toSet 去重时"59 只剩一个"肉眼可见。
collect(...):终端操作里的"收纳员"。toList()收有序列表、toSet()自动去重、toMap(键怎么取, 值怎么取)收成 Map。不写会怎样:整条链只是搭好的架子,一行不执行。forEach返回 void:只做打印,返回类型就没给你装回来结果的地方。count()返回 long :面试爱考。用int n = ...count()编译直接报错。max(...).orElseThrow():max 返回Optional<Student>,外面裹了层"可能没有"的盒子,得拆出来;Comparator.comparingInt(Student::score)是告诉它"比分数这个字段"。- 三个判官都是短路 :
anyMatch找到第一个符合就停;allMatch发现第一个不及格就停;noneMatch发现第一个满分就停。 reduce(0, Integer::sum):0 是雪球起点,Integer::sum是合并规则,把每个分数揉上去。- 计时段为什么预热、为什么加
filter(x -> x >= 0):JVM 冷启动不预热,计时数字能飘十倍;这个"永远真"的过滤让流长度没法提前算出来,逼count()真去逐个遍历------不然 count 被 JDK 偷偷优化掉,演示就穿帮了。
完整演示 + 实测输出
运行方法(Linux/macOS 的 Bash,Windows 用 WSL 或 Git Bash):
bash
cd stream-journey/lesson-04
mvn -q clean install -DskipTests
java -cp target/classes com.stream.lesson04.Main
实测输出,本机 JDK 21.0.12.1 运行结果:
text
========== 1. collect(toList / toSet / toMap) ==========
toList 收名字:[张三, 李四, 王五, 赵六, 孙七]
toSet 收分数(去重):[86, 74, 59, 92]
toMap 姓名->分数:{李四=59, 孙七=59, 张三=86, 王五=92, 赵六=74}
========== 2. forEach:逐个上门 ==========
不及格的同学(分数 < 60):
-> 李四(59分)
-> 孙七(59分)
========== 3. count:数一数 ==========
不及格人数:2 人
========== 4. max / min:找极值 ==========
最高分:王五(92分)
最低分:李四(59分)
========== 5. findFirst / findAny ==========
第一个不及格的:李四(59分)
随便一个不及格的:李四(59分)
========== 6. anyMatch / allMatch / noneMatch ==========
有人不及格吗(anyMatch):true
全都及格了吗(allMatch):false
没有一个满分吗(noneMatch,满分=100):true
========== 7. reduce:自己动手汇总 ==========
总分:370,人数:5,平均分:74
========== 8. 短路实测:10 万元素,计时对比 ==========
anyMatch(找队首 0),命中=true,耗时 595160 纳秒
count(全量数完),数到=100000,耗时 2199641 纳秒
anyMatch(找队尾 99999),命中=true,耗时 4586972 纳秒
(数字随机器波动,看相对关系:队首命中秒停,count 必须数完;命中在队尾的 anyMatch 也快不起来)
输出怎么读:
toSet 收分数里 59 只出现一次:两个 59 的学生被 Set 自动去重收成一个分数------这就是开头"两个 59"的伏笔回收。max/min打印的是学生对象而不是数字:因为流里跑的还是 Student,拆盒出来的就是整个对象。- 最后三行计时是本课重头戏,数字随机器波动,看相对快慢不看绝对值 :找队首 0 的 anyMatch 约 0.6 毫秒就收工,count 数完 10 万个约 2.2 毫秒;而找队尾 99999 的 anyMatch 反而最慢,约 4.6 毫秒------它得把每个元素都过一遍才知道答案。结论:短路不是魔法,命中得早就赚,命中得晚就跟全量遍历一样,甚至更亏。我本机连跑三次,相对关系一直稳定:队首 anyMatch < count < 队尾 anyMatch。
挑战题(答案都在课程源码里)
- ⭐ 把查找目标从队首挪中间 :把计时段里
anyMatch(x -> x == 0)改成anyMatch(x -> x == 50000),重跑计时------猜一下耗时会落在哪两者之间?(提示:50000 在十万条正中,短路大约停在一半位置,耗时夹在队首和队尾之间) - ⭐⭐ 把求和换成求乘积 :把"求总分"那行
.reduce(0, Integer::sum)改成求所有分数的乘积------种子从 0 改成 1,合并规则写(a, b) -> a * b(或方法引用Math::multiplyExact)。想想种子为什么不能再用 0?(提示:0 乘任何数都是 0,乘积一开始就死了) - ⭐⭐ 把比较器倒过来 :在
max(Comparator.comparingInt(Student::score))后面加.reversed(),重跑------这次"最高分"打印出来是谁?(提示:比较规则翻面之后,max 拿到的反而是原来的最低分)
生产环境进阶
两个加分项,面试聊深了直接用:
- 短路在源码里就是个带 break 的循环 :打开
java.util.stream包源码看ReferencePipeline,anyMatch的内部实现本质就是循环里找到第一个匹配就return,和 for 循环写if (符合) break;一模一样。面试官问"短路原理是什么",你能说出"就是提前 break",比背概念高一个段位。 - count 有个隐藏优化,不总是真遍历 :JDK 9 以后,如果管道"长度可以提前算出来"(源头是 List、中间没有 filter/flatMap 改变长度),
count()直接拿源头大小,一个元素都不遍历。本课 demo 特意加filter(x -> x >= 0)破掉它。面试被问"count 一定遍历吗",答案就是"不一定,能算出来就算给你听"。 - findFirst 和 findAny 的真区别只在并行流 :串行流里它俩结果一样;并行流里
findAny允许"哪个线程先抓到就返回哪个",性能更好。第 6 课并行流我们会亲手验证。
面试回答模板
问:哪些 Stream 操作是短路的?为什么要短路?
- 一句话结论:短路操作指 anyMatch、allMatch、noneMatch、findFirst、findAny 这类不用遍历完整个流就能下结论的终端操作。
- 展开 1~2 句:比如 anyMatch 是"有一个符合就 true",找到第一个符合的元素就直接返回,后面元素不再处理,源码里就是个带 break 的循环。
- 收一句场景:在大集合上做"有没有"类判断时优先用短路操作,答案在队首时能省下绝大部分遍历时间(指向本课 §8 短路实测)。
问:max/min、findFirst 返回什么类型?count 返回 int 还是 long?
- 一句话结论:max/min/findFirst 返回 Optional------流可能为空,不能给 null,用 Optional 包装"可能没有";count 返回 long 不是 int。
- 展开 1~2 句:取值用
orElseThrow()或orElse(默认值)拆盒;count 用 int 接会编译报错,因为流理论上能超过 21 亿个元素。 - 收一句场景:集合可能为空的查询场景,Optional 能把"运行时空指针"变成"编译时提醒你处理空值",第 8 课会展开(指向本课 §4 max/min 实测输出)。
总结表:坑 → 原因 → 解法
| 坑 | 原因 | 解法 |
|---|---|---|
| filter 完再 count 大于 0 判断"有没有" | count 必须全量数完,不短路 | 有就用 anyMatch,命中即停 |
| count 用 int 接 / max 结果直接当对象用 | count 返回 long;max 返回 Optional 盒子 | 声明 long n;后面接 .orElseThrow() 拆盒 |
| 写完管道没有终端操作 | 中间操作全是懒惰的,不点火不跑 | 链尾永远要有一个终端操作 |
| 不预热直接计时,数字飘十倍 | JVM 冷启动解释执行,JIT 没叫醒 | 正式计时前白跑几轮同一管道 |
| 以为短路一定快 | 找队尾元素时 anyMatch 比 count 还慢 | 用短路前先想"答案大概在流的哪个位置" |
| 不 export JAVA_HOME 就编译,报 invalid source release: 21 | 本机默认 java 不是 JDK 21 | 每轮终端先 export JAVA_HOME 指向 JDK 21 |
关于这个系列
完整代码在 stream-journey 课程第 4 课 (src/main/java/com/stream/lesson04/Main.java):
bash
git clone https://gitee.com/j67mk2/stream-journey.git
cd stream-journey/lesson-04
mvn -q clean install -DskipTests
java -cp target/classes com.stream.lesson04.Main
本文属于 Java 后端实战精通营 系列(Stream 实战 10 课):L1 流的世界观 → L2 流的创建与管道骨架 → L3 中间操作全家桶 → L4 终端操作与短路 → L5 Collector 分组聚合 → L6 并行流与性能 → L7 惰性求值与无限流 → L8 Optional×Stream → L9 实战:报表数据管道 → L10 企业级实践与面试冲刺。
下一篇:Collector 分组聚合------groupingBy、partitioningBy、toMap、joining,让"按部门统计人数、平均工资"这种报表一行代码搞定。提前准备:把本课 collect 的三种写法跑熟。
你平时判断"集合里有没有符合条件的数据",第一反应是 anyMatch 还是 filter 完再 count?评论区聊聊。觉得有用就收藏,下一篇讲 groupingBy。