从资源收尾到异常边界:Java程序如何体面地失败(007–008)

Java语言基础与面向对象约 17 分钟阅读9,317 字符
知识库文档 9 / 251

覆盖考点:007–008 主题:final、finally、finalize、Cleaner,以及 Java 异常体系与工程实践
预计阅读时长:32–38 分钟

程序的主流程往往不难写:打开文件、请求数据库、调用支付渠道、返回结果。真正拉开工程质量差距的,是失败之后发生什么:资源有没有归还,原始原因有没有保留,调用方能否看懂,系统能否恢复。

先记住本文的两条主线:

  1. 资源问题的核心是所有权。 谁申请,谁负责在确定的时间释放;GC 兜底不能代替显式生命周期。
  2. 异常问题的核心是边界。 底层保留原因,中层翻译语义,最外层决定怎样记录并回应调用方。

下面四张图不是装饰,而是全文的导航。第一张先给出资源释放的优先级:显式关闭是主流程,Cleaner 只是安全网,finalize() 已经退出历史舞台。

图 1|资源生命周期:显式 CLOSE 是主路径,Cleaner 只负责漏关兜底

文章配图
文章配图


007|final、finally、finalize、Cleaner:四个近似名字,四种完全不同的职责

一、final:限制“变化入口”,并不自动创造不可变对象

final 可以修饰变量、方法和类。三者的共同点不是“常量”,而是阻止某种再次变化。

写法 禁止的事情 没有禁止的事情
final int retry = 3 变量再次赋值 无
final List<String> items 引用改指向另一列表 修改原列表内容
final void validate() 子类重写该方法 子类新增其他方法
final class OrderId 其他类继承它 类内部拥有可变字段

最常见的误解是把“final 引用”当成“不可变对象”:

java
final List<String> channels = new ArrayList<>();
channels.add("CARD");       // 合法:对象内容发生变化
// channels = new ArrayList<>(); // 编译失败:引用不能重新赋值

如果希望列表本身不可修改,可以在边界处建立不可变副本:

java
public final class RoutingRule {
    private final List<String> channels;

    public RoutingRule(List<String> channels) {
        this.channels = List.copyOf(channels);
    }

    public List<String> channels() {
        return channels;
    }
}

这里有两层保护:字段不能改指向,List.copyOf 返回的列表也不允许增删。若列表元素自身可变,还要继续考虑深层可变性;不可修改的容器不等于整张对象图都不可变。

final 字段还有一层并发语义

Java 内存模型对构造器中正确写入的 final 字段提供特殊保证。简单说:对象没有在构造期间逸出时,另一个线程拿到对象引用后,应该能看到构造完成时的 final 字段值,而不需要为这一次初始化额外加锁。

java
public final class Endpoint {
    private final String host;
    private final int port;

    public Endpoint(String host, int port) {
        this.host = Objects.requireNonNull(host);
        this.port = port;
    }
}

这不是说“用了 final 就线程安全”。若字段指向可变对象,后续修改仍需要同步;如果在构造器里把 this 注册到监听器、静态集合或线程中,对象提前逸出,初始化安全也会被破坏。

问:成员变量加 final 后,必须在声明处赋值吗? 答:不必。它可以在声明处、实例初始化块或每个构造器路径中完成一次赋值;这类字段常称为 blank final。编译器会检查是否存在未赋值或重复赋值路径。

问:final 方法和 private 方法都不能被重写,有什么区别? 答:final 方法参与继承,只是禁止子类改变实现;private 方法对子类不可见,谈不上重写。子类写一个同名方法只是新方法。

二、finally:负责控制流收尾,但不是资源管理的最佳抽象

当控制流离开 try 时,finally 通常会执行,包括正常结束、抛出异常、return、break 或 continue。但“finally 百分之百执行”并不严谨:进程被强制结束、JVM 崩溃、机器掉电、执行 Runtime.halt() 时,都没有机会保证它运行。

return 与 finally 到底谁先?

java
static int demo() {
    int result = 1;
    try {
        return result;
    } finally {
        result = 2;
    }
}

返回值是 1。JVM 会先计算并保存待返回的值,再执行 finally,最后把先前保存的结果交给调用方。

但是,如果 finally 自己写了 return,它会覆盖原返回值;如果 finally 抛出新异常,它会取代正在传播的异常。这两种写法都会隐藏真实控制流:

