跳到内容

第 1 卷 / 第 11 章 / 第 2 课

11.2 异常处理实战 ​

这是异常机制之后的实战篇。用约 50 分钟跑完 Java 17 示例,你会安排 catch 顺序、用 try-with-resources 管理文件,并设计能保留 cause 与结构化上下文的领域异常。

config.txt 缺失之后 ​

启动程序时,config.txt 不见了。旧代码用一个很大的 catch (Exception) 接住所有错误,然后打印“使用默认配置”。文件不存在时这个策略似乎能工作;文件存在但权限不足、内容损坏或代码出现空指针时,它也会悄悄使用默认值。实际故障被藏起来了。

同一批旧代码还要读取 map.txt,内容是 北区,150,200;客户提交订单时可能余额不足;过期任务要报告 ExpiredTaskException。这些失败属于不同边界,处理方式也不同:能恢复的就地恢复,不能恢复的保留 cause 后传播,资源交给 try-with-resources 关闭。

1. catch 从具体排到通用 ​

一个 try 可能抛出多种异常。JVM 按源码顺序选择第一个类型兼容的 catch。下面的方法只在配置文件缺失时返回 8080,其他读取或格式错误都会向上传播;本例把合法端口限定为 1 到 65535。相关类型需从 java.io、java.nio.file 和 java.nio.charset 导入:

java
static int loadPort(Path path) {
    try {
        String input = Files.readString(path, StandardCharsets.UTF_8).strip();
        int port = Integer.parseInt(input);
        if (port < 1 || port > 65535) {
            throw new IllegalArgumentException("port must be in 1..65535");
        }
        return port;
    } catch (NoSuchFileException exception) {
        return 8080;
    } catch (NumberFormatException exception) {
        throw new IllegalArgumentException("port is not an integer", exception);
    } catch (IOException exception) {
        throw new UncheckedIOException("cannot read port configuration", exception);
    }
}

NoSuchFileException 是 IOException 的子类,所以子类分支在前。父类放在前面会让子类分支永远不可达,编译器直接拒绝。

catch (Exception) 不是语法错误。它适合少数明确的顶层边界,例如任务执行器要把一次任务标记为失败,随后结束该任务。底层 readConfig 若这么写,会把解析错误、程序缺陷和 I/O 失败混成一句话。

1.1 multi-catch ​

处理动作完全相同时,可以合并互不继承的异常类型:

java
catch (NumberFormatException | DateTimeParseException exception) {
    reportInvalidConfig(exception);
}

备选类型不能有父子关系。NoSuchFileException | IOException 无法编译,因为 IOException 已经包含前者。multi-catch 参数也被视为 final,不能在 catch 内重新赋值。

1.2 catch 住之后做什么 ​

合理的 catch 通常完成一种动作:

  • 恢复:缺少可选配置时使用明确的默认值。
  • 转换:把 IOException 转成调用方理解的 MapLoadException,并保留 cause。
  • 结束当前边界:请求入口记录一次失败,返回错误响应。

只打印一句话再继续,往往既没有恢复,也没有传播。生产日志还需要结构化上下文、级别和统一的记录位置;printStackTrace() 适合临时排查,不是完整的日志策略。

2. 读取 map.txt:资源由谁关闭 ​

旧 helper 直接返回 new BufferedReader(...).readLine()。它没有关闭 Reader,资源可能长期保持打开;不能把 GC 当成及时关闭文件描述符的协议。

现代 Path/Files API 能把路径和编码写清楚:

java
static String readMap(Path path) throws IOException {
    try (BufferedReader reader = Files.newBufferedReader(
            path, StandardCharsets.UTF_8)) {
        return reader.readLine();
    }
}

try 括号中的对象必须实现 AutoCloseable。控制流离开 try 时,Java 对已成功取得的非 null 资源调用 close。若初始化后一个资源就失败,前面已取得的资源仍会关闭;未成功取得的资源不在关闭列表里。这适用于正常返回和异常传播。

2.1 多个资源按逆序关闭 ​

java
try (BufferedReader reader = Files.newBufferedReader(input, UTF_8);
     BufferedWriter writer = Files.newBufferedWriter(output, UTF_8)) {
    String line = reader.readLine();
    if (line != null) writer.write(line);
}

这个片段只复制第一行,空文件产生空输出;input 与 output 必须是不同文件,否则打开 writer 时就可能截断输入。资源按声明的逆序关闭:先 writer,后 reader。这与嵌套构造的所有权方向一致。后声明的资源往往依赖先声明的资源,应先释放。

Java 9+ 还允许把已经声明、保持 final 或 effectively final 的资源放进括号:

java
BufferedReader reader = Files.newBufferedReader(path, UTF_8);
try (reader) {
    System.out.println(reader.readLine());
}

