第 11 课:异常体系、捕获、抛出、自定义异常与资源关闭
本课目标
学完后,你应该能够:
- 理解异常对象和
Throwable层次结构。 - 区分
Error、受检异常和运行时异常。 - 正确使用
try-catch-finally,并能推演执行路径。 - 区分
throw与throws。 - 编写和使用自定义业务异常。
- 正确安排多个
catch的顺序。 - 使用
try-with-resources自动关闭文件、流和数据库资源。 - 避免空
catch、过度捕获和丢失异常原因。 - 让异常边界清晰,知道何处恢复、何处继续向上交给调用者。
1. 异常是什么
异常是程序运行期间发生的、打断正常执行路径的事件对象,例如:
- 除数为 0;
- 数组索引超出范围;
- 字符串无法转换为数字;
- 读取文件失败;
- 用户输入违反业务规则。
没有异常处理时,异常会沿着调用栈向上寻找处理者;一直没有处理就会终止当前线程,并打印堆栈信息。
异常不是“程序可以忽略的错误提示”,而是带有类型、消息、原因和堆栈位置的对象。
2. 异常类层次结构
Throwable
/ \
Error Exception
/ \
RuntimeException 其他受检异常
Error
OutOfMemoryError、StackOverflowError 等通常表示 JVM 或运行环境层面的问题,应用一般不应该试图恢复。不要用 catch (Throwable) 把它们全部吞掉。
Exception
应用可以处理的异常通常属于 Exception。
RuntimeException
运行时异常是 RuntimeException 的子类,例如:
NullPointerException;ArithmeticException;ArrayIndexOutOfBoundsException;NumberFormatException;IllegalArgumentException。
编译器不强制你捕获它们,但程序仍可能在运行时抛出。
受检异常 Checked Exception
直接继承 Exception 但不属于 RuntimeException 的异常,例如 IOException。方法如果可能抛出受检异常,必须:
- 在方法内部
try-catch处理;或 - 在方法签名写
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 问题后可以:
- 自己恢复并返回结果;
- 添加上下文后重新抛出;
- 让 Service 处理;
- 最终由统一边界记录日志并转换成用户能理解的响应。
不要在每层都无意义地 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();
}
}
}
练习关键点
- 两个业务异常分别表达不同失败原因,调用者可以分别处理。
throws同时列出两个受检异常,调用者不能假装它们不存在。- 余额扣减和目标增加位于校验之后,校验失败时不会修改任何状态。
finally适合放审计输出或清理逻辑,但不要在里面返回结果。- 真实转账还需要事务、幂等、并发控制和金额精度处理,本例只训练异常边界。
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. 检查题与答案
问题
Throwable的两个主要分支是什么?Error与普通应用异常有什么区别?RuntimeException是否强制编译器捕获?- 受检异常必须怎样处理?
- try 中抛出异常后,异常位置后面的语句会执行吗?
- 多个 catch 为什么要子类在前、父类在后?
- finally 通常适合做什么?
throw和throws的区别是什么?- 自定义受检异常通常继承哪个类?
- try-with-resources 的资源必须满足什么条件?
- 多个资源关闭顺序是什么?
catch (Exception)是否总是错误?- 为什么不应该写空 catch?
- 如何保留重新包装异常的原始原因?
target == null || target == this为什么能安全判断?- finally 中 return 有什么危险?
IOException为什么通常需要 catch 或 throws?- 为什么业务校验方法可以抛自定义异常而不是直接打印?
答案
Error和Exception。Error多表示 JVM 或运行环境严重问题,应用通常不应尝试恢复;Exception更多是应用可处理的失败。- 不强制,但仍应根据场景处理,否则会在运行时终止路径。
- 在方法内部捕获,或在方法签名用
throws继续声明。 - 不会,当前 try 会立即离开并寻找匹配的 catch。
- 匹配从上到下,父类在前会先捕获子类,使后面的子类分支不可达。
- 无论成功失败都必须执行的清理、审计或收尾逻辑。
throw实际抛出异常对象;throws声明方法可能抛出哪些异常。Exception(如果希望成为受检异常且不是RuntimeException子类)。- 实现
AutoCloseable,常见的Closeable也满足。 - 按声明顺序的逆序关闭,后声明的先关闭。
- 不一定,但通常过宽。只有当前层确实能统一处理所有异常时才使用,并且不能吞掉问题。
- 它会静默丢失失败原因,让错误状态继续传播,难以排查。
- 使用带 cause 的构造方法,例如
new ServiceException("...", exception)。 ||左侧为真时右侧短路,避免对null调用或继续判断。- 它可能覆盖 try/catch 的返回值或原始异常。
- 它是受检异常,编译器要求调用者明确处理或声明。
- 校验逻辑与展示/接口响应解耦,调用者可以决定记录、提示、重试或转换成响应。
19. 本课完成标准
- 能画出基本异常层次并区分受检与运行时异常。
- 能准确推演 try、catch、finally 的执行顺序。
- 能区分
throw、throws与try-with-resources。 - 能编写带消息的自定义业务异常。
- 能按具体程度安排 catch 顺序。
- 能在资源代码中使用自动关闭。
- 能保留异常 cause,不写空 catch。
- 能把异常处理放在合适的边界,而不是到处吞异常。
下一课学习集合框架:List、Set、Map、泛型、迭代器和集合选择原则。