java
static int bad() {
    try {
        throw new IllegalStateException("真正的问题");
    } finally {
        return 0; // 异常被吞掉,调用方误以为成功
    }
}

工程约定很简单:不要在 finally 中 return;收尾动作自身也要避免掩盖主异常。

手写 finally 为什么容易错?

假设业务读取文件失败,关闭文件时也失败:

java
InputStream in = null;
try {
    in = Files.newInputStream(path);
    return parse(in);
} finally {
    if (in != null) {
        in.close();
    }
}

close() 抛出的异常可能覆盖 parse() 的主异常。排障人员最终只看见“关闭失败”,却丢了最初导致业务失败的原因。资源变多后,还要处理部分初始化、关闭顺序和每次关闭失败,代码迅速失控。

三、try-with-resources:让资源所有权进入语法结构

Java 7 引入 try-with-resources。只要资源实现 AutoCloseable,作用域结束时就会自动关闭:

java
try (InputStream raw = Files.newInputStream(path);
     BufferedInputStream buffered = new BufferedInputStream(raw)) {
    return parse(buffered);
}

两个关键规则必须会说:

  1. 资源按声明的逆序关闭。 上例先关 buffered,再关 raw。
  2. 主异常优先。 业务异常继续向外抛,关闭异常被放进主异常的 getSuppressed()。

图 2|try-with-resources:A、B 依次打开,退出时 B、A 逆序关闭;关闭失败成为 suppressed exception

文章配图
文章配图

下面的例子可以直接观察 suppressed exception:

java
final class BrokenResource implements AutoCloseable {
    @Override
    public void close() {
        throw new IllegalStateException("close failed");
    }
}

try (BrokenResource ignored = new BrokenResource()) {
    throw new IllegalArgumentException("business failed");
} catch (Exception e) {
    System.out.println(e.getMessage());
    // business failed
    System.out.println(e.getSuppressed()[0].getMessage());
    // close failed
}

这比“后来的关闭异常覆盖先来的业务异常”更适合诊断。日志框架打印完整异常栈时,通常也会展示 Suppressed: 部分。

AutoCloseable 怎么设计才好用?

一个资源类最好做到:

  • close() 尽量幂等,多次调用不会重复释放或破坏状态;
  • 明确关闭后其他方法的行为,必要时快速失败;
  • 不在 close() 中做无界等待;
  • 保留原始异常作为 cause,不要只抛一句新的错误文本;
  • 如果拥有子资源,由拥有者按依赖逆序关闭。
java
public final class PaymentSession implements AutoCloseable {
    private final Channel channel;
    private boolean closed;

    public PaymentSession(Channel channel) {
        this.channel = channel;
    }

    @Override
    public synchronized void close() throws IOException {
        if (closed) return;
        closed = true;
        channel.close();
    }
}

问:所有对象都应该实现 AutoCloseable 吗? 答:不应该。它适合拥有明确外部资源或生命周期动作的对象,例如文件、Socket、数据库连接、Native 内存句柄。普通内存对象交给 GC 即可。

问:连接池里的 Connection 执行 close,真的关闭物理连接吗? 答:通常不会。连接池返回的是代理,close() 的语义是把逻辑连接归还池中。调用方仍必须 close,否则池会被耗尽。

四、finalize():它的问题不是“慢一点”,而是生命周期不可证明

历史上的 Object.finalize() 试图在对象被回收前给它一次清理机会。这个机制有四个根本问题:

  1. 不及时。 何时执行依赖 GC,对象不可达不等于马上终结。
  2. 不保证。 进程结束前可能根本没有执行机会。
  3. 吞吐差。 待终结对象需要额外队列和线程处理,积压会拖住大量对象和资源。
  4. 行为危险。 终结方法可以把 this 重新赋给静态变量,让对象“复活”;异常处理和安全审计也更困难。

JEP 421 已将 Finalization 标记为 Deprecated for Removal。实际项目不要再设计新的 finalize(),更不能依赖它释放数据库连接、锁、文件句柄或 Native 内存。

五、Cleaner:比 finalize 可控,但仍然只是最后一道保险

Java 9 提供 Cleaner。它把清理动作与被观察对象分离,底层使用幻象引用及引用队列,在对象不可达后安排清理任务。相较 finalize(),它没有对象复活的常规入口,也更容易限制清理逻辑。

典型结构如下:

java
public final class NativeBuffer implements AutoCloseable {
    private static final Cleaner CLEANER = Cleaner.create();

