Skip to content

异常体系与处理 ​

S1-021必学入门约 90 分钟JDK 21编程语言基础 · 常用类

1. 本节定位 ​

《读懂 Java 错误信息》 教了"怎么读异常",本节教"怎么设计异常":什么时候抛、抛什么类型、什么时候捕获、什么时候包装、绝不能做什么。

这是从"能跑"到"可维护"的分界线。异常处理写得好,线上问题能一眼定位;写得差,就是"出错了但什么也不知道"。

2. 学习目标 ​

完成本节后,你应该能够:

  • 画出异常层次,并说清 Error、受检异常、非受检异常的区别与用途;
  • 写出正确的 try-catch-finally,并知道 finally 的语义;
  • 用 try-with-resources 自动关闭资源;
  • 定义业务异常并携带上下文数据;
  • 用 (message, cause) 构造异常链,保留根因;
  • 说出异常处理的三条原则,并认出"吞异常""catch Exception""用异常做流程控制"这三类反模式。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

异常处理最容易走两个极端:

极端一:到处 try-catch,catch 里什么都不做。

java
try {
    processOrder(order);
} catch (Exception e) {
    // 什么都不做
}

后果:问题消失了,业务也坏了。订单没处理成功,日志里什么都没有,用户投诉时你只能猜。

极端二:throws Exception 一路往上甩。

java
public void handle() throws Exception { }

后果:调用方完全不知道要处理什么,只能同样一路抛上去,最终在 main 里崩掉。

正确的做法:异常是 API 的一部分——它告诉调用方"可能会出什么问题、你需要处理什么"。本节讲的就是怎么把这件事做好。

5. 核心概念 ​

5.1 异常层次 ​

text
Throwable
├── Error                     JVM 层面的严重问题,一般不捕获
│   ├── OutOfMemoryError
│   └── StackOverflowError
└── Exception
    ├── RuntimeException      ← 非受检异常(unchecked),编译器不强制处理
    │   ├── NullPointerException
    │   ├── IllegalArgumentException
    │   └── IllegalStateException
    └── 其他 Exception         ← 受检异常(checked),编译器强制处理
        ├── IOException
        └── SQLException
类别编译期检查典型代表用途
Error无OutOfMemoryErrorJVM 内部错误,不要捕获
受检异常强制IOException调用方有能力恢复的问题
非受检异常无IllegalArgumentException编程错误(参数不对、状态不对)

怎么选:受检还是非受检

判断标准是"调用方能不能合理地恢复"。

  • 能恢复(网络断了可以重试、文件不存在可以提示用户)→ 受检异常,强制调用方处理;
  • 不能恢复(参数传错了、状态不对,这是代码 bug)→ 非受检异常,让它往上抛直到被发现。

实践趋势:现代 Java 框架(Spring 等)大量使用非受检异常,因为受检异常会污染整条调用链的签名。

5.2 try-catch-finally 的执行顺序 ​

java
try {
    System.out.println("try 块执行");
    throw new IllegalStateException("演示异常");
} catch (IllegalStateException exception) {
    System.out.println("catch 块捕获: " + exception.getMessage());
} finally {
    System.out.println("finally 块总会执行");
}

执行顺序固定:try → catch(如有异常)→ finally(总是)。

finally 的语义:无论是否发生异常、是否 return、是否被 break,都会执行——唯一例外是 System.exit() 或 JVM 崩溃。

它的用途:释放资源(关文件、关连接、解锁)。但现代写法更推荐 try-with-resources。

5.3 try-with-resources:资源管理的正确姿势 ​

java
try (StringWriter writer = new StringWriter()) {
    writer.write("资源会被自动关闭");
    System.out.println(writer);
}

只要资源实现了 AutoCloseable,写在 try (...) 里就会自动关闭(无论正常结束还是抛异常)。多个资源用分号分隔,关闭顺序与声明顺序相反。

为什么它比 finally 好:

java
// 手动关闭:容易漏,且可能覆盖原始异常
FileReader reader = null;
try {
    reader = new FileReader(file);
    return read(reader);
} finally {
    if (reader != null) {
        reader.close();          // 如果这里抛异常,会覆盖 try 里的异常
    }
}

// try-with-resources:不用管,且内部处理了"异常覆盖"问题
try (FileReader reader = new FileReader(file)) {
    return read(reader);
}

Java 9 之后,如果资源是 final 或等效 final 的变量,可以直接写变量名:try (reader)。

5.4 自定义异常 ​

java
static class InsufficientBalanceException extends RuntimeException {
    private final long shortfallInCents;

    InsufficientBalanceException(String message, long shortfallInCents) {
        super(message);
        this.shortfallInCents = shortfallInCents;
    }

    long shortfallInCents() {
        return shortfallInCents;
    }
}

自定义异常的价值:除了消息,还能携带结构化的业务数据(这里是"还差多少钱"),让上层能做判断,而不是去解析消息字符串。

设计要点:

要点说明
继承哪个业务错误(不可恢复)→ RuntimeException;可恢复的外部问题 → Exception
命名以 Exception 结尾,如 InsufficientBalanceException
构造方法提供 (String message) 与 (String message, Throwable cause)
携带数据把关键上下文做成字段(如 orderId、shortfallInCents)

5.5 异常链:保留根因 ​

java
try {
    Integer.parseInt("abc");
} catch (NumberFormatException exception) {
    throw new IllegalStateException("参数解析失败", exception);     // 把原异常作为 cause
}

这样抛出后的信息里会包含完整的因果链:

text
包装后的异常: 参数解析失败
原因: NumberFormatException - For input string: "abc"

三个必须遵守的点:

  1. 包装时一定传 cause:new IllegalStateException("...", exception);
  2. 不要只传 message:new IllegalStateException(exception.getMessage()) 会丢掉类型与调用栈;
  3. 日志里传异常对象:log.error("处理失败", exception) 而不是 log.error("处理失败: " + exception.getMessage())。

5.6 异常处理的三条原则 ​

原则含义反例
早抛晚捕发现问题就抛(不要让错误值继续传);在有上下文能处理的地方捕返回 -1 表示错误,让调用方猜
保留原因包装时带上 cause吞掉原始异常
不要吞异常捕获后必须有动作(记日志、转换、上报)catch (Exception e) { }

5.7 finally 里的 return 会覆盖返回值 ​

java
static String misleadingMethod() {
    try {
        return "try 的返回值";
    } finally {
        return "finally 的返回值";      // 覆盖了上面的 return
    }
}

调用结果是 "finally 的返回值"——try 里的 return 被静默丢弃。

规则:finally 里不要写 return(也不要 throw),否则会掩盖真实结果或异常。IDE 会给出警告。

6. 递进示例 ​

6.1 最小示例:捕获与不捕获 ​

java
// 受检异常:不处理就编译不过
public void read() throws IOException {
    Files.readString(Path.of("a.txt"));
}

// 非受检异常:编译器不管,但要在逻辑上处理
if (amount <= 0) {
    throw new IllegalArgumentException("金额必须为正");
}

6.2 业务示例:账户取款 ​

java
void withdraw(long amountInCents) {
    if (amountInCents <= 0) {
        throw new IllegalArgumentException("取款金额必须大于 0");
    }
    if (amountInCents > balanceInCents) {
        throw new InsufficientBalanceException("余额不足", amountInCents - balanceInCents);
    }
    balanceInCents -= amountInCents;
}

三种情况对应三种处理:

情况异常说明
参数不合法(负数)IllegalArgumentException编程错误,JDK 现成的异常够用
业务规则不满足(余额不足)自定义异常 + 数据上层可能要展示"还差多少钱"
状态不对(账户已注销)IllegalStateException《读懂 Java 错误信息》 讲过两者的区别

