Java 基础体系 · 第 7/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 异常处理:受检异常、错误链、资源关闭和 API 契约

Java 异常处理不只是“发生异常时打印日志”。它同时规定了四件事:

  1. 哪些异常必须在编译期被处理;
  2. 一个异常如何携带另一个异常的原因;
  3. 资源在正常路径和失败路径上如何关闭;
  4. API 如何向调用者表达失败,以及调用者能否据此稳定编程。

本文以 Java 25 为范围,重点讨论 Throwable 体系、受检异常、异常链、try 语句、try-with-resources、API 的异常契约,以及它们在 I/O、测试和并发代码中的边界。


一、异常处理的运行模型

1. Throwable 是异常传播的根类型

Java 中能够通过 throw 抛出、通过 catch 捕获的对象,必须是 Throwable 的实例:

public class Demo {
    public static void main(String[] args) {
        throw new IllegalStateException("state is invalid");
    }
}

Throwable 主要分为两个分支:

Throwable
├── Error
└── Exception
    └── RuntimeException

这是概念上的主要结构,实际 JDK 还包含其他直接或间接子类。

  • Error 表示通常由 JVM、运行时环境或严重系统条件导致的问题,例如 OutOfMemoryErrorStackOverflowError
  • Exception 表示应用程序通常可能处理或向上层报告的异常。
  • RuntimeException 表示运行时异常,通常用于编程错误或无法由编译器强制处理的失败,例如 NullPointerExceptionIllegalArgumentExceptionIllegalStateException

这里的“通常”很重要。Java 语法允许捕获 Error,也允许主动抛出 RuntimeException;但捕获一个 OutOfMemoryError 后继续运行,往往并不能恢复程序的一致状态。

2. 异常传播是沿调用栈向上的控制转移

假设调用关系为:

main()
  └── service()
        └── repository()
              └── Files.readString()

repository() 中发生异常时,JVM 会按以下顺序寻找匹配的处理器:

  1. 在当前方法中寻找可处理该异常的 catch
  2. 如果没有,终止当前方法,返回调用者;
  3. 在调用者中继续寻找;
  4. 直到找到匹配处理器,或者线程的未捕获异常处理器接管。

例如:

static void repository() {
    throw new IllegalStateException("database unavailable");
}

static void service() {
    repository();
}

public static void main(String[] args) {
    service();
}

如果没有任何 catch,异常会一直向上传播,最终由线程的未捕获异常处理器处理。常见实现会向标准错误输出堆栈信息,但“如何输出”属于实现行为,不应把它当作业务 API 契约。

异常传播时,异常对象通常携带:

  • 异常类型;
  • 异常消息;
  • 创建异常时记录的堆栈轨迹;
  • 原因异常 cause
  • 在 try-with-resources 中产生的被抑制异常 suppressed exceptions

二、受检异常:编译期强制的异常契约

1. 什么是受检异常

根据 Java 语言规范的异常检查规则,除 RuntimeException 及其子类、Error 及其子类之外,其他直接或间接继承 Throwable 的异常属于受检异常。

例如:

class ConfigurationException extends Exception {
    ConfigurationException(String message) {
        super(message);
    }
}

ConfigurationException 是受检异常,因为它继承自 Exception,且不是 RuntimeException 的子类。

相反:

class InvalidOrderException extends RuntimeException {
    InvalidOrderException(String message) {
        super(message);
    }
}

InvalidOrderException 是非受检异常。

受检与非受检不是“严重程度”的划分:

  • 受检异常不一定比运行时异常严重;
  • 运行时异常也不一定可以忽略;
  • 这个分类首先描述的是编译器是否强制调用者显式处理潜在异常

2. 受检异常必须被捕获或声明

下面的代码无法通过编译:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

class ReadDemo {
    static String load(Path path) {
        return Files.readString(path);
    }
}

Files.readString(Path) 声明可能抛出 IOExceptionIOException 是受检异常,因此 load 必须捕获它,或者在自己的方法声明中继续声明:

static String load(Path path) throws IOException {
    return Files.readString(path);
}

也可以在本层处理:

static String loadOrDefault(Path path) {
    try {
        return Files.readString(path);
    } catch (IOException e) {
        return "";
    }
}

这两种写法表达的契约不同:

  • throws IOException:当前方法不负责决定如何处理 I/O 失败,将决定权交给调用者;
  • catch (IOException e):当前方法已经决定了失败后的行为,例如返回默认值、转换异常、重试或记录状态。

如果既不捕获,也不声明,编译器会报告类似“未报告的异常,必须被捕获或声明”的错误。

3. 编译器检查的核心条件

可以把方法中的异常检查简化为如下条件:

如果一个受检异常 E 可能从方法体正常退出路径传播,
那么方法必须:
    1. 在某个可达 catch 中处理 E;或
    2. 在 throws 子句中声明 E 的超类型。

这里有三个关键点。

第一,必须是受检异常

static void parse(String text) {
    throw new IllegalArgumentException("bad input");
}

不需要 throws IllegalArgumentException,因为它是非受检异常。

虽然可以写:

static void parse(String text) throws IllegalArgumentException {
    throw new IllegalArgumentException("bad input");
}

但这不会让调用者获得编译期强制处理。throws 对非受检异常主要是文档作用。

第二,异常必须具有可达传播路径

static void example() {
    if (false) {
        throw new Exception("unreachable");
    }
}

这里的 throw 不构成可执行路径,因此不会产生通常意义上的“必须处理”要求。

第三,捕获类型必须能够匹配抛出类型

static void load() {
    try {
        throw new java.io.FileNotFoundException();
    } catch (java.io.IOException e) {
        // 可以捕获,因为 FileNotFoundException 是 IOException 的子类
    }
}

反过来,不能用不相关的异常类型捕获:

try {
    throw new java.io.IOException();
} catch (java.sql.SQLException e) {
    // 编译错误:异常类型不可能匹配
}

4. throws 不是运行时抛出动作

以下两部分作用不同:

static void read() throws java.io.IOException {
    throw new java.io.IOException("read failed");
}
  • throws IOException 是方法声明中的异常契约,告诉编译器和调用者该方法可能传播什么受检异常;
  • throw new IOException(...) 是运行时实际创建并抛出异常对象的动作。

throws 本身不会创建异常,也不会自动处理异常。


三、trycatchfinally 与控制流

1. catch 按顺序匹配

多个 catch 会从上到下进行匹配,先匹配到的处理器执行:

try {
    throw new java.io.FileNotFoundException();
} catch (java.io.FileNotFoundException e) {
    System.out.println("file not found");
} catch (java.io.IOException e) {
    System.out.println("other I/O error");
}

不能把父类型放在子类型前面:

try {
    throw new java.io.FileNotFoundException();
} catch (java.io.IOException e) {
    // 已经可以匹配 FileNotFoundException
} catch (java.io.FileNotFoundException e) {
    // 编译错误:永远不可达
}

2. 多重捕获表示“同一种处理逻辑”

如果多个异常的处理逻辑完全相同,可以使用多重捕获:

try {
    process();
} catch (java.io.IOException | java.text.ParseException e) {
    throw new IllegalStateException("input processing failed", e);
}

多重捕获中的类型不能存在父子关系,例如下面是非法的:

catch (java.io.IOException | java.io.FileNotFoundException e) {
}

因为 FileNotFoundException 已经被 IOException 覆盖。

多重捕获参数在语言层面具有近似隐式不可重新赋值的性质,不能在处理器中把它重新赋成另一个异常对象:

catch (IOException | ParseException e) {
    // e = new IOException(); // 编译错误
}

3. finally 通常用于无论成败都要执行的动作

Lock lock = ...;
lock.lock();
try {
    updateState();
} finally {
    lock.unlock();
}

只要控制流离开 tryfinally 通常都会执行,包括:

  • try 正常结束;
  • try 中抛出异常;
  • try 中执行 return
  • try 中执行 breakcontinue

finally 不是“绝对执行”的保证。例如:

  • JVM 在执行前进程被强制终止;
  • System.exit 结束虚拟机;
  • JVM 崩溃或宿主机断电;
  • 线程卡死,根本没有离开 try

4. 不要在 finally 中返回或抛出无关异常

下面的 return 会覆盖 try 中的返回值,也可能覆盖原本正在传播的异常:

static int bad() {
    try {
        throw new IllegalStateException("original");
    } finally {
        return 0;
    }
}

调用 bad() 的结果是 0,原始异常被吞掉。

类似地:

static void alsoBad() {
    try {
        throw new IllegalStateException("original");
    } finally {
        throw new RuntimeException("cleanup failed");
    }
}

最终传播的是 "cleanup failed",原始异常丢失。资源关闭应优先使用 try-with-resources;如果必须使用 finally,就要明确处理关闭失败与原始失败的关系。


四、异常链:保留失败的因果关系

1. 什么是原因异常

当低层异常不适合直接暴露给上层时,通常需要创建一个更符合当前抽象层次的新异常,同时保留原始异常作为原因:

class UserRepositoryException extends Exception {
    UserRepositoryException(String message, Throwable cause) {
        super(message, cause);
    }
}

使用:

static String loadUserName(java.nio.file.Path path)
        throws UserRepositoryException {
    try {
        return java.nio.file.Files.readString(path);
    } catch (java.io.IOException e) {
        throw new UserRepositoryException(
                "无法加载用户资料: " + path, e);
    }
}

这里形成了一条原因链:

UserRepositoryException
└── cause: IOException

上层看到的是领域相关的 UserRepositoryException,诊断时仍然可以追溯到底层 IOException

2. 直接传入原因,而不是只拼接消息

错误做法:

catch (IOException e) {
    throw new UserRepositoryException(
            "load failed: " + e.getMessage());
}

这只复制了文字,丢失了:

  • 原始异常类型;
  • 原始堆栈轨迹;
  • 原始异常的原因链;
  • 机器可判断的异常信息。

正确做法:

catch (IOException e) {
    throw new UserRepositoryException("load failed", e);
}

异常消息面向人,异常类型和原因链面向程序与诊断工具。不能用消息文本替代类型契约。

3. initCause 用于延迟设置原因

除了构造器,还可以使用:

Throwable wrapper = new RuntimeException("wrapper");
wrapper.initCause(original);

initCause 通常只能成功设置一次原因。如果异常已经在构造器中指定原因,或者原因已被设置,再次调用会抛出 IllegalStateException

自定义异常优先提供带 cause 的构造器,因为它能在对象创建时建立完整链条:

class DataAccessException extends RuntimeException {
    DataAccessException(String message, Throwable cause) {
        super(message, cause);
    }
}

4. getCause()getSuppressed() 表达不同关系

这两种关系不能混淆。

原因链:一个失败由另一个失败解释

业务异常
└── cause: 数据库异常

它通常表示“当前异常是对底层异常的抽象转换”。

被抑制异常:主失败发生后,清理动作也失败

主异常:读取失败
└── suppressed: 关闭文件失败

它表示两个异常同时存在,但主异常优先传播。

诊断代码可以分别查看:

static void printDetails(Throwable error) {
    System.err.println(error);

    for (Throwable suppressed : error.getSuppressed()) {
        System.err.println("suppressed: " + suppressed);
    }

    Throwable cause = error.getCause();
    while (cause != null) {
        System.err.println("caused by: " + cause);
        cause = cause.getCause();
    }
}

不要简单地递归打印所有关系而不区分 causesuppressed,否则会把“失败原因”和“清理失败”混成一条线。


五、try-with-resources:资源关闭的确定性规则

1. 资源是什么

资源通常是需要显式释放的对象,例如:

  • 文件输入输出流;
  • ReaderWriter
  • socket 和网络流;
  • 数据库连接、语句、结果集;
  • 压缩流;
  • 锁或其他实现了 AutoCloseable 的对象。

try-with-resources 要求资源实现 AutoCloseable

public interface AutoCloseable {
    void close() throws Exception;
}

Closeable 继承自 AutoCloseable,其 close() 声明为 IOException,因此更适合 I/O 资源。

2. 基本语法与生命周期

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

static String read(Path path) throws IOException {
    try (var reader = Files.newBufferedReader(path)) {
        return reader.readLine();
    }
}

生命周期是:

创建资源
  ↓
执行 try 主体
  ↓
按声明顺序的逆序关闭资源
  ↓
决定最终传播哪个异常

资源会在离开 try 主体时关闭,包括主体执行 return 或抛出异常的情况。

3. 多个资源按逆序关闭

try (var input = Files.newInputStream(source);
     var output = Files.newOutputStream(target)) {
    input.transferTo(output);
}

关闭顺序为:

先关闭 output
再关闭 input

原因是后声明的资源通常依赖先声明的资源,逆序关闭可以先释放更内层的依赖。

如果资源创建过程如下:

try (Resource a = openA();
     Resource b = openB();
     Resource c = openC()) {
    use(a, b, c);
}

openC() 失败:

  1. a 已创建;
  2. b 已创建;
  3. c 创建失败;
  4. 已创建的 b 关闭;
  5. 已创建的 a 关闭;
  6. 原始创建失败继续传播,关闭失败作为被抑制异常附加。

4. 主体异常与关闭异常的优先级

下面的完整示例使用自定义资源展示规则:

public class SuppressedDemo {
    static final class FailingResource implements AutoCloseable {
        @Override
        public void close() {
            throw new IllegalStateException("close failed");
        }

        void use() {
            throw new IllegalArgumentException("use failed");
        }
    }

    public static void main(String[] args) {
        try (var resource = new FailingResource()) {
            resource.use();
        } catch (Exception e) {
            System.out.println("primary: " + e.getMessage());

            for (Throwable suppressed : e.getSuppressed()) {
                System.out.println("suppressed: " + suppressed.getMessage());
            }
        }
    }
}

