第 11 课:异常体系、捕获、抛出、自定义异常与资源关闭

0 阅读13分钟

第 11 课:异常体系、捕获、抛出、自定义异常与资源关闭

本课目标

学完后,你应该能够:

  1. 理解异常对象和 Throwable 层次结构。
  2. 区分 Error、受检异常和运行时异常。
  3. 正确使用 try-catch-finally,并能推演执行路径。
  4. 区分 throwthrows
  5. 编写和使用自定义业务异常。
  6. 正确安排多个 catch 的顺序。
  7. 使用 try-with-resources 自动关闭文件、流和数据库资源。
  8. 避免空 catch、过度捕获和丢失异常原因。
  9. 让异常边界清晰,知道何处恢复、何处继续向上交给调用者。

1. 异常是什么

异常是程序运行期间发生的、打断正常执行路径的事件对象,例如:

  • 除数为 0;
  • 数组索引超出范围;
  • 字符串无法转换为数字;
  • 读取文件失败;
  • 用户输入违反业务规则。

没有异常处理时,异常会沿着调用栈向上寻找处理者;一直没有处理就会终止当前线程,并打印堆栈信息。

异常不是“程序可以忽略的错误提示”,而是带有类型、消息、原因和堆栈位置的对象。

2. 异常类层次结构

                    Throwable
                   /         \
                Error        Exception
                              /       \
                   RuntimeException   其他受检异常

Error

OutOfMemoryErrorStackOverflowError 等通常表示 JVM 或运行环境层面的问题,应用一般不应该试图恢复。不要用 catch (Throwable) 把它们全部吞掉。

Exception

应用可以处理的异常通常属于 Exception

RuntimeException

运行时异常是 RuntimeException 的子类,例如:

  • NullPointerException
  • ArithmeticException
  • ArrayIndexOutOfBoundsException
  • NumberFormatException
  • IllegalArgumentException

编译器不强制你捕获它们,但程序仍可能在运行时抛出。

受检异常 Checked Exception

直接继承 Exception 但不属于 RuntimeException 的异常,例如 IOException。方法如果可能抛出受检异常,必须:

  1. 在方法内部 try-catch 处理;或
  2. 在方法签名写 throws,交给调用者处理。

3. try-catch 执行路径

try {
    riskyOperation();
    afterOperation();
} catch (SpecificException exception) {
    recover();
}
nextStep();

正常路径:

进入 try → riskyOperation 成功 → afterOperation → 跳过 catch → nextStep

异常路径:

进入 try → riskyOperation 抛异常
       → try 后续语句跳过
       → 匹配 catch
       → catch 结束
       → nextStep

异常发生后,当前 try 代码块中异常位置之后的语句不会执行。catch 处理完成后,程序可以继续执行 catch 之后的代码。

只把真正可能失败的语句放进 try,不要把整个巨大方法全部包住,否则难以判断哪一行出错。

4. 多个 catch 与匹配顺序

try {
    // 可能抛出多种异常
} catch (ArrayIndexOutOfBoundsException exception) {
    // 更具体的异常
} catch (RuntimeException exception) {
    // 更宽泛的父类异常
}

异常匹配从上到下进行。子类必须写在父类之前:

catch (RuntimeException exception) { }
catch (NullPointerException exception) { } // 编译错误:永远到不了

因为 NullPointerException 已经会被前面的 RuntimeException 捕获。

Java 也支持 multi-catch:

try {
    riskyOperation();
} catch (IOException | NumberFormatException exception) {
    System.out.println("两种异常使用同一处理策略");
}

multi-catch 中的类型不能存在父子关系,否则更宽泛的类型会覆盖子类型。

5. finally

finally 通常用于无论成功或失败都必须执行的清理逻辑:

try {
    useResource();
} catch (Exception exception) {
    handle(exception);
} finally {
    closeResource();
}

常规情况下:

  • try 成功:执行 finally;
  • try 抛异常并被 catch:执行 catch,再执行 finally;
  • try 抛异常但没有匹配 catch:仍会执行 finally,然后继续向上抛。

极端情况下 System.exit()、JVM 崩溃或进程被强制终止,finally 可能无法执行。

不要在 finally 中 return:它可能覆盖 try 或 catch 中的返回值,甚至覆盖原始异常,让问题非常难查。

6. throw 与 throws

throw:现在抛出一个异常对象

if (score < 0 || score > 100) {
    throw new InvalidScoreException("成绩范围无效");
}