    private static final class State implements Runnable {
        private long address;

        State(long address) {
            this.address = address;
        }

        @Override
        public void run() {
            if (address != 0) {
                NativeMemory.free(address);
                address = 0;
            }
        }
    }

    private final State state;
    private final Cleaner.Cleanable cleanable;

    public NativeBuffer(long address) {
        this.state = new State(address);
        this.cleanable = CLEANER.register(this, state);
    }

    @Override
    public void close() {
        cleanable.clean();
    }
}

最容易写错的地方是让 State 持有外部 NativeBuffer,例如把它写成非静态内部类。这样清理动作反过来强引用被清理对象,对象可能一直不可达不了,Cleaner 也就无法触发。

正确优先级是:调用方用 try-with-resources 显式 close();如果漏关,Cleaner 才尝试兜底。Cleaner 同样依赖对象不可达和调度,不具备及时性,不能拿来维持连接池容量或锁的正确性。

六、把资源释放落实为“所有权协议”

面试官追问“谁来关”时,不要只回答 try-with-resources。先判断所有权:

场景 谁拥有资源 谁关闭
方法内部创建并消费 InputStream 当前方法 当前方法的 try-with-resources
方法接收调用方传入的 InputStream 通常是调用方 除非契约明确转移所有权,否则不要擅自关闭
连接池借出的 Connection 当前借用作用域 当前作用域 close,语义是归还连接池
框架注入的共享客户端 框架容器 业务方法不要逐次关闭
返回 Stream 且底层绑定文件 接收返回值的一方 API 必须明确要求调用方关闭

这比背“finally 会不会执行”更接近生产问题。资源泄漏往往不是忘了 API,而是所有权契约含糊。


008|Java 异常体系:不是把错误包起来,而是保留因果、稳定边界

一、先把 Throwable 家族分清楚

Java 中只有 Throwable 及其子类能被 throw,也只有它们能被 catch。最上层分为 Error 与 Exception;Exception 中再区分 RuntimeException 和其他受检异常。

图 3|Java 异常体系:Error、运行时异常与受检异常承担不同语义

文章配图
文章配图

这张图表达的是类别,不是完整类表。IOException、SQLException 等通常属于受检异常;NullPointerException、IllegalArgumentException 属于运行时异常。

类别 编译器是否强制处理 常见含义 通常策略
Error 否 JVM、链接、内存等严重问题 通常不在业务层恢复;在最外层留诊断后让系统治理
受检异常 是 调用者有机会预期并采取措施的外部失败 捕获处理,或在方法签名中继续声明
运行时异常 否 前置条件、状态、编程错误或业务拒绝 在合适边界转为稳定业务语义

“受检异常一定可恢复、运行时异常一定是程序 bug”只是粗略口诀,并非语言强制。异常类型最终是一份 API 契约:调用者是否必须在编译期面对它。

Error 能不能 catch?

语法上能,业务上通常不应该随意捕获。比如 OutOfMemoryError 发生时,进程可能已没有足够资源执行复杂恢复逻辑。最外层线程边界或基础设施可以捕获 Throwable 做极简诊断、隔离任务,但不应把严重错误包装成“业务失败”后继续假装系统健康。

也不要捕获 ThreadDeath、VirtualMachineError 等后无条件吞掉。是否终止实例、摘流或重启,应该交给进程与平台级治理。

二、throw、throws 和异常传播:栈是怎样一层层退回来的

  • throw 抛出一个具体异常对象;
  • throws 在方法签名上声明该方法可能把哪些受检异常交给调用者。

异常抛出后,JVM 从当前位置寻找能匹配的 catch。当前方法找不到,就执行必要的 finally,弹出当前栈帧,回到调用者继续找。这个过程叫栈展开。

java
public Receipt pay(Order order) {
    try {
        return gateway.charge(order);
    } catch (SocketTimeoutException e) {
        throw new PaymentGatewayTimeout(order.id(), e);
    }
}

这里不是在“消灭异常”,而是在改变抽象层级:上层不需要理解 Socket,只需要知道支付网关超时。原始 e 作为 cause 保留下来,排障时仍能沿因果链回到底层。

catch 匹配为什么要从具体到宽泛?

java
try {
    callRemote();
} catch (SocketTimeoutException e) {
    retryLater(e);
} catch (IOException e) {
    failAsNetworkError(e);
}