注意"失败时状态不变":抛异常之前不能改状态,否则会出现"报错了但钱扣了"的事故。第 7 节的代码里有一行验证:失败后余额未变: 1000。

6.3 反例:三种反模式 ​

java
// 反模式一:吞异常
try {
    process();
} catch (Exception e) {
    // 什么都不做
}

// 反模式二:catch Exception(捕获过宽)
try {
    int value = Integer.parseInt(input);
} catch (Exception e) {          // 连 NullPointerException 都吞了
    return 0;
}

// 反模式三:用异常做流程控制
try {
    return map.get(key).intValue();
} catch (NullPointerException e) {
    return 0;                    // 用"抛异常"代替判空,性能差且掩盖问题
}

第三个反模式的正确写法:

java
return map.getOrDefault(key, 0);

判断标准:异常应该是"例外情况",不是"预料之中的分支"。如果某个分支经常发生,就应该用 if 判断,而不是靠异常。

7. 完整可运行代码 ​

文件:labs/common-classes/src/main/java/cn/kb/learn/common/ExceptionLesson.java

java
package cn.kb.learn.common;

import java.io.StringWriter;

public class ExceptionLesson {
    /** 自定义异常:除了消息,还可以携带业务数据 */
    static class InsufficientBalanceException extends RuntimeException {
        private static final long serialVersionUID = 1L;

        private final long shortfallInCents;

        InsufficientBalanceException(String message, long shortfallInCents) {
            super(message);
            this.shortfallInCents = shortfallInCents;
        }

        long shortfallInCents() {
            return shortfallInCents;
        }
    }

    static class Account {
        private long balanceInCents;

        Account(long balanceInCents) {
            this.balanceInCents = balanceInCents;
        }

        void withdraw(long amountInCents) {
            if (amountInCents <= 0) {
                throw new IllegalArgumentException("取款金额必须大于 0");
            }
            if (amountInCents > balanceInCents) {
                throw new InsufficientBalanceException("余额不足", amountInCents - balanceInCents);
            }
            balanceInCents -= amountInCents;
        }

        long balanceInCents() {
            return balanceInCents;
        }
    }

    public static void main(String[] args) {
        // 1. 异常层次
        System.out.println("RuntimeException 继承 Exception: "
                + (RuntimeException.class.getSuperclass() == Exception.class));
        System.out.println("Exception 继承 Throwable: "
                + (Exception.class.getSuperclass() == Throwable.class));

        // 2. try-catch-finally 的执行顺序
        try {
            System.out.println("try 块执行");
            throw new IllegalStateException("演示异常");
        } catch (IllegalStateException exception) {
            System.out.println("catch 块捕获: " + exception.getMessage());
        } finally {
            System.out.println("finally 块总会执行");
        }

        // 3. 自定义异常携带业务数据
        Account account = new Account(1_000);
        try {
            account.withdraw(5_000);
        } catch (InsufficientBalanceException exception) {
            System.out.println("业务异常: " + exception.getMessage()
                    + ",缺口 " + exception.shortfallInCents() + " 分");
        }
        System.out.println("失败后余额未变: " + account.balanceInCents());

        // 4. 异常链:包装时保留原因
        try {
            wrapAndThrow();
        } catch (IllegalStateException exception) {
            System.out.println("包装后的异常: " + exception.getMessage());
            System.out.println("原因: " + exception.getCause().getClass().getSimpleName()
                    + " - " + exception.getCause().getMessage());
        }

        // 5. try-with-resources:自动关闭
        try (StringWriter writer = new StringWriter()) {
            writer.write("资源会被自动关闭");
            System.out.println("try-with-resources: " + writer);
        } catch (Exception exception) {
            System.out.println("不会走到这里: " + exception.getMessage());
        }

        // 6. 多重 catch
        for (String input : new String[]{"42", "abc", null}) {
            try {
                String safe = input.trim();
                System.out.println("解析 " + safe + " -> " + Integer.parseInt(safe));
            } catch (NumberFormatException | NullPointerException exception) {
                System.out.println("解析 " + input + " 失败: " + exception.getClass().getSimpleName());
            }
        }

        // 注意:Integer.parseInt(null) 抛的是 NumberFormatException 而不是 NullPointerException
        try {
            Integer.parseInt(null);
        } catch (NumberFormatException exception) {
            System.out.println("parseInt(null) 抛的是: " + exception.getClass().getSimpleName()
                    + " - " + exception.getMessage());
        }

        // 7. finally 中的 return 会覆盖 try 中的 return
        System.out.println("finally 覆盖返回值: " + misleadingMethod());
    }