throw 是执行语句,表示此刻中断当前正常流程并抛出指定对象。

throws:声明方法可能抛出什么

public static void validate(double score)
        throws InvalidScoreException {
}

throws 写在方法签名中,告诉调用者:调用这个方法必须处理或继续声明该异常。

对比:

关键字位置含义
throw方法体内部实际抛出一个异常对象
throws方法声明末尾声明可能抛出的异常类型

一个方法可以 throw 多次,但每个受检异常都必须在签名中声明或被内部捕获。

7. 自定义异常

IllegalArgumentException 不能准确表达业务语义时,可以创建自己的异常:

public class InvalidScoreException extends Exception {
    public InvalidScoreException(String message) {
        super(message);
    }
}

选择继承:

  • 继承 Exception:受检异常,调用者必须处理或声明;适合调用者应该明确面对的业务失败。
  • 继承 RuntimeException:非受检异常,调用者不被编译器强制;适合参数违反方法前置条件等编程错误或不希望层层声明的场景。

异常消息应说明发生了什么以及相关上下文,但不要把密码、完整令牌等敏感信息写进消息或日志。

8. 异常传播与处理边界

Controller → Service → Repository

底层 Repository 发现 I/O 问题后可以:

  1. 自己恢复并返回结果;
  2. 添加上下文后重新抛出;
  3. 让 Service 处理;
  4. 最终由统一边界记录日志并转换成用户能理解的响应。

不要在每层都无意义地 catch (Exception) 后重新抛一个没有原因的新异常。重新包装时保留 cause:

throw new ServiceException("读取用户失败", exception);

这样既能提供业务上下文,又不会丢掉原始堆栈。

9. try-with-resources

实现 AutoCloseable 的资源可以写在 try 括号中:

try (BufferedReader reader = createReader()) {
    use(reader);
} catch (IOException exception) {
    handle(exception);
}

离开 try 时 Java 自动调用 close()。多个资源按声明的逆序关闭:

try (Resource first = ...; Resource second = ...) {
}
// 先关闭 second,再关闭 first

与手写 finally 相比,try-with-resources:

  • 不容易忘记关闭;
  • 关闭异常会被记录为 suppressed exception;
  • 代码更短,资源边界更清晰。

10. suppressed exception

如果 try 主体已经抛出一个异常,随后 close() 又抛出另一个异常,主异常仍然作为主要异常,关闭异常会附加为 suppressed:

for (Throwable suppressed : exception.getSuppressed()) {
    System.out.println(suppressed.getMessage());
}

这比手写 finally 中直接抛出关闭异常更好,因为不会覆盖原始失败原因。

11. 完整源码一:ExceptionBasics.java

public class ExceptionBasics {
    public static void main(String[] args) {
        // try 中的代码可能出错;出错后会立即跳到匹配的 catch。
        try {
            int numerator = 10;
            int denominator = 0;
            int result = numerator / denominator;
            System.out.println("结果:" + result);
        } catch (ArithmeticException exception) {
            System.out.println("不能除以零:" + exception.getMessage());
        } finally {
            System.out.println("第一段处理结束。");
        }
​
        // 多个 catch 时,子类异常必须写在父类异常之前。
        try {
            int[] numbers = {10, 20, 30};
            System.out.println(numbers[5]);
        } catch (ArrayIndexOutOfBoundsException exception) {
            System.out.println("数组索引越界:" + exception.getMessage());
        } catch (RuntimeException exception) {
            System.out.println("其他运行时异常:" + exception.getClass().getSimpleName());
        }
​
        // 只把真正可能失败的语句放进 try,便于定位问题。
        String input = "abc";
        try {
            int number = Integer.parseInt(input);
            System.out.println("转换结果:" + number);
        } catch (NumberFormatException exception) {
            System.out.println("无法把文字转换为整数:" + input);
        }
​
        System.out.println("程序仍然可以继续执行。");
    }
}

执行路径:第一段因为除数为 0 进入 catch,然后进入 finally;第二段因为访问索引 5 进入更具体的数组异常 catch;第三段捕获文字转整数异常,最后程序继续执行。

12. 完整源码二:InvalidScoreException.java

// 自定义受检异常:继承 Exception,表示调用者必须处理或声明它。
public class InvalidScoreException extends Exception {
    public InvalidScoreException(String message) {
        super(message);
    }
}

super(message) 调用父类 Exception 的构造方法,把业务消息保存到异常对象中。调用者可以通过 getMessage() 读取。

