开始前,会这些就够了
- 会读
List.of(1, 2, 3)和简单 Lambda。 - 知道 map、filter 是中间操作,count、
forEach是终止操作。 - 先读 Stream:筛选、转换和汇总一批数据:先分清中间操作、终止操作,以及每次从原集合新建 Stream。
你给三条记录做统计,在 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 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
同样三个元素,切换终点后看哪些动作真的发生
正在准备实验控件…
回放本页 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 标签,它是解释机制的参考版本,不冒充本机发行版的精确源码。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
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,就断言三次外部写入都已完成。
自己试一小步
把“转换结果正确”和“日志出现”分开验证,你会怎样改这个实验?
- 新增一组以
toList结束的流水线,先在纸上预测结果内容与计数器。 - 把输入改为
List.of(-2, 0, 3),比较 filter 前后放 peek 的记录。 - 写一个普通 for 循环实现同样的筛选与转换,标出真正必须完成的动作。
做到这些,就算完成
- 每次实验新建 Stream,不复用已消费对象。
- 分别核对最终值、保留元素数和观察回调数,不混用指标。
- 说明哪些数字是本机实测,哪些行为是 API 保证;不要求不同实现选择同一种优化。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Java 17 Stream:count 与操作省略许可 (新标签页)↗
count 可以直接利用已知大小;不影响最终结果的中间操作及其副作用可能被省略。
- OpenJDK 17u 固定源码:ReduceOps.makeRefCounting (新标签页)↗
参考实现先检查
exactOutputSizeIfKnown,已知则直接返回大小。