SocketTimeoutException 是 IOException 的子类。如果先写 catch (IOException),后面的子类分支永远不可达,编译器会拒绝。处理策略越具体的异常越靠前。

Java 7 的 multi-catch 适合处理逻辑完全相同、互无继承关系的异常:

java
try {
    parseAndStore(command);
} catch (DateTimeParseException | NumberFormatException e) {
    throw new InvalidCommandException("请求字段格式错误", e);
}

三、异常翻译:每一层只暴露自己承诺的语言

一个常见反模式是 Controller 直接把 SQLException 的消息返回前端。这样既泄漏表名和 SQL 细节,又让 API 与数据库实现绑定。

更稳的做法是分层翻译:Repository 保存技术原因,Domain/Application 层赋予业务含义,API 层映射为稳定协议。

图 4|异常翻译:技术失败向上转化为领域语义,再映射为稳定 API 响应

文章配图
文章配图

例如订单号唯一键冲突:

java
// Repository:理解数据库错误,但不决定 HTTP
try {
    jdbcTemplate.update(INSERT_SQL, order.id(), order.amount());
} catch (DuplicateKeyException e) {
    throw new OrderAlreadyExistsException(order.id(), e);
}
java
// API 边界:理解领域异常,并决定对外协议
@ExceptionHandler(OrderAlreadyExistsException.class)
ResponseEntity<ApiError> handleDuplicate(OrderAlreadyExistsException e) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(new ApiError("ORDER_ALREADY_EXISTS", "订单已存在"));
}

三个细节决定这是不是一次合格的翻译:

  1. 保留 cause。new XxxException(message, e),不能只复制 e.getMessage()。
  2. 对外错误码稳定。 不直接把 Java 类名、SQL 文本或第三方文案当协议。
  3. 只记录一次完整栈。 中间每层都打印再抛出,会产生多份重复日志,让一次故障看起来像多次。

问:catch 后重新 throw 原异常,会丢栈吗? 答:throw e; 会继续抛同一对象,原始创建位置仍在栈中。throw new XxxException(e) 会形成新的顶层栈并保留 cause。最糟糕的是 throw new XxxException(e.getMessage()),它丢失因果链。

问:异常翻译是不是每层都要包装一次? 答:不是。只有跨越抽象边界、上层不应依赖下层异常类型时才翻译。没有新增语义的层层套娃只会把栈变长。

四、自定义异常:少而稳定,比“每个方法一个异常”更有用

自定义异常至少要回答三个问题:

  1. 调用者能否采取与其他异常不同的动作?
  2. 它属于哪个稳定抽象层?
  3. 需要携带哪些结构化上下文?
java
public final class PaymentDeclinedException extends RuntimeException {
    private final String orderId;
    private final String reasonCode;

    public PaymentDeclinedException(
            String orderId, String reasonCode, Throwable cause) {
        super("payment declined, orderId=" + orderId, cause);
        this.orderId = orderId;
        this.reasonCode = reasonCode;
    }

    public String orderId() { return orderId; }
    public String reasonCode() { return reasonCode; }
}

异常消息给人读,reasonCode 给程序决策。不要让调用方解析 message 中的中文或字符串片段来判断重试、降级或返回码。

异常对象也不该塞入完整订单、用户隐私或超大响应报文。日志、监控和序列化链路可能把这些内容带到意外的位置。

五、什么时候用受检异常,什么时候用运行时异常?

可以用一个问题做判断:调用者是否能在当前抽象层采取有意义、且经常需要的恢复动作?

  • 读取用户选择的本地文件失败,调用者可能改选文件或提示权限问题,受检异常有一定价值。
  • 方法收到负数金额,是契约被违反,通常用 IllegalArgumentException。
  • 订单状态不允许退款,是业务拒绝,可用有稳定错误码的领域运行时异常。
  • 基础设施 SDK 抛出几十种受检异常,而业务只能统一重试或失败,可以在适配层翻译成少量领域异常。

受检异常的问题不是“麻烦”,而是会沿调用链污染签名;运行时异常的问题不是“危险”,而是调用者容易忽略契约。选择依据应是 API 使用方式,而不是团队对某一派的信仰。

六、不要用异常控制正常流程

异常对象通常要捕获栈信息,抛出还会触发栈展开。更重要的是,它会把普通分支伪装成故障路径,降低代码可读性。