13. 完整源码三:ScoreValidator.java

public class ScoreValidator {
    // throws 声明这个方法可能抛出 InvalidScoreException。
    public static void validate(double score) throws InvalidScoreException {
        if (score < 0 || score > 100) {
            // throw 在这里真正创建并抛出异常对象。
            throw new InvalidScoreException("成绩必须在 0 到 100 之间:" + score);
        }
    }
​
    public static void main(String[] args) {
        double[] scores = {88.5, -2, 101, 76};
​
        for (double score : scores) {
            try {
                validate(score);
                System.out.println("有效成绩:" + score);
            } catch (InvalidScoreException exception) {
                // 这里负责决定如何向用户反馈校验失败。
                System.out.println("成绩无效:" + exception.getMessage());
            }
        }
    }
}

validate() 不知道调用者想怎样显示错误,所以只负责判断并抛出;main 负责捕获并输出。职责边界比让校验方法直接打印更清晰,也更容易被 Web 接口复用。

14. 完整源码四:ResourceDemo.java

import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
​
public class ResourceDemo {
    public static void main(String[] args) {
        // try (...) 中声明的资源必须实现 AutoCloseable。
        // 离开 try 后,reader 会自动 close,不需要手写 finally。
        try (BufferedReader reader = new BufferedReader(
                new StringReader("第一行\n第二行\n第三行"))) {
​
            String line;
            int lineNumber = 1;
​
            // readLine() 读到末尾时返回 null。
            while ((line = reader.readLine()) != null) {
                System.out.println(lineNumber + ": " + line);
                lineNumber++;
            }
        } catch (IOException exception) {
            // readLine 和 close 声明了可能出现 IOException。
            System.out.println("读取文本失败:" + exception.getMessage());
        }
​
        System.out.println("资源处理完成。");
    }
}

虽然这里读取的是内存中的 StringReader,语法与读取文件、网络流、数据库连接的资源管理方式相同。真实项目中应优先使用 try-with-resources。

15. 独立练习完整答案:BankTransferPractice.java

需求:

  • 非法目标或非法金额抛出 InvalidTransferException
  • 余额不足抛出 InsufficientBalanceException
  • 调用者分别捕获两类业务异常;
  • 无论成功失败,finally 都打印最终账户状态。
public class BankTransferPractice {
    // 转账金额本身不合法时使用的业务异常。
    public static class InvalidTransferException extends Exception {
        public InvalidTransferException(String message) {
            super(message);
        }
    }
​
    // 余额不足时使用的业务异常。
    public static class InsufficientBalanceException extends Exception {
        public InsufficientBalanceException(String message) {
            super(message);
        }
    }
​
    public static class Account {
        private String owner;
        private double balance;
​
        public Account(String owner, double balance) {
            this.owner = owner;
            this.balance = Math.max(balance, 0.0);
        }
​
        public void transferTo(Account target, double amount)
                throws InvalidTransferException, InsufficientBalanceException {
            if (target == null || target == this) {
                throw new InvalidTransferException("转账目标无效。");
            }
            if (amount <= 0) {
                throw new InvalidTransferException("转账金额必须大于 0。");
            }
            if (amount > balance) {
                throw new InsufficientBalanceException(
                        "余额不足,当前余额:" + balance);
            }
​
            balance -= amount;
            target.balance += amount;
        }
​
        public void printInfo() {
            System.out.println(owner + " 的余额:" + balance);
        }
    }
​
    public static void main(String[] args) {
        Account first = new Account("小明", 1000.0);
        Account second = new Account("小红", 300.0);
​
        try {
            first.transferTo(second, 500.0);
            System.out.println("转账成功。");
        } catch (InvalidTransferException exception) {
            System.out.println("转账参数错误:" + exception.getMessage());
        } catch (InsufficientBalanceException exception) {
            System.out.println("转账失败:" + exception.getMessage());
        } finally {
            // 无论成功或失败,都打印最终状态用于审计。
            first.printInfo();
            second.printInfo();
        }
    }
}

练习关键点

  1. 两个业务异常分别表达不同失败原因,调用者可以分别处理。
  2. throws 同时列出两个受检异常,调用者不能假装它们不存在。
  3. 余额扣减和目标增加位于校验之后,校验失败时不会修改任何状态。
  4. finally 适合放审计输出或清理逻辑,但不要在里面返回结果。
  5. 真实转账还需要事务、幂等、并发控制和金额精度处理,本例只训练异常边界。