预期输出类似:

primary: use failed
suppressed: close failed

控制流可以逐步写成:

  1. 创建 FailingResource
  2. 执行 use(),产生 IllegalArgumentException
  3. 开始关闭资源;
  4. close() 产生 IllegalStateException
  5. 因为主体已经有主异常,关闭异常被加入主异常的 suppressed 列表;
  6. 主异常继续传播。

如果主体没有异常:

try (var resource = new FailingResource()) {
    // 正常结束
}

此时 close() 抛出的异常就是最终传播的异常。

5. try-with-resources 的等价结构

概念上,编译器会把资源管理转换为类似下面的结构:

Resource resource = acquire();
Throwable primary = null;

try {
    use(resource);
} catch (Throwable t) {
    primary = t;
    throw t;
} finally {
    if (resource != null) {
        if (primary != null) {
            try {
                resource.close();
            } catch (Throwable closeFailure) {
                primary.addSuppressed(closeFailure);
            }
        } else {
            resource.close();
        }
    }
}

这不是精确的编译器输出,但能说明核心规则:

  • 资源必须关闭;
  • 主体异常优先;
  • 关闭异常不会被静默丢弃;
  • 关闭异常会附加到主异常的 suppressed 列表。

6. Java 9 起可使用有效最终变量

资源不一定必须在括号内声明。只要变量是 final 或 effectively final,就可以使用:

var input = Files.newInputStream(Path.of("input.txt"));

try (input) {
    input.transferTo(System.out);
}

inputtry 之前没有再次赋值,因此是有效最终变量。

如果之后重新赋值,则不能作为资源:

var input = Files.newInputStream(Path.of("input.txt"));
input = Files.newInputStream(Path.of("other.txt"));

try (input) { // 编译错误
}

7. close() 是否幂等是资源实现的契约

try-with-resources 会负责一次关闭,但程序中也可能存在其他关闭路径。某些资源的 close() 重复调用是安全的,某些资源则可能抛出异常或产生状态问题。

因此,自定义 AutoCloseable 时应明确:

  • close() 是否允许重复调用;
  • 关闭后其他方法的行为;
  • close() 可能抛出哪类异常;
  • 关闭失败是否意味着资源状态未知。

六、finally 与 try-with-resources 的选择边界

旧式资源代码通常写成:

var input = Files.newInputStream(path);
try {
    return input.read();
} finally {
    input.close();
}

它至少存在两个问题:

  1. close() 的受检异常可能覆盖主体中的异常;
  2. 多个资源会产生复杂的嵌套 try/finally,容易漏关或覆盖异常。

try-with-resources:

try (var input = Files.newInputStream(path)) {
    return input.read();
}

表达的是更精确的资源协议:主体失败是主失败,关闭失败作为 suppressed exception 保留。

但这不代表 finally 没有用途。它适合不属于 AutoCloseable 生命周期的动作,例如:

boolean previous = transactionActive();
try {
    setTransactionActive(true);
    execute();
} finally {
    setTransactionActive(previous);
}

这里恢复线程上下文或状态,不一定对应一个需要 close() 的资源。


七、I/O 与 NIO 中的异常边界

1. 路径检查不能替代实际操作

下面的代码存在竞态:

if (Files.exists(path)) {
    return Files.readString(path);
}

exists 返回 truereadString 执行之间,文件可能被删除、权限可能改变、符号链接目标可能变化。因此,真正的操作仍然必须处理 IOException

更直接的方式是:

static String readRequired(Path path) throws IOException {
    try {
        return Files.readString(path);
    } catch (java.nio.file.NoSuchFileException e) {
        throw new IOException("required file is missing: " + path, e);
    }
}

这里将更具体的异常转成当前方法的契约,仍然保留原始原因。

2. Stream、Channel、Buffer 的失败位置不同

Java I/O 的异常可能发生在多个阶段:

获取资源
  ├── 打开文件 / 建立连接:可能失败
  ├── 读取或写入:可能失败
  ├── flush:可能失败
  └── close:可能失败

使用 OutputStream 时,write() 成功不必然表示数据已经到达最终介质;缓冲层可能还未 flush()。因此 flush()close() 也是潜在失败点。

使用 Channel 时,read()write() 返回值还表达了数据流状态:

  • 正数:实际读写的字节数;
  • 0:本次操作没有进展,非阻塞场景尤其常见;
  • -1:输入端到达末尾,通常表示 EOF。

异常与返回值是两套不同的信息:

int count = channel.read(buffer);

if (count == -1) {
    // EOF,不是 IOException
}

