要说调试 Stream 流程最轻量的方式,非 peek 莫属。它扮演的是一个“中间观察员”的角色——每个元素流过时,你可以让它执行某个动作(比如打印一行日志),但它本身不会改动数据。简单说,就是让你在过滤、映射这些中间步骤之间“偷看一眼”当前的状态。

其实核心就一句话:Stream.peek() 不改变数据,只在每个元素经过时执行你指定的操作,比如打印日志。 但要记住,它是个中间操作,必须后面跟上终止操作才会真正干活,否则你写再多 peek 都是白搭。
peek 的核心作用:观察中间状态
熟悉 Stream 的都知道,它是惰性求值的——filter、map 这些中间操作不会立即执行,只有遇到 collect、forEach 这类终止操作时,整条流水线才会动起来。而 peek 就是在链条上“插个眼”,让你看到某个环节的元素长什么样。
注意一个关键点:peek 必须放在终止操作之前,否则什么都不会打印。这不是 bug,是设计。
基本用法:打印当前元素
直接传一个 Lambda 表达式进去,用 System.out.println 输出就行。
Listlist = Arrays.asList("apple", "banana", "cherry"); list.stream() .filter(s -> s.length() > 5) .peek(s -> System.out.println("过滤后拿到:" + s)) .map(String::toUpperCase) .peek(s -> System.out.println("转大写后:" + s)) .collect(Collectors.toList());
输出结果一目了然:
过滤后拿到:banana 转大写后:BANANA 过滤后拿到:cherry 转大写后:CHERRY
更实用的调试技巧
- 打印带索引的元素:配合
AtomicInteger或IntStream.range,精准定位第几个元素出了问题。 - 结合断点调试:在
peek的 Lambda 里加一行Debugger.breakpoint()(Ja va 14+),或者直接在 IDE 的行号上打断点(部分 IDE 支持 Lambda 内的断点)。 - 只对特定元素打印:加个条件判断,避免刷屏。比如
.peek(s -> { if (s.contains("err")) System.out.println("可疑项:" + s); })。 - 避免副作用误用:peek 天生是“观察者”,不该修改元素本身(比如
s += "!"),更不该用来做业务逻辑(比如调用service.sa ve())。它的本职工作只是看一看。
peek 和 forEach 的区别别搞混
forEach 是终止操作,一执行完流就结束了;peek 是中间操作,必须后面还有终止操作才能生效。下面这段代码什么都不会打印:
list.stream().peek(System.out::println); // ❌ 没有终止操作,整条流根本没运行
而这样才行:
list.stream().peek(System.out::println).count(); // ✅ count 是终止操作
替代方案:什么时候不该用 peek
- 需要记录完整执行路径(比如每个阶段的耗时),建议用自定义的工具方法把流操作包装起来,或者直接上 AOP。
- 调试复杂的转换逻辑时,不如把流拆成变量分步执行,再用普通日志或断点,比在链式调用里加一堆 peek 更清晰。
- 生产环境禁用日志时,
peek里的println仍然会执行——应改用 SLF4J 等支持日志级别的方案,避免意外输出。