    static void wrapAndThrow() {
        try {
            Integer.parseInt("abc");
        } catch (NumberFormatException exception) {
            throw new IllegalStateException("参数解析失败", exception);
        }
    }

    /** 刻意演示:finally 中的 return 会覆盖 try 中的 return */
    @SuppressWarnings("finally")
    static String misleadingMethod() {
        try {
            return "try 的返回值";
        } finally {
            return "finally 的返回值";
        }
    }
}

运行命令:

powershell
cd labs
./mvnw -pl common-classes compile
java -cp common-classes/target/classes cn.kb.learn.common.ExceptionLesson

预期输出:

text
RuntimeException 继承 Exception: true
Exception 继承 Throwable: true
try 块执行
catch 块捕获: 演示异常
finally 块总会执行
业务异常: 余额不足,缺口 4000 分
失败后余额未变: 1000
包装后的异常: 参数解析失败
原因: NumberFormatException - For input string: "abc"
try-with-resources: 资源会被自动关闭
解析 42 -> 42
解析 abc 失败: NumberFormatException
解析 null 失败: NullPointerException
parseInt(null) 抛的是: NumberFormatException - Cannot parse null string
finally 覆盖返回值: finally 的返回值

四处值得停留:

输出含义
失败后余额未变: 1000抛异常前没有改状态,这是业务代码的硬要求
原因: NumberFormatException - ...异常链保留了根因
解析 null 失败: NullPointerExceptioninput.trim() 对 null 抛 NPE
parseInt(null) 抛的是: NumberFormatException反直觉:parseInt 对 null 抛的不是 NPE
finally 覆盖返回值: finally 的返回值try 里的 return 被静默丢弃

最后一行的 parseInt(null) 是个很好的例子:不要凭"应该抛什么"来写代码,要看实际行为——这也是为什么本知识库的每段代码都要实际运行。

8. Debug 观察点 ​

  1. 在 catch (InsufficientBalanceException exception) 处断点,展开 exception 观察它的字段(shortfallInCents)与 cause(这里是 null);
  2. 在 wrapAndThrow() 抛异常处断点,然后在 Frames 面板里从 wrapAndThrow 点回调用方,观察栈的层次——这就是"异常链之外,调用栈本身也在记录路径";
  3. 在 misleadingMethod() 的 try 里 return 处断点,按 F7/Step Over 观察执行流跳到 finally 而不是返回——finally 优先于 return 执行;
  4. 用异常断点(《Java 程序的运行与调试》)针对 IllegalStateException,观察它停在 throw 的那一行而不是 catch 处。

9. 常见错误 ​

错误 1:吞异常 ​

java
try {
    process();
} catch (Exception e) {
}

修复:至少要记日志,且要传异常对象:

java
} catch (Exception e) {
    log.error("处理订单失败,orderId={}", orderId, e);
    throw new IllegalStateException("订单处理失败", e);
}

错误 2:捕获过宽 ​

java
try {
    // ...
} catch (Exception e) { }

问题:连 NullPointerException、IllegalArgumentException 这些"代码 bug"也一起吞了。

修复:按需捕获具体异常类型;确实要兜底时,在捕获块里重新抛出(并带上原因)。

错误 3:包装时丢掉原因 ​

java
throw new IllegalStateException(exception.getMessage());     // 丢失类型与栈
throw new IllegalStateException("参数解析失败", exception);    // 正确