java
// 不推荐:缺少缓存是正常分支
try {
    return cache.get(key).value();
} catch (NullPointerException e) {
    return loadFromDatabase(key);
}
java
// 更清晰
CacheValue value = cache.get(key);
return value != null ? value.value() : loadFromDatabase(key);

但也不要为了“避免异常”返回含义模糊的 null 或 -1。正常的“未找到”可以用 Optional、空集合或明确的结果类型;真正无法履行方法契约的失败仍应抛异常。

Optional 和异常不是替代关系

Optional.empty() 适合表示“没有值也是正常结果”,例如按可选昵称查询。数据库不可用不是“没有查到”,如果也返回 empty,会把故障伪装成业务不存在。

七、catch Exception 什么时候合理?

在业务深处用 catch (Exception e) 一把抓,通常会误吞无法正确处理的失败;如果只打印一句日志再返回成功,更是危险。

但在明确的执行边界,宽捕获有合理用途:

  • Web 全局异常处理器:把未知异常映射为 500,并统一记录 traceId;
  • 消息消费者入口:决定重试、死信或暂停消费;
  • 线程池任务包装器:避免一个任务悄悄失败且无人观察;
  • 批处理单元边界:隔离单条记录失败,继续其他记录。

边界捕获后必须做出明确决定:恢复、转换、重试、隔离,或记录后终止。仅仅“catch 到”不是处理。

八、异步代码中的异常不会自动回到提交线程

java
executor.submit(() -> {
    throw new IllegalStateException("async failed");
});

任务在线程池线程执行,外层提交代码的 try-catch 捕获不到。使用 submit 时,异常通常被保存在 Future 中,调用 get() 才以 ExecutionException 暴露;如果 Future 被丢弃,失败可能长期无人观察。

CompletableFuture 通常用 whenComplete、handle 或 exceptionally 观察异常:

java
CompletableFuture<Receipt> future =
        CompletableFuture.supplyAsync(() -> gateway.pay(command), pool)
                .orTimeout(2, TimeUnit.SECONDS)
                .whenComplete((value, error) -> {
                    if (error != null) {
                        metrics.recordFailure(unwrap(error));
                    }
                });

异步编排常用 CompletionException 包装真实 cause。异常翻译前要适度解包,但不能粗暴丢失包装层携带的上下文。

九、异常与事务、重试的组合陷阱

在 Spring 默认规则下,声明式事务通常遇到 RuntimeException 或 Error 才回滚;受检异常默认不回滚,除非配置 rollbackFor。这是框架规则,不是 Java 语言规则。

java
@Transactional(rollbackFor = PaymentException.class)
public void settle(...) throws PaymentException {
    // ...
}

另一个高频问题是盲目重试。只有失败是暂时的、操作具备幂等性、重试有次数和退避时,重试才安全。参数非法、余额不足、唯一键冲突通常不会因为立刻再试一次而好转。

可以给异常附带稳定分类,而不是根据类名猜:

分类 例子 常见动作
可重试瞬时故障 连接超时、限流、短暂主从切换 指数退避、抖动、上限、熔断
不可重试业务拒绝 余额不足、状态不允许 返回明确错误码
不可重试调用错误 参数缺失、签名非法 修正请求,不进入自动重试
系统性严重故障 OOM、配置整体错误 摘流、告警、重启或人工介入

十、日志怎么记,才不会把一次异常变成十次告警?

推荐原则:

  • 能处理就处理,不必每层都打印。
  • 需要继续抛时保留 cause。
  • 在拥有请求上下文的边界记录一次完整栈。
  • 日志包含 traceId、稳定错误码和必要业务标识,不记录密码、Token、完整身份证号等敏感内容。
  • 预期内的业务拒绝通常用 INFO/WARN 和指标统计;未知系统失败才进入 ERROR 与告警。

最典型的噪声代码是:

java
catch (Exception e) {
    log.error("调用失败", e);
    throw e;
}

如果上层还会统一记录,这一层没有新增上下文,就只是在制造重复日志。若确实需要增加上下文,优先把信息加入异常或 tracing span,并明确全链路只在哪个边界打印完整栈。


综合案例:支付超时后,资源与异常怎样一起收口

java
public PaymentResult pay(PaymentCommand command) {
    try (PaymentSession session = gateway.openSession()) {
        return session.charge(command);
    } catch (SocketTimeoutException e) {
        throw new PaymentGatewayTimeoutException(
                command.orderId(), "GATEWAY_TIMEOUT", e);
    } catch (IOException e) {
        throw new PaymentInfrastructureException(
                command.orderId(), "GATEWAY_IO_ERROR", e);
    }
}