try 结束后对象已关闭,不要继续读取。

2.2 工作和关闭都失败 ​

假设读取抛出 IOException("读取失败"),随后两个资源的 close 也失败。Java 保留读取异常作为主异常,把关闭异常放进 getSuppressed()。这样调用方不会因为清理失败而失去最初触发栈展开的原因。

手写 finally 很容易反过来:close 抛出的新异常会覆盖读取异常。try-with-resources 已经定义好主次关系,资源代码应优先使用它。

3. 自定义异常表达领域失败 ​

订单需要 50 分,账户里只有 30 分。这里用整数最小货币单位表达固定的小金额,只演示异常契约。IOException、IllegalStateException 都不能直接说明业务上下文。自定义异常可以把所需金额和现有金额作为字段保留下来:

java
final class InsufficientFundsException extends Exception {
    private static final long serialVersionUID = 1L;

    private final int required;
    private final int available;

    InsufficientFundsException(int required, int available) {
        super("需要 " + required + " 分,但账户里只有 " + available);
        this.required = required;
        this.available = available;
    }

    int required() { return required; }
    int available() { return available; }
}

调用方可以显示 message,也能用字段决定“还差多少”。不要让业务逻辑从异常文本里解析数字,文本会被翻译和调整。

ExpiredTaskException 需要区分两种语义。如果调用者违反了“任务必须处于可领取状态”的前置条件,RuntimeException 可以表达调用错误;如果正常业务允许任务在确认时过期,返回一个明确的失败结果或 checked exception 可能更便于调用方展示恢复选项。先写调用方要做的事,再选继承树。

3.1 转换时保留 cause ​

购买模块不想让界面直接依赖 InsufficientFundsException,可以转换为 PurchaseException:

java
try {
    placeOrder(cost, balance);
} catch (InsufficientFundsException exception) {
    throw new PurchaseException("购买失败", exception);
}

构造器要调用 super(message, cause)。若只传 message,监控和堆栈只能看到“购买失败”,看不到具体的余额不足对象。

异常类不必为每个方法各造一个。名称应表达调用方会采取的处理边界,并携带安全、稳定的上下文。类名以 Exception 结尾是 Java 社区的常见约定,不是编译器要求。

4. 完整实战:区域数据、订单和资源关闭 ​

先准备临时目录;程序内部还会再创建并清理自己的 I/O 临时目录:

bash
chapter11_practice_root=$(mktemp -d)
readonly chapter11_practice_root
mkdir -p "$chapter11_practice_root/out"
cd "$chapter11_practice_root"

保存为 ExceptionPracticeDemo.java:

java
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public class ExceptionPracticeDemo {
    public static void main(String[] args) throws Exception {
        Path directory = Files.createTempDirectory("ch11-practice-");
        Path mapFile = directory.resolve("map.txt");

        try (TemporaryFiles temporary = new TemporaryFiles(mapFile, directory)) {
            Files.writeString(
                    temporary.file(), "北区,150,200\n", StandardCharsets.UTF_8);

            String mapData;
            try (BufferedReader reader = Files.newBufferedReader(
                    mapFile, StandardCharsets.UTF_8)) {
                mapData = reader.readLine();
            }
            checkEquals("北区,150,200", mapData);
            System.out.println("区域数据:" + mapData);

            PurchaseException purchaseFailure = expect(
                    PurchaseException.class,
                    () -> completePurchase(50, 30));
            check(purchaseFailure.getCause() instanceof InsufficientFundsException,
                    "购买异常必须保留余额不足的 cause");
            InsufficientFundsException balanceFailure =
                    (InsufficientFundsException) purchaseFailure.getCause();
            checkEquals(50, balanceFailure.required());
            checkEquals(30, balanceFailure.available());
            System.out.println("购买失败:" + balanceFailure.getMessage());

            List<String> closeOrder = new ArrayList<>();
            IOException primaryFailure;
            try (CloseProbe first = new CloseProbe("first", closeOrder);
                 CloseProbe second = new CloseProbe("second", closeOrder)) {
                check(first != second, "关闭探针必须是两个对象");
                throw new IOException("读取失败");
            } catch (IOException exception) {
                primaryFailure = exception;
            }
            checkEquals(List.of("second", "first"), closeOrder);
            checkEquals("读取失败", primaryFailure.getMessage());
            checkEquals(2, primaryFailure.getSuppressed().length);
            checkEquals("关闭 second 失败",
                    primaryFailure.getSuppressed()[0].getMessage());
            checkEquals("关闭 first 失败",
                    primaryFailure.getSuppressed()[1].getMessage());
            System.out.println("关闭顺序:" + closeOrder);
            System.out.println("主异常:" + primaryFailure.getMessage()
                    + ",suppressed=" + primaryFailure.getSuppressed().length);
        }

        check(!Files.exists(mapFile) && !Files.exists(directory),
                "临时文件必须清理");
        System.out.println("异常实战检查通过");
    }

    static void completePurchase(int cost, int balance) {
        try {
            placeOrder(cost, balance);
        } catch (InsufficientFundsException exception) {
            throw new PurchaseException("购买失败", exception);
        }
    }

    static void placeOrder(int cost, int balance)
            throws InsufficientFundsException {
        if (cost <= 0) {
            throw new IllegalArgumentException("cost 必须为正数");
        }
        if (balance < 0) {
            throw new IllegalArgumentException("negative balance");
        }
        if (balance < cost) {
            throw new InsufficientFundsException(cost, balance);
        }
    }

    static final class InsufficientFundsException extends Exception {
        private static final long serialVersionUID = 1L;

        private final int required;
        private final int available;

        InsufficientFundsException(int required, int available) {
            super("需要 " + required + " 分,但账户里只有 " + available);
            this.required = required;
            this.available = available;
        }

        int required() {
            return required;
        }

        int available() {
            return available;
        }
    }

    static final class PurchaseException extends RuntimeException {
        private static final long serialVersionUID = 1L;

        PurchaseException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    static final class TemporaryFiles implements AutoCloseable {
        private final Path file;
        private final Path directory;

        TemporaryFiles(Path file, Path directory) {
            this.file = file;
            this.directory = directory;
        }

        Path file() { return file; }

        @Override
        public void close() throws IOException {
            IOException failure = null;
            for (Path path : List.of(file, directory)) {
                try {
                    Files.deleteIfExists(path);
                } catch (IOException exception) {
                    if (failure == null) failure = exception;
                    else failure.addSuppressed(exception);
                }
            }
            if (failure != null) throw failure;
        }
    }

    static final class CloseProbe implements AutoCloseable {
        private final String name;
        private final List<String> closeOrder;

        CloseProbe(String name, List<String> closeOrder) {
            this.name = name;
            this.closeOrder = closeOrder;
        }

        @Override
        public void close() throws IOException {
            closeOrder.add(name);
            throw new IOException("关闭 " + name + " 失败");
        }
    }

    static <T extends Throwable> T expect(Class<T> type, Runnable action) {
        try {
            action.run();
        } catch (Throwable throwable) {
            if (type.isInstance(throwable)) return type.cast(throwable);
            throw new AssertionError(
                    "expected=" + type + ", actual=" + throwable, throwable);
        }
        throw new AssertionError("expected exception=" + type.getName());
    }

    static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }

    static void checkEquals(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) {
            throw new AssertionError("expected=" + expected + ", actual=" + actual);
        }
    }
}

编译并运行:

bash
javac --release 17 -encoding UTF-8 -Xlint:all -d out ExceptionPracticeDemo.java
java -cp out ExceptionPracticeDemo

预期输出:

text
区域数据:北区,150,200
购买失败:需要 50 分,但账户里只有 30
关闭顺序:[second, first]
主异常:读取失败,suppressed=2
异常实战检查通过

保存需要的输出后清理外层编译目录:

bash
cd
ls -ld -- "$chapter11_practice_root"
rm -r -- "$chapter11_practice_root"

示例用临时目录隔离 I/O,不把机器路径打印进输出。TemporaryFiles 也参与 try-with-resources:它依次尝试删除文件和目录,保留清理失败;若正文已经失败,清理异常会附加为 suppressed,不会覆盖主异常。关闭完成后再断言路径不存在。main throws Exception 只用于这个自校验程序的顶层边界,业务入口应根据启动策略报告失败。

4.1 逐步核对 ​

  1. UTF-8 编码写在读写调用中,北区,150,200 不依赖平台默认编码。
  2. BufferedReader 在 try-with-resources 结束时关闭。
  3. InsufficientFundsException 保存数值字段,PurchaseException 保存它作为 cause。
  4. CloseProbe 按 second、first 的顺序关闭;正文异常仍是主异常,两次 close 失败进入 suppressed 列表。
  5. TemporaryFiles 管理测试夹具清理,并保留关闭失败;可靠业务写入仍需要持久化协议,资源关闭不等于事务提交。

5. finally 的反模式 ​

5.1 finally 中 return ​

java
static int hiddenResult() {
    try {
        return 1;
    } finally {
        return 2;
    }
}

调用方只能得到 2。try 中的返回值被覆盖;若 try 抛了异常,异常也会被 return 吞掉。不要在 finally 中写 return、break 或 continue。

5.2 finally 抛出新异常 ​

java
try {
    readMap();
} finally {
    closeMap(); // 若这里失败,可能覆盖 readMap 的失败
}

资源改用 try-with-resources。其他清理操作若会失败,要设计主异常与清理异常的报告关系,不能随手覆盖。

