Stream.onClose 什么时候执行:count 结束不等于 close
先数出两个元素,再检查关闭标记,结果仍是false。onClose里的清理没有失效,它只是在等待close,而count只负责完成计算。
这条差异适合直接用执行轨迹看清,不必先套资源泄漏结论:
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public class CloseHandlers {
public static void main(String[] args) {
AtomicBoolean closed = new AtomicBoolean();
Stream<Integer> values = Stream.of(1, 2).onClose(() -> closed.set(true));
System.out.println("count=" + values.count());
System.out.println("after_count=" + closed.get());
values.close(); System.out.println("after_close=" + closed.get());
RuntimeException first = new IllegalStateException("h1");
RuntimeException second = new IllegalArgumentException("h2");
try (Stream<Integer> stream = Stream.of(1)
.onClose(() -> { System.out.println("close=h1"); throw first; })
.onClose(() -> { System.out.println("close=h2"); throw second; })
.onClose(() -> System.out.println("close=h3"))) {
System.out.println("body=" + stream.count());
} catch (RuntimeException ex) {
System.out.println("primary=" + ex.getMessage());
System.out.println("suppressed=" + ex.getSuppressed()[0].getMessage());
}
}
}
在JDK 17保存为 CloseHandlers.java,执行 java CloseHandlers.java:
count=2
after_count=false
after_close=true
body=1
close=h1
close=h2
close=h3
primary=h1
suppressed=h2
第一段只有在调用close以后才改变标记;第二段的三个关闭处理器按注册顺序执行,即使前两个抛出异常,第三个仍然运行。第一个失败成为主异常,后续不同失败保留为suppressed。
一个终止的是计算,一个终止的是生命周期
count、collect和findFirst都是终止操作,表示本次管线计算已经求值。资源可能仍然打开,尤其是流来源持有文件、目录或其他可关闭对象时。短路拿到第一项也没有把输入所有权自动转移给终止操作。
onClose注册处理器,不会改变这个关系。代码如果使用try-with-resources,处理器会在离开作用域时调用;如果只在普通局部变量上挂了onClose,之后没有close,这段动作就可能永远没有执行机会。
因此创建资源流的方法应说明由谁关闭返回值。调用方得到一个Stream并不代表它一定只是内存遍历;工具库如果把资源流包装成过滤后的流返回,也应保留这份关闭责任。比起在各个终止操作后复制清理调用,明确作用域更能同时覆盖成功、短路与异常。
返回文件流时,把关闭责任一起返回
看一个实际工具方法:records只筛掉空行,把文件流交给调用方;调用方只想找第一条记录。函数签名仍然是Stream,看上去和内存数据没区别,但这份结果拥有文件资源,调用者必须用作用域接住它。过滤包装没有取消原来的关闭链。
保存下面的 FileStreamOwner.java,在 Java 17 运行 java FileStreamOwner.java。临时文件由程序创建,退出后删除;第二段故意同时制造处理失败与关闭失败,方便观察异常归属。
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public class FileStreamOwner {
static Stream<String> records(Path file, AtomicBoolean closed) throws IOException {
return Files.lines(file, StandardCharsets.UTF_8)
.filter(line -> !line.isBlank()).onClose(() -> closed.set(true));
}
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("stream-owner-", ".txt");
try {
Files.writeString(file,"\nalpha\nbeta\n", StandardCharsets.UTF_8);
AtomicBoolean closed = new AtomicBoolean();
try (Stream<String> stream = records(file,closed)) {
System.out.println("first=" + stream.findFirst().orElseThrow());
}
System.out.println("short-circuit-closed=" + closed.get());
try (Stream<String> stream = records(file,new AtomicBoolean())
.onClose(() -> { throw new UncheckedIOException(new IOException("closing")); })) {
stream.forEach(line -> { throw new IllegalStateException("processing"); });
} catch (IllegalStateException e) {
System.out.println("body-primary=" + e.getMessage());
Throwable suppressed = e.getSuppressed()[0];
System.out.println("close-suppressed=" + suppressed.getCause().getMessage());
}
} finally { Files.deleteIfExists(file); }
}
}
first=alpha
short-circuit-closed=true
body-primary=processing
close-suppressed=closing
findFirst在alpha处短路,没有读取整份业务记录,但离开try以后关闭标记仍为true。可以看出“遍历到文件末尾”不是关闭的必要条件,方法内也没有要求调用方为了释放资源先把剩余行全部消费完。
第二段的主失败来自处理函数,关闭失败挂在它的suppressed中。它与前一个只有关闭失败的样本形成对照:同样的onClose处理器,放进不同主体结果下,异常树的位置会变化。若告警只保留最外层英文消息,就会丢掉关闭阶段的诊断信息。
Files.lines在打开文件时可能抛IOException,后续读取与解码失败还可能以UncheckedIOException进入流消费过程。因此错误处理不能只包住创建那一行,再假设所有I/O已经结束;真正消耗数据的终止操作也属于资源作用域。这个例子用合法的小文件固定正常读取路径,没有把人为抛出的closing异常冒充操作系统实际关闭失败。
工具库还有另一个可选合同:不要返回资源流,而是在方法内部打开、完整处理并关闭,最终只返回一个普通结果。调用方不需要持续流式处理时,这种所有权更集中;需要边读边消费时,则保留Stream并在接口说明中明确由谁关闭。两种设计都比通过方法名猜资源寿命可靠。
若在records中打开流以后,又执行一段可能失败的参数检查,失败发生在返回之前,调用方还没有机会取得资源。这条路径应由创建方法自己清理;最简单的是在打开文件之前完成不依赖读取的校验。所有权尚未交付与已经交付之后,清理责任并不在同一个地方。
多个关闭失败,不应该只剩最后一条日志
样本让h1与h2分别失败。它们用于展示异常组织,不代表正常关闭逻辑应该主动抛出。清理一个资源失败以后,其他清理仍有执行机会,这能避免一条失败阻断后续释放。
排查时应同时检查主异常与suppressed,而不是只打印getMessage。若代码捕获主异常后只抛一个没有cause的新异常,相关关闭证据又会丢失。错误映射可以简化对外信息,内部诊断仍应保留完整关系。
try-with-resources还有自己的异常规则:若try主体本来已经失败,关闭过程的失败可能被挂到主体异常上。最初 CloseHandlers 程序的第二段主体没有抛错,才让关闭h1成为主失败。不能把本例顺序推广成“关闭异常永远覆盖业务异常”。
关闭处理器不适合补写业务提交
onClose可能由正常结束、短路、异常退出或调用方主动取消触发,它不是“所有元素已经处理成功”的信号。把支付提交、批次完成或消息确认放进onClose,会把资源作用域结束误当业务成功。
清理动作适合释放本作用域负责的资源,或完成与关闭直接相关的记录。业务完成状态应由处理结果和事务协议决定。若资源是借用的,则也不应因为注册了一个方便回调就擅自关闭它。
最后,别用这个小样本推断任意AutoCloseable都能随意重复关闭。具体类型的幂等、异常和并发关闭行为仍要看接口。本文观察的是Stream的关闭处理链,而不是为所有资源建立一种相同的销毁语义。计算结束与作用域结束分开表达,onClose才不会变成一段触发时点模糊的补救代码。