原创

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才不会变成一段触发时点模糊的补救代码。

正文到此结束
评论插件初始化中...
Loading...
本文目录