Stream 终端操作一次讲透:collect、count、reduce 怎么选

系列: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。

挑战题(答案都在课程源码里)

  1. ⭐ 把查找目标从队首挪中间 :把计时段里 anyMatch(x -> x == 0) 改成 anyMatch(x -> x == 50000),重跑计时------猜一下耗时会落在哪两者之间?(提示:50000 在十万条正中,短路大约停在一半位置,耗时夹在队首和队尾之间)
  2. ⭐⭐ 把求和换成求乘积 :把"求总分"那行 .reduce(0, Integer::sum) 改成求所有分数的乘积------种子从 0 改成 1,合并规则写 (a, b) -> a * b(或方法引用 Math::multiplyExact)。想想种子为什么不能再用 0?(提示:0 乘任何数都是 0,乘积一开始就死了)
  3. ⭐⭐ 把比较器倒过来 :在 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。


相关推荐
白山编程大哥2 小时前
Java 后端如何优雅地把 List 集合返回给前端
java·前端·list
Linux运维技术栈2 小时前
CVE-2026-91403 高危漏洞修复:Rocky Linux 9 多版本 JDK 环境清理实操
java·linux·运维
On The Road :2 小时前
Java 多线程详解:从入门到线程安全
java·python·安全
Shaoshing2 小时前
LeetCode100 49. 字母异位词分组
java·算法
划破黑暗的第一缕曙光3 小时前
[C++]:5.STL:string(2)
java·c++·redis
hhhjhl3 小时前
Undici 跨端三方库仓颉适配全流程
java·开发语言·ubuntu
wangchunyu1143 小时前
JAVA JVM内存结构与运行时数据区
java·开发语言·jvm
光锥智能4 小时前
海康机器人举行2026移动机器人新品发布会:箱式存拣升级,「明智」系列发布
java·前端·机器人
行思理4 小时前
Nginx 跨站请求(CORS)配置完全指南:从原理到生产实践
java·前端·nginx