5.3 把业务提交押在 finally ​

finally 可能因进程终止而不执行,即使执行也不能撤回已经发出的远程调用。余额扣减、库存转移和文件替换需要事务、幂等操作、补偿或原子文件移动等机制。

6. 常见 catch 反模式 ​

空 catch ​

java
try {
    loadConfig();
} catch (IOException ignored) {
}

只有当失败确实可以忽略,并且变量名、注释或 API 契约解释了原因时,忽略才说得通。例如 Files.deleteIfExists 已把“不存在”设计成返回值,通常不需要空 catch。

log 后原样抛出 ​

java
catch (IOException exception) {
    logger.error("读取失败", exception);
    throw exception;
}

上层若也记录,会产生重复日志。选择一个拥有请求或任务结果的边界记录;中间层增加上下文时,包装并保留 cause。

用 Exception 控制普通循环 ​

java
try {
    while (true) consume(iterator.next());
} catch (NoSuchElementException end) {
    // 用异常表示正常结束
}

循环条件应使用 hasNext()。异常栈比普通分支昂贵,语义也不匹配。

吞掉 InterruptedException ​

java
try {
    queue.take();
} catch (InterruptedException exception) {
    Thread.currentThread().interrupt();
    throw new TaskCancelledException("任务被中断", exception);
}

能声明 throws InterruptedException 时直接传播。无法传播时恢复中断标志,再退出当前工作或转成带 cause 的边界异常。空 catch 会让线程池无法按中断协议停止任务。

7. 异常信息与日志边界 ​

有用的异常消息说明操作、对象和可公开的上下文:

text
读取地图配置失败:file=map.txt, tenant=west

不要写入访问令牌、密码、完整连接串或个人数据。异常 message 面向诊断,不应成为程序解析协议;机器要用的字段放进异常属性或结构化错误对象。

日志通常在这些边界出现:HTTP 请求入口、消息消费者、定时任务执行器、命令行 main。底层库负责抛出带 cause 的异常,让边界决定日志级别、用户响应和重试。

重试也不是 catch 后立刻再调用。先判断操作是否幂等,设置次数与退避,并区分永久错误和暂时错误。一次购买请求若已经扣款,盲目重试可能重复扣款。

8. 动手练习 ​

练习 A:完整区域数据加载器 ​

实现 MapLoader.load(Path):

  • 用 UTF-8 和 try-with-resources 读取第一行;
  • 格式必须是 name,x,y;
  • 文件缺失抛 MapNotFoundException,格式错误抛 InvalidMapFormatException;
  • 两个异常都保留原 cause 或原始安全字段;
  • 返回不可变 MapLocation(name, x, y)。

测试正常的 北区,150,200、缺少列、非数字坐标和不存在文件。测试异常类型与字段,不要断言整段 stack trace。

练习 B:购买与任务 ​

当前 placeOrder 只校验金额,没有扣款状态。进阶练习先为它增加账户余额与实际扣减,再添加请求 ID:限定在单进程、单线程和内存存续期间,同一请求重试返回原结果,同一 ID 换了金额应被拒绝;进程重启和并发幂等暂不包含在这个练习中。再实现 confirmTask:调用方应能区分“不存在”“已过期”和“前置任务未完成”。先设计返回/异常契约,再写类。

练习 C:关闭失败 ​

改造 CloseProbe:正文成功但 close 失败时,close 异常会成为主异常;正文与 close 都失败时,close 异常进入 suppressed。为两条路径分别写断言。

9. 沿着主异常再走一遍 ​

把完整示例中的“读取失败”暂时删掉,只让 CloseProbe.close() 失败:第一个关闭异常现在应成为主异常,后续关闭异常进入 suppressed。恢复正文失败后,主异常又应回到“读取失败”,两个关闭异常按 second、first 的顺序附加。两条路径都写断言,才能看清 try-with-resources 的主次规则。

再给中断示例加一个测试线程。在线程内部捕获 InterruptedException、恢复标志并转成 TaskCancelledException,再由外层测试捕获后断言 Thread.currentThread().isInterrupted() 为 true。若这项检查失败,线程池收到的取消信号已经在你的 catch 中丢失。

最后看一遍异常边界:底层方法提供类型、字段与 cause;请求、任务或命令行入口只记录一次;普通的“不存在”若是预期结果,就用返回类型表达。三层职责分开后,异常才不会变成跨模块的字符串协议。

查阅 Java 17 的正式规则 ​

下一步:泛型 ​

区域数据加载器、购买服务和任务系统都开始出现“同一套逻辑处理不同类型”的需求。下一章用类型参数收拢重复代码,同时保留编译期检查。

继续学习第 12 章:类型参数与泛型方法

Built with VitePress | Software Systems Atlas