不能把 -1 当作异常,也不能因为没有异常就认为一定读到了数据。

3. Buffer 的状态错误通常是运行时异常

ByteBufferpositionlimitcapacity 共同决定可读写区域。典型流程是:

ByteBuffer buffer = ByteBuffer.allocate(1024);

channel.read(buffer); // 写入模式:position 增加
buffer.flip();        // 切换到读取模式:limit = 旧 position,position = 0

while (buffer.hasRemaining()) {
    channel.write(buffer);
}

如果遗漏 flip(),写入操作留下的 position 不会自动变成读取边界,可能导致没有数据可写或读到错误区域。这类错误通常表现为 BufferUnderflowExceptionBufferOverflowException 或逻辑错误,而不是受检 I/O 异常。

因此,API 契约不仅包括 throws,还包括状态转换前置条件:

  • 谁负责调用 flip()
  • 方法返回后 buffer 处于读模式还是写模式;
  • 是否允许部分读写;
  • 发生异常后资源和 buffer 是否仍可复用。

八、异常转换:跨层传播时保持抽象边界

1. 低层异常不应无条件泄漏到领域层

假设业务层直接暴露:

User loadUser(Path path) throws IOException

这会把文件系统细节写入业务 API。将来数据源改成数据库或远程服务时,调用者可能被迫理解多个基础设施异常。

可以在边界处转换:

class UserLoadException extends RuntimeException {
    UserLoadException(String message, Throwable cause) {
        super(message, cause);
    }
}

static User loadUser(Path path) {
    try {
        String json = Files.readString(path);
        return parseUser(json);
    } catch (IOException e) {
        throw new UserLoadException("读取用户资料失败: " + path, e);
    } catch (ParseException e) {
        throw new UserLoadException("解析用户资料失败: " + path, e);
    }
}

转换需要满足两个条件:

  1. 新异常表达当前层能理解的失败语义;
  2. 原始异常通过 cause 保留,便于诊断。

2. 不要把所有异常都包装成同一种异常

下面的代码会破坏调用者区分故障的能力:

try {
    return Files.readString(path);
} catch (Exception e) {
    throw new UserLoadException("failed", e);
}

它会把编程错误、线程中断、严重错误等也混在一起。通常应只捕获能够合理处理或转换的类型:

catch (IOException e) {
    // 转换为存储层异常
}

对于 InterruptedException,一般不能简单包装后继续执行。应恢复中断标志:

try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new UserLoadException("线程被中断", e);
}

InterruptedException 表示协作式取消信号。捕获后如果不恢复中断状态,上层的取消逻辑可能无法观察到该信号。


九、API 契约:异常是接口的一部分

1. 受检异常是编译器可验证的契约

下面的 API 明确要求调用者处理存储失败:

interface UserStore {
    User find(String id) throws UserStoreException;
}

调用者必须:

try {
    User user = store.find("u-1");
} catch (UserStoreException e) {
    // 选择重试、返回错误响应或终止当前操作
}

这种契约适用于调用者能够采取有意义行动的失败,例如:

  • 文件不存在;
  • 外部服务暂时不可用;
  • 输入格式不符合协议;
  • 事务提交失败;
  • 权限不足。

如果调用者只能在每一层机械地记录并重新抛出,受检异常会增加样板代码,却没有增加决策价值。

2. 非受检异常仍然必须记录在 API 文档中

IllegalArgumentException 不要求调用者捕获,但方法仍然应说明参数条件:

/**
 * @param timeoutMillis 必须大于等于 0
 * @throws IllegalArgumentException 当 timeoutMillis 小于 0 时
 */
void setTimeout(long timeoutMillis);

非受检异常也属于行为契约,只是编译器不强制调用者处理。

3. 重写方法不能扩大受检异常范围

interface Reader {
    String read() throws IOException;
}

class FileReader implements Reader {
    @Override
    public String read() throws FileNotFoundException {
        return "...";
    }
}

这是合法的,因为 FileNotFoundExceptionIOException 的子类。

下面不合法:

class NetworkReader implements Reader {
    @Override
    public String read() throws SQLException {
        return "...";
    }
}

SQLException 不是 IOException 的子类,重写方法扩大了受检异常范围。否则调用者只根据 Reader 类型编写代码时,编译器无法保证其处理完整。

重写方法可以:

  • 不声明受检异常;
  • 声明更具体的受检异常;
  • 声明父方法已允许的受检异常;
  • 任意抛出非受检异常。

这体现了可替换性:调用者按照父类型契约使用对象,子类型不能要求调用者额外处理新的受检异常。

4. 异常类型应表达可分类的语义

不推荐让调用者通过消息判断:

if (e.getMessage().contains("timeout")) {
    retry();
}

消息可能变化、国际化或为空。更稳定的方式是使用类型或结构化字段:

class RemoteCallException extends Exception {
    private final boolean retryable;

    RemoteCallException(String message, boolean retryable, Throwable cause) {
        super(message, cause);
        this.retryable = retryable;
    }

    boolean isRetryable() {
        return retryable;
    }
}

调用者基于 isRetryable() 做决策,而不是解析日志文本。


十、异常策略:处理、传播、转换与恢复

一个方法面对异常时,通常有四种不同动作。

1. 处理

方法能够完成恢复或给出替代结果:

static String readOptional(Path path) {
    try {
        return Files.readString(path);
    } catch (NoSuchFileException e) {
        return "";
    } catch (IOException e) {
        throw new UncheckedIOException("读取失败", e);
    }
}

这里仅将“文件不存在”定义为空内容;其他 I/O 失败仍然传播。

2. 传播

当前层没有足够上下文决定如何处理:

static byte[] download(URI uri) throws IOException {
    // 让上层决定 HTTP 响应、重试或降级策略
    return ...;
}

3. 转换

底层类型不应成为当前抽象的公开契约:

catch (IOException e) {
    throw new DocumentLoadException("文档加载失败", e);
}

4. 恢复

恢复必须保证业务状态仍然一致。下面的重试不能只根据“捕获了异常”决定:

for (int attempt = 1; attempt <= 3; attempt++) {
    try {
        return remoteCall();
    } catch (RemoteCallException e) {
        if (!e.isRetryable() || attempt == 3) {
            throw e;
        }
    }
}
throw new AssertionError("unreachable");

只有明确知道操作可重试时才能重试。对扣款、创建订单等非幂等操作,网络超时并不代表服务端没有执行成功,盲目重试可能导致重复副作用。


十一、异常与并发:失败可能被包装或延迟观察

1. Future.get() 会包装任务异常

ExecutorService executor = Executors.newSingleThreadExecutor();

try {
    Future<String> future = executor.submit(() -> {
        throw new IOException("read failed");
    });

    future.get();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new IllegalStateException("等待任务时被中断", e);
} catch (ExecutionException e) {
    Throwable cause = e.getCause();
    // cause 通常是任务实际抛出的 IOException
    throw new IllegalStateException("异步任务失败", cause);
} finally {
    executor.shutdown();
}

这里调用 submit 的线程不会立即看到任务中的 IOException。异常被保存到 Future,直到 get() 时以 ExecutionException 形式报告。

因此诊断时要区分:

ExecutionException       —— Future API 的包装异常
    └── cause             —— 异步任务实际失败

2. CompletableFuture 也可能包装异常

在异步链中:

CompletableFuture
    .supplyAsync(this::load)
    .thenApply(this::parse)
    .exceptionally(error -> {
        // error 可能是 CompletionException
        Throwable cause = error.getCause() != null
                ? error.getCause()
                : error;
        return fallback(cause);
    });

具体包装形式取决于观察 API 和失败发生的位置。公共错误处理代码通常需要沿 cause 链检查真正原因,但不能无条件剥掉所有包装,否则可能丢失异步阶段的语义。

3. 中断不是普通业务异常

处理中断时应保留中断状态:

try {
    queue.take();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    return;
}

如果直接:

catch (InterruptedException e) {
    log.warn("interrupted", e);
}

线程的中断标志已经被 InterruptedException 清除。上层可能因此继续等待、无法停止或无法取消任务。

并发代码中的异常还可能发生在线程边界之外:

  • 工作线程异常不会自动抛到提交线程;
  • 线程池任务可能只在 Future.get() 时暴露;
  • 未捕获异常可能由线程的 UncaughtExceptionHandler 处理;
  • 取消可能表现为 CancellationExceptionInterruptedException,不能统一当作普通失败。

十二、日志、堆栈和异常信息

1. 记录异常对象,而不是只记录消息

推荐:

logger.error("加载订单 {} 失败", orderId, e);

不推荐:

logger.error("加载订单失败: {}", e.getMessage());

前者保留类型、堆栈、原因链和 suppressed exceptions;后者通常只留下不可检索的文本。

2. 不要在每层重复打印同一个异常

如果底层已经记录并向上抛出,上层再次完整打印,可能产生重复堆栈。常见策略是:

  • 在最接近能够采取措施的边界记录;
  • 中间层转换异常时只添加上下文并保留 cause;
  • API 边界向客户端返回安全、稳定的错误结构;
  • 不把内部路径、SQL、凭据或网络细节暴露给外部用户。

3. 异常消息中的数据也有风险

