Lambda、Stream 与 Optional:惰性、终止与副作用
结论基线:API 语义以 Java SE 17 为准,实现分析以 OpenJDK 17u
jdk-17.0.12+7为准。实验在本机 Oracle JDK 17.0.12(x86_64)实际运行。Lambda 捕获是语言规则(JLS §15.27),Stream 惰性/复用是实现结构(java.util.stream),两者边界要分开引用。
Lambda:捕获与函数式协议
Lambda 表达式是函数式接口(只有一个抽象方法的接口)的实例。它带来两个语言级约束,出错都在编译期或以"意外行为"出现,值得各给一个可复现反例。
捕获约束:Lambda 体只能引用 final 或 effectively final(从不重新赋值)的局部变量。给计数器写 int[] calls = {0} 这种数组或用 AtomicInteger,正是因为直接 int count 在 lambda 里自增会被 javac 拒绝。字段(含实例字段)不受此限,但并发下字段自增同样有竞态——语言规则只保证"捕获的是值",不保证线程安全。
受检异常协议:lambda 的受检异常必须落在目标函数式接口的 throws 声明内。Supplier<String> s = () -> { throw new IOException(); } 编译失败,因为 Supplier.get() 没有 throws IOException;换成自定义 interface Load { String get() throws IOException; } 即可编译。这正是 Function 族接口不能直接包装受检异常调用、需要自定义接口或在 lambda 内 try-catch 的原因。
组合顺序用源码读最准确:
原码连续摘录(仅统一公共缩进,截取方法局部):Function.java,jdk-17.0.12+7,L66–L89。
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> apply(before.apply(v));
}
/**
* Returns a composed function that first applies this function to
* its input, and then applies the {@code after} function to the result.
* If evaluation of either function throws an exception, it is relayed to
* the caller of the composed function.
*
* @param <V> the type of output of the {@code after} function, and of the
* composed function
* @param after the function to apply after this function is applied
* @return a composed function that first applies this function and then
* applies the {@code after} function
* @throws NullPointerException if after is null
*
* @see #compose(Function)
*/
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(apply(t));
}f.andThen(g).apply(3) 是 g(f(3))(先 f 后 g);f.compose(g).apply(3) 是 f(g(3))(先 g 后 f)。plus=n+1、twice=n*2 时分别为 8 与 7——方向记反一次就全错。方法引用(String::equals、StringBuilder::append)是等价的 lambda 简写;Predicate.and/or 短路:左侧已定值时右侧不执行,把有副作用的断言放右侧要小心。
Stream 管道:惰性、单次终止与副作用
一条管道 = 数据源 + 若干中间操作 + 一个终端操作。三个行为契约决定了所有"Stream 不生效"类 bug 的答案。
中间操作惰性:map 只是把操作记录进管道(StatelessOp),不触发任何计算:
原码连续摘录(仅统一公共缩进,截取方法局部):ReferencePipeline.java,jdk-17.0.12+7,L188–L199。
@Override
@SuppressWarnings("unchecked")
public final <R> Stream<R> map(Function<? super P_OUT, ? extends R> mapper) {
Objects.requireNonNull(mapper);
return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,
StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
@Override
Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {
return new Sink.ChainedReference<P_OUT, R>(sink) {
@Override
public void accept(P_OUT u) {
downstream.accept(mapper.apply(u));
}
};
}
};
}没有终端操作,mapper 一次都不会执行——实验里 mapped[0] 在 toList() 之前保持 0。"忘记写终端操作所以没有副作用"是这类 bug 的全部机理。
终端操作只许一次:管道被终端操作消费后即失效:
原码连续摘录(仅统一公共缩进,截取方法局部):AbstractPipeline.java,jdk-17.0.12+7,L226–L239。
final <R> R evaluate(TerminalOp<E_OUT, R> terminalOp) {
assert getOutputShape() == terminalOp.inputShape();
if (linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
linkedOrConsumed = true;
return isParallel()
? terminalOp.evaluateParallel(this, sourceSpliterator(terminalOp.getOpFlags()))
: terminalOp.evaluateSequential(this, sourceSpliterator(terminalOp.getOpFlags()));
}
/**
* Collect the elements output from the pipeline stage.
*复用同一条管道再 count() 抛 IllegalStateException;恢复方式是重建管道(重新 Stream.of(...)/list.stream()),不是缓存 Stream 对象。
副作用不是契约:peek 的 javadoc 明确用于调试。Stream.of("a","b","c").peek(计数).count() 在 JDK 17 实测计数为 0——count() 已知大小可直接返回,跳过元素处理。把业务写入 peek/map 再"顺便"依赖它执行,就是把副作用挂在一个明确不承诺执行的钩子上;需要执行就该用终端操作(forEach/collect)并消费结果。
关闭与并行:Stream 是资源(AutoCloseable),但只有显式 close() 才触发 onClose 处理器;终端操作不自动关闭。try (Stream s = ...) 适用于必须释放的 IO 型流。并行流只换执行调度,不换语义契约:reduce 的累积函数必须是结合的(associative)。顺序执行 (0-1)-2...=-36(对 1..8),并行执行按分组重排为其他值——实测 1..8 减法 sequential=-36、parallel=0(观察值,非契约;两者都是"合法分组"的产物)。求和、求最值可并行;减法、依赖顺序状态的累积不可。无界源(Stream.iterate)必须接 limit 等短路操作,否则永不终止。
收集器:重复键与 null 契约
Collectors.toMap 默认实现不允许重复键,也不允许 null 值:
原码连续摘录(仅统一公共缩进,截取方法局部):Collectors.java,jdk-17.0.12+7,L176–L184。
BiConsumer<Map<K, V>, T> uniqKeysMapAccumulator(Function<? super T, ? extends K> keyMapper,
Function<? super T, ? extends V> valueMapper) {
return (map, element) -> {
K k = keyMapper.apply(element);
V v = Objects.requireNonNull(valueMapper.apply(element));
V u = map.putIfAbsent(k, v);
if (u != null) throw duplicateKeyException(k, u, v);
};
}重复键抛 IllegalStateException(消息带两侧行合并值),null 值抛 NPE——两条都是收集时才爆炸,源头上游已经流过去了。修复与选择:
| 场景 | 写法 | 结果 |
|---|---|---|
| 重复键应合并(求和/拼接) | toMap(k, v, Integer::sum) | 显式合并函数 |
| 保留分组明细 | groupingBy(Sale::id) | Map<K, List<T>> |
| 二分类 | partitioningBy(amt -> amt > 2) | 键恒为 true/false |
| 需要可变结果容器 | toCollection(ArrayList::new) | 可继续增删 |
| 要不可变列表 | toList() | 当前实现允许 null 元素、结果通常不可变 |
Stream.toList()(JDK 16+)与 Collectors.toUnmodifiableList() 的差异要用对口径:前者按当前实现观察允许 null 元素、返回不可修改列表;后者的 API 契约明确拒绝 null(抛 NPE)。把 null 放进"不可变收集器"是本票实测过的真实失败分支。收集器抛异常时部分累积已经发生——失败恢复要重新收集,不能假设"半成品可复用"。
Optional:空值协议与惰性求值
Optional 表达"可能没有值",它的边界都写在 null 分支上:Optional.of(null) NPE(值必须非 null),Optional.ofNullable(null) 得到 empty;empty().get() 抛 NoSuchElementException;map 的映射函数返回 null 得到 empty,flatMap 的映射函数返回 null 直接 NPE(它要求返回的已经是 Optional)。
惰性是 orElse 与 orElseGet 的唯一区别,源码直读:
原码连续摘录(仅统一公共缩进,截取方法局部):Optional.java,jdk-17.0.12+7,L349–L367。
public T orElse(T other) {
return value != null ? value : other;
}
/**
* If a value is present, returns the value, otherwise returns the result
* produced by the supplying function.
*
* @param supplier the supplying function that produces a value to be returned
* @return the value, if present, otherwise the result produced by the
* supplying function
* @throws NullPointerException if no value is present and the supplying
* function is {@code null}
*/
public T orElseGet(Supplier<? extends T> supplier) {
return value != null ? value : supplier.get();
}orElse(getDefault()) 的实参在方法调用前就要求值——即使 value 存在,getDefault() 也执行了;orElseGet(() -> getDefault()) 存在时零次执行、缺席时恰好一次。默认值是常量字面量时用 orElse 无妨;默认值是方法调用(尤其有 IO、计数或随机性)时必须 orElseGet。实验用计数器证明了三连:orElse 传 fallback.get() 执行 1 次,orElseGet 存在时 0 次,empty 时 1 次。
Optional 不是"更优雅的 null"全覆盖:字段与入参不建议用 Optional 类型(序列化与重载负担),它定位于"返回值可能缺席"的表达;判空链里 map/filter/orElse* 组合替代多层 if-null。
设计案例:订单榜单报表管道(算例,非实测)
负载假设:每 10 分钟批处理一次过去窗口的订单明细,5 万条/批;上游 id 重复率约 5%;榜单只取金额 Top100;报表集群 4 核。不变量:榜单金额总和等于明细金额总和(可对账);重复 id 的金额合并而不是覆盖;批处理总时长预算 30s。
选择 1:单线程 Stream + toMap(merge)。 sales.stream().collect(toMap(Sale::id, Sale::amount, Integer::sum)):顺序确定、失败即停(重复键有 merge 函数不再抛);5 万条的单机聚合远低于 30s 预算,恢复 = 修数据后重跑整批,管道作者负责对账断言。成本:一条管道完成分组+聚合,中间无共享状态。
选择 2:并行流 + groupingByConcurrent。 4 核并行可能缩短聚合时间,但引入两个条件:合并函数必须结合(求和满足)、下游容器必须支持并发(groupingByConcurrent);一旦有人顺手把求和改成"拼接明细 id"这类非结合操作,并行结果不可复现(本文减法实测即为反例模型)。恢复责任同样在管道作者,但验证成本升高(需要固定输入断言 + 多次运行稳定)。
惰性副作用的审计陷阱:若把"上报单条处理日志"写成 peek(this::audit),用 count() 收尾——JDK 17 实测 count 会跳过元素处理,审计静默丢失且无报错。恢复:审计必须放在终端操作(forEach/collect)或显式副作用容器,并在管道末端断言审计条数 == 明细条数。失败输入(重复键、null 值)在 toMap 收集点抛出,整批重跑,不做部分提交。
条件变化与边界
| 条件变化 | 需要复核的点 |
|---|---|
| 换 JDK 版本 | count() 跳过元素的实现选择(JDK 9+ 起如此,仍是实现行为);toList 允许 null 是当前实现观察 |
| 数据源改为 IO 流 | 必须显式 close()(try-with-resources),onClose 才有机会执行 |
| 并行 + 非线程安全容器 | forEach 向 ArrayList 并发写入丢更新;用 collect/并发容器 |
| lambda 捕获并发计数 | 语言只保证捕获值语义;并发自增需要 AtomicInteger 等 |
| 受检异常 API 演进 | 函数式接口的 throws 面不变时,包装/自定义接口的修复路径稳定 |
实验:输入、步骤、预期与边界
实验入口:labs/java-senior/run.sh library(StdLibraryProbe.java)。
cd <仓库根>
JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home \
bash labs/java-senior/run.sh libraryU05 关键实测(Oracle JDK 17.0.12,x86_64;完整命令、stdout/stderr、退出状态见本批证据):
PASS functions composition=8/7, predicate short circuit, Bi/primitive references
PASS actual javac rejects rebound capture/checked Supplier, corrected sources compile, temp cleaned
OBSERVE non-associative reduce rangeClosed(1,8) sequential=-36 parallel=0 (observed outputs, not a contract; both are valid per-spliterator groupings)
PASS stream lazy/reuse recovery/close, bounded sum=500500, count=3 observedPeek=0
PASS collectors duplicate rejected -> merge a=5/group size=2/partition; explicit list/null contracts
PASS Optional of/null/get/map/flatMap failures and recovery; eager=1 then lazy=0 then empty=1场景与预期:组合方向 8/7 与谓词短路;真实 javac(javax.tools,--release 17)拒绝重绑捕获与未声明受检异常、修正后编译通过且临时目录清理;map 计数在终端前为 0、终端后为 3,复用抛 ISE 后新管道恢复;count() 观察 peek=0;toMap 重复键 ISE → merge 恢复 a=5;Stream.toList 允许 null 而 toUnmodifiableList/toMap null 值 NPE;toCollection 恢复可变;orElse 急切 1 次/orElseGet 存在 0 次/empty 1 次;1..1000 顺序与并行求和均 500500(结合函数),减法非结合 OBSERVE 仅记录不冒充契约。
边界:lambda 编译边界用 JDK 自带编译器验证,只覆盖所列四个反例;count 跳过与 toList null 行为按"当前实现观察"记录,不外推全部版本;并行减法输出依赖 spliterator 分组与核数,两个值都不是契约;实验不测性能,30s/4 核等数字是算例假设。
追问链
第一层:为什么捕获变量要求 effectively final?
捕获的是值的副本,若允许变量后续重新赋值,lambda 读到的与调用点看到的会分叉,语义难以定义且易错。JLS §15.27 把它定成语言规则;需要"可变"就用单元素容器或字段,但那是在显式声明"我要共享可变状态",与捕获无关。
第二层:peek 能用来打日志吗?
调试用途可以,业务用途不行。javadoc 把 peek 定位为"主要用于调试",且终端操作(尤其存在短路或已知大小时)可以不处理元素——JDK 17 count() 跳过 peek 是实测。日志丢了还不报错,比不写日志更糟;要执行的副作用放 forEach 或收集后处理。
第三层:collect 与 reduce 怎么选?
要可变容器/多字段结果用 collect(收集器表达分组、分片、映射拼接);要纯不可变聚合(求和、最值)用 reduce 或原始类型特化(mapToInt().sum())。并行场景的关键判据是结合性:Integer::sum 结合,减法不结合。可变归约(collect)在并行下依赖容器 combinable,toMap 无 merge 函数遇重复键 fail-fast——两个 API 的失败边界不同。
第四层:代码评审时按什么顺序检查一条管道?
- 有终端操作吗?惰性中间操作不执行。2. 终端是否复用同一条管道?ISE。3. 副作用放在哪个环节?peek/map 里的是隐患。4.
toMap有 merge 函数吗?重复键与 null 值两条 NPE/ISE。5. 并行有必要且结合吗?核数不是免费的。6. 异常路径:收集失败后是整批重跑还是部分提交?不变量谁验证?
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| JLS17 §15.27 Lambda 表达式 | Java SE 17 | 捕获与函数式接口语言规则 |
| Java SE 17 java.util.stream 包描述 | Java SE 17 | 惰性、单次遍历、结合性、副作用边界 |
| Java SE 17 Stream API | Java SE 17 | 中间/终端/短路/close 契约 |
| Java SE 17 Collectors | Java SE 17 | toMap/groupingBy/toList 契约 |
| Java SE 17 Optional | Java SE 17 | of/nullable/map/flatMap/orElse 契约 |
| OpenJDK 17u Function 源码 | jdk-17.0.12+7 | compose/andThen 方向(L66) |
| OpenJDK 17u ReferencePipeline 源码 | jdk-17.0.12+7 | map 惰性 StatelessOp(L188) |
| OpenJDK 17u AbstractPipeline 源码 | jdk-17.0.12+7 | 终端单次 evaluate/ISE(L226) |
| OpenJDK 17u Collectors 源码 | jdk-17.0.12+7 | uniqKeysMapAccumulator 重复键/null(L176) |
| OpenJDK 17u Optional 源码 | jdk-17.0.12+7 | orElse/orElseGet 惰性分支(L349) |
本地 StdLibraryProbe 实验 | Oracle JDK 17.0.12 | 本轮全部行为实测与编译反例 |
访问日期:2026-10-24。源码行号以 jdk-17.0.12+7 标签为准;实验运行版本为 Oracle 17.0.12(x86_64)。count 跳过元素、Stream.toList 允许 null 为该版本实测观察,正文已标注与 API 契约的区分。
闭卷验收
本页 10 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。