这段代码很短,却包含完整的工程决策:

  • PaymentSession 的所有权属于当前调用,通过 try-with-resources 确定释放;
  • 关闭时若失败,不覆盖支付过程中的主异常,而是进入 suppressed 列表;
  • Socket 和 I/O 细节不泄漏到业务层,被翻译为支付领域异常;
  • orderId 和稳定错误码用于定位与协议映射,原始 cause 用于排障;
  • 上层可以根据异常类型和错误码决定重试、降级或响应,不需要解析 message。

面试快问快答

1. finally 一定会执行吗? 通常会在控制流离开 try 时执行,但 JVM 崩溃、进程强停、掉电或 Runtime.halt() 等情况不保证。回答“一定”过于绝对。

2. finally 中 return 有什么风险? 它会覆盖 try 的返回值,甚至吞掉正在传播的异常,让失败伪装成成功,应禁止。

3. try-with-resources 多个资源按什么顺序关闭? 按声明的逆序关闭。后构造的包装资源通常依赖先构造的底层资源,所以先关闭包装层。

4. 主逻辑和 close 同时抛异常,哪个向外传播? 主逻辑异常向外传播,关闭异常通过 getSuppressed() 保留。

5. final 引用是否代表对象不可变? 否。它只禁止引用重新赋值,对象内容能否修改取决于对象本身。

6. Cleaner 能否替代 close? 不能。Cleaner 没有及时性保证,只适合在调用方漏关时兜底;正确主流程仍是 AutoCloseable 和 try-with-resources。

7. 为什么 finalize 被废弃? 执行时间和执行机会不确定,吞吐差,可能导致对象复活,还扩大安全与诊断复杂度。

8. RuntimeException 与受检异常的本质差异是什么? 编译器是否强制调用者捕获或声明。它们的业务语义由 API 设计决定,不是 JVM 自动判定“可恢复/不可恢复”。

9. 为什么不能只抛**new BusinessException(e.getMessage())**? 因为原异常类型、栈和因果链都丢了。应把 e 作为 cause 传入构造器。

10. 捕获 Exception 一定是坏代码吗? 不是。在 Web、消息消费、线程任务等明确边界,宽捕获用于统一记录和隔离很合理;在业务深处无差别吞掉则危险。

11. 异步任务的异常为什么外层 catch 不到? 因为异常发生在另一个线程栈。必须通过 Future、CompletableFuture 回调或线程的未捕获异常机制观察。

12. 异常是否越细越好? 不是。只有调用者需要采取不同动作、或跨抽象边界需要稳定语义时才值得新增类型。过细会造成异常类爆炸和层层包装。

最后自测

场景一

一个方法接收调用方传入的 InputStream,读取后顺手关闭。这样写一定正确吗?

不一定。关键看所有权是否转移。若契约没有说明,通常由创建流的调用方关闭;被调用方法擅自关闭可能破坏后续读取。API 应通过命名、文档或类型明确所有权。

场景二

Repository 捕获 SQLException,打印 ERROR,然后包装为领域异常;Service 又打印 ERROR 并继续抛;ControllerAdvice 再打印一次。问题是什么?

同一个失败产生三份完整栈,污染日志和告警。Repository 应翻译并保留 cause,通常由拥有 traceId 和请求上下文的最外层统一记录一次;中间层只有在真正处理或增加不可替代信息时才记录。

场景三

数据库超时和“订单不存在”都返回 Optional.empty(),看起来调用方便,有什么隐患?

把系统故障伪装成正常的业务缺失,上层无法重试、告警或区分用户提示。Optional 表达“没有值也正常”,不能代替失败通道。

场景四

一个 Native 资源类提供 Cleaner,却不实现 AutoCloseable,是否可靠?

不可靠。资源释放时间仍由 GC 与 Cleaner 调度决定。它应该实现 AutoCloseable,让调用方显式关闭,再用 Cleaner 处理极少数漏关情况。

一句话收束

final 控制变化入口,finally 参与控制流收尾,try-with-resources 管理确定的资源生命周期,Cleaner 只做最后兜底;异常则要沿调用栈保留原因、跨抽象层翻译语义、在系统边界做出明确决策。

能把这两件事讲清楚,说明你理解的不只是 Java 语法,而是一个服务怎样在失败时仍保持可诊断、可恢复和不失控。