错误 4:finally 里 return 或 throw ​

见 5.7 节。它会掩盖 try 里的返回值或异常,让排查变得极其困难。

错误 5:用异常做流程控制 ​

java
// 不推荐:异常是"例外",不是"分支"
try {
    return list.get(0);
} catch (IndexOutOfBoundsException e) {
    return null;
}

// 推荐
return list.isEmpty() ? null : list.get(0);

理由:异常构造与栈填充的成本远高于 if 判断;而且它掩盖了"空列表是正常情况"这个事实。

10. 动手实验 ​

目标:设计一套业务异常并接入调用链。

步骤:

  1. 运行第 7 节代码,核对输出(重点看"失败后余额未变"与"finally 覆盖返回值");
  2. 设计 OrderException(业务异常)与 OrderNotFoundException(具体场景),要求携带 orderId;
  3. 写一个 OrderService:void cancel(String orderId),订单不存在抛 OrderNotFoundException,订单已完成抛 IllegalStateException;
  4. 在 main 里调用它,分别触发两个异常,观察捕获与输出;
  5. 写一个方法 void processWithLog(String orderId):捕获业务异常后记录日志并重新抛出(带上原因),说明为什么"记录后还要抛"。

验收方式:能解释第 5 步"既记录又抛出"与"只记录不抛"的区别。

扩展任务:给 OrderService 加一个方法 Optional<Order> find(String orderId),对比"返回 Optional"与"抛异常"两种表达"找不到"的方式,各自适合什么场景。

11. 练习题 ​

基础题

  1. 判断:Error 应该被捕获并处理。(对 / 错)
  2. 填空:受检异常必须 ______ 或 ______;包装异常时必须传入 ______。
  3. 简答:IllegalArgumentException 与 IllegalStateException 的区别?

代码阅读题

java
static int divide(int a, int b) {
    try {
        return a / b;
    } catch (ArithmeticException e) {
        throw new IllegalArgumentException("除数不能为 0", e);
    } finally {
        System.out.println("finally");
    }
}

System.out.println(divide(6, 2));
System.out.println(divide(6, 0));
  1. 两行输出分别是什么?第二行为什么会打印两次(finally 与异常信息)?

