qinghuan
← 回到知识探索

后端现场 / 动手实验

count 是 3,为什么 map 和 peek 一次都没执行?

给流接上终止操作,仍不等于每个回调都会执行。用三组真实 Java 输出,分清“我要数量”和“我要逐条做事”。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先看三组输出,不急着猜执行顺序
  4. 数量已经知道,就不一定要算每个值
  5. 加了 filter 为什么这次又走起来了
  6. 必须逐条完成的动作,应明确写出来
  7. 保存完整程序,核对真实输出
  8. 把许可、源码和本机观察分开
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 会读 List.of(1, 2, 3) 和简单 Lambda。
  • 知道 map、filter 是中间操作,count、forEach 是终止操作。
  • 先读 Stream:筛选、转换和汇总一批数据:先分清中间操作、终止操作,以及每次从原集合新建 Stream。
遇到不熟悉的词,先看这 2 个解释
副作用
除了返回计算结果,还改动了外部状态的动作。
已知大小
执行器能从数据源和操作性质提前知道输出元素的数量。
打开完整术语小抄 ↗

你给三条记录做统计,在 map 里转换数据,在 peek 里顺手记一次“已处理”。最后 count 返回 3,但两个计数器都是 0。既然已经调用终止操作,为什么什么都没走?

这次没有异步、异常,也没有忘记调用 count。问题在于:数量这个结果,可能根本不需要读取元素的值。下面从同一份 [1, 2, 3] 出发,把能够观察到的动作逐项列出来。

把例子带到自己的电脑上

下载完整练习包

需要 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • Main.java:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
javac -version
javac -encoding UTF-8 --release 17 Main.java
java -cp . Main

解压后,在含 Main.java 的目录打开终端。命令分别回车执行。固定输入的 Java 17 单文件实验;两套真实 JDK 输出可复核,互动页只回放这些记录。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

同样三个元素,切换终点后看哪些动作真的发生

正在准备实验控件…

输入固定为 [1, 2, 3];map 把数乘以 10,peek 只增加观察计数器。每组都重新创建流。

先预测最终数量、map 和 peek 的调用次数,再查看这次真实运行记录。

回放本页 Java 程序的固定实测记录:Microsoft OpenJDK 17.0.20.1 与 21.0.12 输出一致。浏览器没有执行 Stream;只覆盖固定输入、顺序流与这三条路径,其他实现不一定选择相同优化。

先看三组输出,不急着猜执行顺序

第一组是 List.stream().map(...).peek(...).count()。本次结果为 3,mapCalls 与 peekCalls 都为 0。第二组在 map 前增加 filter(x -> x > 0),结果仍是 3,但 filter、map、peek 各执行了 3 次。

第三组去掉 filter,把终点改为 forEach(written::add)。written 得到 [10, 20, 30]。输出里的 result 在前两组表示 count 返回值,在第三组表示写入列表的长度;它们恰好都是 3,含义需要分开看。

这三个例子没有改变输入元素。改变的是最终要交出的结果,以及执行器能不能提前知道这个结果。互动区可以切换三组记录,留意“最终结果相同”与“经历的动作相同”之间的差别。

数量已经知道,就不一定要算每个值

可以把 [1, 2, 3] 想成三个封好的盒子。问题若只是“有几个盒子”,箱单已经写了 3,就不必逐个拆开。map 把每个数乘以 10,却仍是一进一出;peek 也不会增减元素。这两步不改变本例可提前得知的长度。

Java 17 的 Stream.count 文档明确允许这类省略。我们阅读的固定版本 ReduceOps.makeRefCounting 会先询问 exactOutputSizeIfKnown;若拿到已知大小,就直接交出这个数量,不遍历流水线。这里的“已知”来自流及操作的信息,不是执行器把任意 Java 代码都推理了一遍。

因此,“count 返回正确”与“map 里的日志打印了”是两件事。正确的数量不要求打印日志;把日志、发消息或更新数据库塞进中间回调,会把必要工作放到可能被省略的位置。

加了 filter 为什么这次又走起来了

filter 可能保留元素,也可能丢掉元素。对于 x > 0 这段普通谓词,本次 JDK 需要检查数据,才能确定最后剩下几个。虽然固定输入全是正数,流水线在运行前并没有沿用原列表长度作为最终数量。

这解释了第二组的实测现象,却不提供一种可靠的“强制执行 peek”技巧。为了日志多加一个永远返回 true 的 filter,只是在依赖当前流水线的执行方式;业务要求依然没有被表达为最终结果或明确动作。

把输入改成含负数后,filter 的调用数、后续 map 的调用数和最终数量还会不同。阅读日志时,要先问它放在筛选前还是筛选后,不能只靠“原列表有三条”判断应该打印几次。

必须逐条完成的动作,应明确写出来