16. 异常处理的设计原则

只捕获能够处理的异常

如果当前层无法恢复,就不要捕获后静默忽略。可以添加上下文后继续抛出。

不要空 catch

catch (Exception exception) {
}

这会让问题消失,后续状态可能已经错误。至少记录、转换或明确返回失败。

不要所有地方都 catch Exception

过宽的捕获会隐藏编程错误。优先捕获你能真正处理的具体类型。

保留原始原因

throw new RuntimeException("读取配置失败", exception);

第二个参数是 cause,能保留原始堆栈。

异常不要用于普通流程

用户输入校验可以使用条件判断;异常更适合表达异常路径、跨层失败或无法继续的状态。不要用抛异常代替正常循环分支。

17. 常见错误

catch 顺序错误

父类 catch 放在子类前面会导致后面的子类 catch 永远不可达。

忘记处理受检异常

调用声明 throws IOException 的方法时,必须捕获或继续声明。

throw 与 throws 混淆

throw 写在方法体中真正抛出对象;throws 写在方法声明中通知调用者。

在 finally 中 return

会覆盖 try/catch 的返回值,甚至遮蔽异常。不要这样写。

资源没有关闭

文件、流、数据库连接等应优先使用 try-with-resources。

业务异常没有上下文

异常消息要包含必要的业务信息,但不要包含密码、令牌等敏感数据。

18. 检查题与答案

问题

  1. Throwable 的两个主要分支是什么?
  2. Error 与普通应用异常有什么区别?
  3. RuntimeException 是否强制编译器捕获?
  4. 受检异常必须怎样处理?
  5. try 中抛出异常后,异常位置后面的语句会执行吗?
  6. 多个 catch 为什么要子类在前、父类在后?
  7. finally 通常适合做什么?
  8. throwthrows 的区别是什么?
  9. 自定义受检异常通常继承哪个类?
  10. try-with-resources 的资源必须满足什么条件?
  11. 多个资源关闭顺序是什么?
  12. catch (Exception) 是否总是错误?
  13. 为什么不应该写空 catch?
  14. 如何保留重新包装异常的原始原因?
  15. target == null || target == this 为什么能安全判断?
  16. finally 中 return 有什么危险?
  17. IOException 为什么通常需要 catch 或 throws?
  18. 为什么业务校验方法可以抛自定义异常而不是直接打印?

答案

  1. ErrorException
  2. Error 多表示 JVM 或运行环境严重问题,应用通常不应尝试恢复;Exception 更多是应用可处理的失败。
  3. 不强制,但仍应根据场景处理,否则会在运行时终止路径。
  4. 在方法内部捕获,或在方法签名用 throws 继续声明。
  5. 不会,当前 try 会立即离开并寻找匹配的 catch。
  6. 匹配从上到下,父类在前会先捕获子类,使后面的子类分支不可达。
  7. 无论成功失败都必须执行的清理、审计或收尾逻辑。
  8. throw 实际抛出异常对象;throws 声明方法可能抛出哪些异常。
  9. Exception(如果希望成为受检异常且不是 RuntimeException 子类)。
  10. 实现 AutoCloseable,常见的 Closeable 也满足。
  11. 按声明顺序的逆序关闭,后声明的先关闭。
  12. 不一定,但通常过宽。只有当前层确实能统一处理所有异常时才使用,并且不能吞掉问题。
  13. 它会静默丢失失败原因,让错误状态继续传播,难以排查。
  14. 使用带 cause 的构造方法,例如 new ServiceException("...", exception)
  15. || 左侧为真时右侧短路,避免对 null 调用或继续判断。
  16. 它可能覆盖 try/catch 的返回值或原始异常。
  17. 它是受检异常,编译器要求调用者明确处理或声明。
  18. 校验逻辑与展示/接口响应解耦,调用者可以决定记录、提示、重试或转换成响应。

19. 本课完成标准

  • 能画出基本异常层次并区分受检与运行时异常。
  • 能准确推演 try、catch、finally 的执行顺序。
  • 能区分 throwthrowstry-with-resources
  • 能编写带消息的自定义业务异常。
  • 能按具体程度安排 catch 顺序。
  • 能在资源代码中使用自动关闭。
  • 能保留异常 cause,不写空 catch。
  • 能把异常处理放在合适的边界,而不是到处吞异常。

下一课学习集合框架:ListSetMap、泛型、迭代器和集合选择原则。