编程题

  1. 写一个 FileConfig 类:
    • 方法 Map<String, String> load(Path path):读取 key=value 格式的配置文件(每行一条,# 开头为注释);
    • 文件不存在 → 抛自定义的 ConfigLoadException,并携带路径;
    • 格式错误(缺少 =)→ 抛 ConfigLoadException,携带行号;
    • 必须使用 try-with-resources;
    • 附带一个写方法 save(Path path, Map<String, String> config)。

综合任务

  1. 设计一个"支付流程"的异常策略:
    • 三种失败:余额不足(业务,可提示用户)、支付网关超时(外部,可重试)、参数非法(编程错误,不该重试);
    • 为每种情况定义异常类型(用现成的还是自定义);
    • 写一个 PaymentService:PaymentResult pay(PaymentRequest request);
    • 实现"网关超时重试 3 次"的逻辑(重试时注意区分"可重试"与"不可重试"的异常);
    • 说明:为什么"参数非法"不该被重试?
分级提示

提示 1(方向):第 4 题的关键是 finally 在 return 之前执行。

提示 2(关键点):第 5 题的 ConfigLoadException 可以携带 Path 与行号,方便定位。

提示 3(接近答案):第 6 题可以用一个 isRetryable(Throwable) 方法判断,只有 GatewayTimeoutException 返回 true。

12. 参考答案 ​

基础题
  1. 错。Error 表示 JVM 层面的严重问题(内存耗尽、栈溢出),程序一般无法恢复,捕获它通常没有意义,反而会掩盖问题。
  2. 捕获(try-catch) 或 声明抛出(throws);原始异常(cause)。
  3. IllegalArgumentException 表示"传入的参数不合法"(你给我的东西不对);IllegalStateException 表示"对象当前状态不允许这个操作"(时机不对)。两者都是非受检异常。
代码阅读题(第 4 题)
text
finally
3
finally
Exception in thread "main" java.lang.IllegalArgumentException: 除数不能为 0
	... Caused by: java.lang.ArithmeticException: / by zero

过程:

  1. divide(6, 2):try 里 return 计算完成 → finally 先执行(打印 finally)→ 然后才真正返回 3;
  2. divide(6, 0):a / b 抛 ArithmeticException → 进入 catch → 构造新异常(带上 cause)→ finally 执行(打印 finally)→ 然后抛出 IllegalArgumentException;
  3. 因为 main 里没有捕获,异常最终打印到控制台,并带有 Caused by 链。

这个例子还说明:finally 在 return 与 throw 之前都会执行。

编程题(第 5 题)
java
package cn.kb.learn.common;

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.LinkedHashMap;
import java.util.Map;

public class FileConfig {
    /** 配置加载异常:携带路径或行号,便于定位 */
    static class ConfigLoadException extends RuntimeException {
        private final Path path;
        private final int lineNumber;

        ConfigLoadException(String message, Path path, int lineNumber, Throwable cause) {
            super(message, cause);
            this.path = path;
            this.lineNumber = lineNumber;
        }

        Path path() {
            return path;
        }

        int lineNumber() {
            return lineNumber;
        }
    }

    public Map<String, String> load(Path path) {
        if (!Files.exists(path)) {
            throw new ConfigLoadException("配置文件不存在", path, -1, null);
        }
        Map<String, String> config = new LinkedHashMap<>();
        try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
            String line;
            int lineNumber = 0;
            while ((line = reader.readLine()) != null) {
                lineNumber++;
                String trimmed = line.trim();
                if (trimmed.isEmpty() || trimmed.startsWith("#")) {
                    continue;
                }
                int index = trimmed.indexOf('=');
                if (index <= 0) {
                    throw new ConfigLoadException("配置格式错误(缺少 = )", path, lineNumber, null);
                }
                config.put(trimmed.substring(0, index).trim(), trimmed.substring(index + 1).trim());
            }
        } catch (IOException exception) {
            throw new ConfigLoadException("读取配置失败", path, -1, exception);
        }
        return config;
    }

    public void save(Path path, Map<String, String> config) {
        StringBuilder builder = new StringBuilder();
        config.forEach((key, value) -> builder.append(key).append('=').append(value).append(System.lineSeparator()));
        try {
            Files.writeString(path, builder.toString(), StandardCharsets.UTF_8);
        } catch (IOException exception) {
            throw new ConfigLoadException("写入配置失败", path, -1, exception);
        }
    }

    public static void main(String[] args) throws IOException {
        Path path = Path.of("target", "io-demo", "app.conf");
        Files.createDirectories(path.getParent());
        FileConfig fileConfig = new FileConfig();

        Map<String, String> config = new LinkedHashMap<>();
        config.put("app.name", "知识库");
        config.put("app.lessons", "32");
        fileConfig.save(path, config);
        System.out.println("保存并读回: " + fileConfig.load(path));

        try {
            fileConfig.load(Path.of("target", "io-demo", "missing.conf"));
        } catch (ConfigLoadException exception) {
            System.out.println("预期内的失败: " + exception.getMessage() + " -> " + exception.path());
        }

        Path bad = Path.of("target", "io-demo", "bad.conf");
        Files.writeString(bad, "good=1\nbad-line\n", StandardCharsets.UTF_8);
        try {
            fileConfig.load(bad);
        } catch (ConfigLoadException exception) {
            System.out.println("预期内的失败: " + exception.getMessage() + " -> 第 " + exception.lineNumber() + " 行");
        }
    }
}