只要统计数量,就让流水线返回数量,不用回调次数充当业务凭据。需要转换后的值,就把值收集出来并检查结果。需要依次处理每个元素,可以写清楚的普通循环;本页用顺序流的 forEach 演示终止动作会消费每个输出元素。

例如,在外部系统中完成写入时,仍要设计失败如何传播、是否允许重试、重复写入如何处理。本例只向本地 ArrayList 添加三个整数,证明的是这条顺序执行路径;它没有证明数据库提交成功,更没有证明跨服务动作只发生一次。

保存完整程序,核对真实输出

每个场景都会重新创建 Stream。int[] 只是这个顺序实验的观察计数器,没有把它用于并行流;written 则记录明确放在 forEach 中的动作。程序只断言最终数量和第三组内容,不把“所有 JDK 都必须跳过 peek”写成 API 规则。

2026-10-26 实际使用 Microsoft OpenJDK 17.0.20.1 和 21.0.12,两个版本分别以 --release 17 编译并运行三次,输出一致。页面互动区回放这些固定记录,浏览器没有启动 JVM。固定源码阅读使用 OpenJDK jdk17u 的 jdk-17.0.17+10 标签,它是解释机制的参考版本,不冒充本机发行版的精确源码。

展开收起代码 · 小实验java · 43 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class Main {
    static void run(String mode) {
        List<Integer> input = List.of(1, 2, 3);
        int[] calls = new int[3]; // filter, map, peek: sequential experiment only
        List<Integer> written = new ArrayList<>();
        Stream<Integer> stream = input.stream();
        if (mode.equals("filtered-count")) {
            stream = stream.filter(x -> {
                calls[0]++;
                return x > 0;
            });
        }
        stream = stream.map(x -> {
            calls[1]++;
            return x * 10;
        }).peek(x -> calls[2]++);
        long result;
        if (mode.equals("for-each")) {
            stream.forEach(written::add);
            result = written.size();
        } else {
            result = stream.count();
        }
        System.out.printf(
                "{\"mode\":\"%s\",\"result\":%d,\"filterCalls\":%d,"
                + "\"mapCalls\":%d,\"peekCalls\":%d,\"written\":%s}%n",
                mode, result, calls[0], calls[1], calls[2], written);
        if (result != 3) throw new AssertionError("wrong terminal result");
        if (mode.equals("for-each") && !written.equals(List.of(10, 20, 30))) {
            throw new AssertionError("terminal action missed an element");
        }
    }

    public static void main(String[] args) {
        run("count");
        run("filtered-count");
        run("for-each");
    }
}

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-26 使用 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12 复跑这份源码并核对完整输出。固定输入的 Java 17 单文件实验;两套真实 JDK 输出可复核,互动页只回放这些记录。

运行后对照结果
{"mode":"count","result":3,"filterCalls":0,"mapCalls":0,"peekCalls":0,"written":[]}
{"mode":"filtered-count","result":3,"filterCalls":3,"mapCalls":3,"peekCalls":3,"written":[]}
{"mode":"for-each","result":3,"filterCalls":0,"mapCalls":3,"peekCalls":3,"written":[10, 20, 30]}

把许可、源码和本机观察分开

API 许可是“可以省略不影响最终结果的操作”,不是“每个实现都必须出现 0 次回调”。固定源码说明一种实现如何使用已知大小,本机输出则证明本次两个运行环境确实走出了这个反例。

我们没有测试并行流、无限流、异常回调、网络写入或其他 JDK 厂商。在这些条件下,不应照搬这里的回调数量。稳定的设计结论是:必要动作不应依靠中间操作的副作用来保证。

记住这三点

  • count 需要数量,未必需要每个转换后的值。
  • 终止操作触发求值,不保证每个中间回调都按直觉执行。
  • 把业务结果作为返回值或显式终止动作,验证它本身。

别把例子用过头

  • 把 peek 当作可靠审计、发消息或落库入口。
  • 为了让 peek 执行而添加无意义 filter,并把偶然效果当保证。
  • 把这里的 int[] 和 ArrayList 直接搬进并行流。
  • 看到 count 返回 3,就断言三次外部写入都已完成。

自己试一小步

把“转换结果正确”和“日志出现”分开验证,你会怎样改这个实验?

  1. 新增一组以 toList 结束的流水线,先在纸上预测结果内容与计数器。
  2. 把输入改为 List.of(-2, 0, 3),比较 filter 前后放 peek 的记录。
  3. 写一个普通 for 循环实现同样的筛选与转换,标出真正必须完成的动作。

做到这些,就算完成

  • 每次实验新建 Stream,不复用已消费对象。
  • 分别核对最终值、保留元素数和观察回调数,不混用指标。
  • 说明哪些数字是本机实测,哪些行为是 API 保证;不要求不同实现选择同一种优化。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。