文件路径、用户输入、远程响应等内容可能包含敏感信息或控制字符。消息应提供诊断上下文,但要避免泄露:

  • 访问令牌;
  • 密码;
  • 完整连接字符串;
  • 未脱敏的个人数据;
  • 内部拓扑和文件系统细节。

十三、测试异常契约

异常行为是 API 行为的一部分,应当被测试,而不是只测试成功路径。

1. JUnit 5 测试受检异常

import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertSame;

import java.io.IOException;
import org.junit.jupiter.api.Test;

class DocumentServiceTest {
    @Test
    void preservesCauseWhenTranslatingIOException() {
        DocumentLoadException error = assertThrows(
                DocumentLoadException.class,
                () -> service.load(Path.of("missing.txt")));

        assertInstanceOf(IOException.class, error.getCause());
    }

    private final DocumentService service = new DocumentService();
}

测试重点不是只断言“抛了异常”,还应断言:

  • 异常类型;
  • 消息中是否包含必要上下文;
  • getCause() 是否保留底层异常;
  • suppressed 异常是否存在;
  • 中断标志是否恢复;
  • 资源是否确实关闭。

2. 验证 suppressed exception

@Test
void recordsCloseFailureAsSuppressed() {
    Exception error = assertThrows(
            Exception.class,
            () -> useFailingResource());

    assertEquals("use failed", error.getMessage());
    assertEquals(1, error.getSuppressed().length);
    assertEquals("close failed",
            error.getSuppressed()[0].getMessage());
}

这个测试能验证代码确实使用了 try-with-resources 的异常优先级,而不是错误地让关闭异常覆盖主体异常。

3. Mockito 适合验证关闭交互,但不能替代真实 I/O

@Test
void closesReader() throws Exception {
    Reader reader = mock(Reader.class);
    when(reader.read()).thenReturn(-1);

    // 调用使用 reader 的组件

    verify(reader).close();
}

这能验证生命周期交互,但不能证明真实文件、权限、EOF、部分读写和关闭失败行为。涉及文件系统边界时,临时目录测试通常更有价值;涉及数据库或网络协议时,可使用 Testcontainers 等真实依赖环境验证异常边界。

4. 不要让测试只断言消息文本

消息文本可以断言关键上下文,但不应成为唯一契约:

assertEquals("missing document", error.getMessage());

更稳定的断言通常是:

assertInstanceOf(DocumentLoadException.class, error);
assertInstanceOf(NoSuchFileException.class, error.getCause());

如果消息属于对外协议的一部分,例如 REST 错误码描述,则应将其作为明确契约设计,而不是偶然依赖异常默认消息。


十四、常见错误及其失败表现

错误一:捕获后什么都不做

try {
    Files.readString(path);
} catch (IOException ignored) {
}

失败表现是:

  • 调用者误以为操作成功;
  • 状态可能只完成了一半;
  • 真正故障没有日志和指标;
  • 后续错误远离根因。

只有在异常确实代表“可安全忽略”的情形下,空处理才合理,而且应通过变量名、注释或结构让这个决定可审查。

错误二:重新抛出时丢失 cause

catch (IOException e) {
    throw new ServiceException("service failed");
}

失败表现是堆栈只显示业务异常,无法定位是权限、文件不存在、连接关闭还是磁盘故障。

错误三:用 ExceptionThrowable 作为日常控制结构

try {
    execute();
} catch (Exception e) {
    return fallback();
}

这可能把参数错误、编程错误、线程中断和真正可恢复的外部失败混为一谈。应优先捕获最窄的、确实能处理的类型。

错误四:手写资源关闭逻辑覆盖主异常

Exception primary = null;
try {
    use();
} catch (Exception e) {
    primary = e;
    throw e;
} finally {
    close(); // 可能覆盖 primary
}

除非有非常明确的特殊需求,否则应改用 try-with-resources。

错误五:捕获中断后继续执行

catch (InterruptedException e) {
    log.info("interrupted");
}
continueWork();

这会破坏取消协议。若当前方法不能继续,应恢复中断并返回或抛出;若确实能完成清理,也应在清理后保持中断语义。

错误六:以为 finally 一定执行

finally 不能保护进程被终止、JVM 崩溃、宿主机断电或线程永远阻塞的情况。需要跨进程可靠性的操作不能只依赖内存中的清理代码,还需要事务、日志、恢复协议或外部协调机制。


十五、建立异常 API 的推导方法

设计一个方法的异常契约时,可以按以下顺序推导,而不是先决定“全部用 checked”或“全部用 unchecked”。

第一步:列出可能失败的操作

例如:

解析输入
读取文件
调用远程服务
写入数据库
提交事务
关闭资源

第二步:区分失败是否可由调用者采取行动

输入非法          → 返回参数错误或抛出明确的参数异常
文件不存在        → 调用者可能选择创建、降级或返回 404
远程超时          → 可能重试,但要考虑幂等性
JVM 内存耗尽      → 通常不是当前方法可恢复的业务失败

第三步:确定抽象层次

底层:

throws IOException

存储层:

throws UserStoreException

HTTP 边界:

转为稳定的错误码和响应体

每经过一层,异常类型可以变化,但 cause 应保留,除非原始信息确实不能安全保留或属于敏感数据。

第四步:定义主异常和附加异常的优先级

对于资源型操作,应遵守:

主体失败 + 关闭失败
    → 主体异常传播,关闭异常 suppressed

主体成功 + 关闭失败
    → 关闭异常传播

对于批量操作,则可能需要显式设计多个失败结果,而不是只抛出第一个异常:

record ItemFailure(String id, Throwable error) {}

异常适合表示当前操作无法按其契约完成;如果业务本身允许部分成功,结果类型通常比异常更能表达完整状态。

第五步:定义测试可观察行为

至少应明确:

  • 抛出哪一种异常;
  • 是否保留 cause;
  • 是否保留 suppressed;
  • 是否关闭资源;
  • 是否恢复中断;
  • 哪些失败可重试;
  • 哪些消息或错误码对外稳定。

十六、一个完整的分层示例

下面展示文件读取、异常转换、资源管理和测试关注点如何组合。

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

record User(String id, String name) {}

class UserDocumentException extends Exception {
    UserDocumentException(String message, Throwable cause) {
        super(message, cause);
    }
}

class UserDocumentStore {
    User load(Path path) throws UserDocumentException {
        final String content;

        try (var reader = Files.newBufferedReader(path)) {
            content = reader.readLine();
        } catch (IOException e) {
            throw new UserDocumentException(
                    "无法读取用户文档: " + path, e);
        }

        if (content == null || content.isBlank()) {
            throw new UserDocumentException(
                    "用户文档为空: " + path, null);
        }

        String[] fields = content.split(",", -1);
        if (fields.length != 2) {
            throw new UserDocumentException(
                    "用户文档格式错误: " + path, null);
        }

        return new User(fields[0], fields[1]);
    }
}

其错误路径如下:

newBufferedReader 失败
    → IOException
    → UserDocumentException(cause = IOException)

readLine 返回 null
    → UserDocumentException
    → 没有底层 cause,因为这是业务校验失败

readLine 成功但格式错误
    → UserDocumentException

这个设计区分了两类失败:

  • I/O 失败:由 IOException 提供底层原因;
  • 文档内容不符合业务格式:本层直接生成领域异常。

如果读取主体失败、关闭也失败,try-with-resources 会把关闭异常放入原始 IOException 的 suppressed 列表,然后该 IOException 作为 UserDocumentException 的 cause 保存下来,关系仍然完整:

UserDocumentException
└── cause: IOException
    └── suppressed: close failure

十七、规范保证、实现行为与经验建议的边界

以下内容属于 Java 语言或核心库契约,可以依赖:

  • 受检异常必须被捕获或声明;
  • catch 按源代码顺序匹配;
  • 重写方法不能扩大受检异常范围;
  • try-with-resources 按逆序关闭资源;
  • 主体异常优先传播,关闭异常进入 suppressed 列表;
  • Throwable 提供 cause 和 suppressed exception 机制;
  • InterruptedException 的处理会影响线程中断协议。

以下内容不要当作语言保证:

  • 堆栈信息一定输出到哪个日志系统;
  • 未捕获异常一定导致整个进程退出;
  • 某个具体资源实现一定允许重复 close()
  • 某个文件系统操作在所有平台上具有相同的错误类型;
  • 某个网络异常一定表示请求未在远端执行。

经验上,异常处理应围绕“谁有能力决定下一步”来分层:

  • 有能力恢复,就处理;
  • 没有能力恢复,就传播;
  • 需要改变抽象,就转换并保留 cause;
  • 需要清理资源,就使用 try-with-resources;
  • 需要对外稳定表达,就使用明确的异常类型、错误码或结果结构;
  • 需要支持取消,就正确处理并恢复中断状态。

异常处理的目标不是让代码“永不抛异常”,而是在失败发生时保持三个信息不丢失:失败是什么、为什么失败、失败后资源和状态处于什么状态。当受检异常表达清晰的调用责任,错误链保留因果关系,资源关闭遵守主异常优先规则,API 契约便能同时服务于编译器、调用者、测试和生产诊断。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。