设计要点:

  1. 所有 IO 异常都被包装成 ConfigLoadException,调用方只需要处理一种异常;
  2. 携带 Path 与行号,让错误信息直接指向问题位置;
  3. 使用 try-with-resources,BufferedReader 自动关闭,即使中途抛异常;
  4. 包装时传入 cause(IOException),保留了底层原因。

预期输出:

text
保存并读回: {app.name=知识库, app.lessons=32}
预期内的失败: 配置文件不存在 -> target\io-demo\missing.conf
预期内的失败: 配置格式错误(缺少 = ) -> 第 2 行
综合任务(第 6 题)
java
package cn.kb.learn.common;

public class PaymentService {
    /** 余额不足:业务问题,提示用户,不重试 */
    static class InsufficientBalanceException extends RuntimeException {
        InsufficientBalanceException(String message) {
            super(message);
        }
    }

    /** 网关超时:外部问题,可重试 */
    static class GatewayTimeoutException extends RuntimeException {
        GatewayTimeoutException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    record PaymentRequest(String orderId, long amountInCents) {
    }

    record PaymentResult(boolean success, String message) {
    }

    private final Gateway gateway;
    private final long balanceInCents;

    PaymentService(Gateway gateway, long balanceInCents) {
        this.gateway = gateway;
        this.balanceInCents = balanceInCents;
    }

    interface Gateway {
        void charge(PaymentRequest request) throws Exception;
    }

    PaymentResult pay(PaymentRequest request) {
        if (request.amountInCents() <= 0) {
            throw new IllegalArgumentException("金额必须为正");           // 编程错误,不重试
        }
        if (request.amountInCents() > balanceInCents) {
            throw new InsufficientBalanceException("余额不足");           // 业务问题,不重试
        }

        int attempts = 0;
        while (true) {
            attempts++;
            try {
                gateway.charge(request);
                return new PaymentResult(true, "支付成功(第 " + attempts + " 次尝试)");
            } catch (Exception exception) {
                if (!isRetryable(exception) || attempts >= 3) {
                    throw new GatewayTimeoutException("支付网关异常,已尝试 " + attempts + " 次", exception);
                }
            }
        }
    }

    /** 只有外部可恢复的问题才值得重试 */
    private boolean isRetryable(Throwable throwable) {
        return throwable instanceof java.util.concurrent.TimeoutException
                || throwable instanceof java.io.IOException;
    }

    public static void main(String[] args) {
        Gateway flaky = request -> {
            if (Math.random() < 0.7) {
                throw new java.util.concurrent.TimeoutException("网关超时");
            }
        };
        try {
            new PaymentService(flaky, 100_000).pay(new PaymentRequest("O1", 1_000));
        } catch (GatewayTimeoutException exception) {
            System.out.println("预期内的失败: " + exception.getMessage());
        }

        try {
            new PaymentService(flaky, 100_000).pay(new PaymentRequest("O1", -1));
        } catch (IllegalArgumentException exception) {
            System.out.println("预期内的失败: " + exception.getMessage());
        }
    }
}

为什么"参数非法"不该被重试:

  1. 重试改变不了结果——参数是错的,重试一百次还是错的,只浪费资源;
  2. 会掩盖问题——本应立即暴露的代码 bug 被"重试成功"掩盖了;
  3. 可能造成重复副作用——如果参数错误发生在"扣款成功后",重试可能导致重复扣款。

重试的判断标准:重试能否改变结果。网络超时、临时不可用可以重试;参数错误、业务规则不满足不能重试。

13. 掌握标准 ​

  • [ ] 能画出异常层次并区分受检/非受检异常;
  • [ ] 会用 try-with-resources 管理资源;
  • [ ] 能定义携带业务数据的自定义异常;
  • [ ] 会用 (message, cause) 保留根因;
  • [ ] 能说出异常处理三条原则,并认出三类反模式;
  • [ ] 知道 finally 里不该写 return。

14. 扩展资源 ​

15. 下一步学习 ​

《文件与 IO 流》:读写文件、处理大文件、理解字节流与字符流的区别。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引