摘要: 本文全面讲解Java异常处理机制,从异常体系结构到实际应用技巧。详细介绍了try-catch-finally、throw与throws、自定义异常等核心概念,通过真实代码示例演示异常捕获与处理的最佳实践。帮助开发者编写更健壮、可维护的Java程序,让程序在遇到错误时能够优雅恢复而非直接崩溃。
关键词: Java异常处理, try-catch-finally, 受检异常, 自定义异常, 异常最佳实践
从前面的文章走过来,我们写了很多代码,也运行了很多次。但你可能早就注意到了一件事:程序并不总是乖乖听话的。
比如,你写了一个从用户输入读取数字的程序,用户却输入了字母“abc”。程序可能就崩溃了,抛出一串红字。再比如,你打开一个文件,但文件不存在;你访问一个数组的索引,但索引超出了范围。
这些情况,在编程术语中叫作 “异常(Exception)” ——程序在运行过程中发生的、打乱了正常执行流程的意外事件。
如果程序一遇到异常就崩溃,那它未免太脆弱了。一个健壮的程序,应该能够预见到可能发生的错误,并在错误发生时,从容地处理它,而不是直接“死掉”。
今天,我们就来学习 Java 中处理异常的完整机制。它让你的程序学会“带伤前行”,即使遇到问题也能优雅地恢复或退出。
1. 为什么需要异常处理?—— 一个真实的例子
假设你写了一个读取文件内容并显示的程序,代码如下(先不考虑异常处理):
public class FileReaderDemo {
public static void main(String[] args) {
// 假设文件不存在
java.io.FileReader reader = new java.io.FileReader("not_exist.txt");
// 如果上面抛出了异常,下面的代码不会执行
System.out.println("文件打开成功");
reader.close();
}
}
如果 not_exist.txt 不存在,FileReader 的构造方法会抛出 FileNotFoundException,程序会直接停止,控制台会输出一堆红色错误信息,而“文件打开成功”和 close() 都不会执行。
这显然不是我们想要的。我们希望程序能检测到这个问题,然后告诉用户“文件不存在,请检查文件名”,让程序继续运行或安全退出。
异常机制正是为此而生的。
2. 异常体系的层次结构
在 Java 中,所有异常类都继承自 Throwable 类。Throwable 有两个主要的子类:
Error:表示严重错误,通常是 JVM 层面的问题,如OutOfMemoryError、StackOverflowError。这类错误程序一般无法处理,也不应该去处理(你也处理不了)。我们通常不关心它。Exception:表示程序可以处理的异常情况。这是我们今天的重点。
Exception 又分为两大类:
| 类型 | 特点 | 是否必须处理 | 常见例子 |
|---|---|---|---|
| Checked Exception(受检异常) | 编译器强制要求处理(要么 try-catch,要么 throws) | ✅ 必须 | IOException、SQLException、ClassNotFoundException |
| Unchecked Exception(非受检异常) | 不强制处理,通常由编程错误导致 | ❌ 不强制(但可以处理) | NullPointerException、ArrayIndexOutOfBoundsException、ArithmeticException、IllegalArgumentException |
用一张简图表示:
Throwable ├── Error (严重错误,如 OutOfMemoryError) └── Exception ├── RuntimeException (及其子类) ← 未受检异常 │ ├── NullPointerException │ ├── ArithmeticException │ └── ... └── (其他 Exception 子类) ← 受检异常 ├── IOException ├── SQLException └── ...
受检异常是 Java 的一大特色(很多语言没有)。它的设计哲学是:某些错误是“可预见的”,编译器强制你在编写代码时就考虑处理它们,而不是等到运行时才发现。例如,你打开一个文件,文件不存在是很有可能发生的,所以编译器强迫你处理这种情况。
非受检异常通常是由程序员的失误引起的,比如传入 null、数组越界、除以零。这些错误如果能避免(通过代码检查),就不需要强制处理。当然,你也可以选择捕获它们。
3. 捕获异常 —— try-catch-finally
处理异常的基本语法是 try-catch-finally。
① 基础形式:try-catch
try {
// 可能抛出异常的代码
int result = 10 / 0; // 会抛出 ArithmeticException
} catch (ArithmeticException e) {
// 捕获到异常后执行这里的代码
System.out.println("除零错误:" + e.getMessage());
}
try块中放置可能抛出异常的代码。catch块中放置处理异常的代码。一个try可以跟多个catch块,分别处理不同类型的异常。
② 多个 catch 块
try {
String s = null;
System.out.println(s.length()); // NullPointerException
int[] arr = new int[5];
System.out.println(arr[10]); // ArrayIndexOutOfBoundsException
} catch (NullPointerException e) {
System.out.println("空指针异常:" + e.getMessage());
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("数组越界:" + e.getMessage());
}
多个 catch 时,子类异常必须写在父类异常之前,否则编译报错(因为父类会捕获所有子类,后面的子类 catch 永远不会执行)。
// 错误顺序
catch (Exception e) { ... } // 父类在前
catch (NullPointerException e) { ... } // 子类在后,编译错误
③ finally —— 无论如何都会执行的“善后”代码
finally 块中的代码,无论 try 中是否抛出异常,也无论 catch 是否执行,都会在最后执行。它非常适合用来释放资源(如关闭文件、数据库连接)。
java.io.FileReader reader = null;
try {
reader = new java.io.FileReader("test.txt");
// 读取文件...
} catch (java.io.FileNotFoundException e) {
System.out.println("文件未找到:" + e.getMessage());
} catch (java.io.IOException e) {
System.out.println("IO错误:" + e.getMessage());
} finally {
if (reader != null) {
try {
reader.close();
} catch (java.io.IOException e) {
System.out.println("关闭文件出错:" + e.getMessage());
}
}
}
即使
try或catch中有return语句,finally也会在return之前执行。所以finally是“绝对可靠”的善后之地。
④ try-with-resources —— 更优雅的资源管理(JDK 7+)
上面的代码有点啰嗦,尤其是关闭资源时还要再包一层 try-catch。从 Java 7 开始,引入了 try-with-resources 语法,它自动帮你关闭实现了 AutoCloseable 接口的资源。
try (java.io.FileReader reader = new java.io.FileReader("test.txt");
java.io.BufferedReader br = new java.io.BufferedReader(reader)) {
String line = br.readLine();
System.out.println(line);
} catch (java.io.IOException e) {
System.out.println("IO错误:" + e.getMessage());
}
// reader 和 br 会自动关闭(不管是否发生异常)
try-with-resources 的语法是在 try 后面的括号里声明资源,多个资源用分号 ; 分隔。这些资源会在 try 结束后自动调用 close(),无需你手动 finally。代码简洁了很多,而且更安全(不会忘记关闭)。
JDK 21 的小提升:在 try-with-resources 中,你可以使用未命名变量 _ 来表示不需要引用的资源:
try (var _ = new java.io.FileInputStream("test.dat")) {
// 文件自动关闭,但我们不需要引用它
System.out.println("文件已处理");
} catch (java.io.IOException e) {
System.out.println("错误:" + e.getMessage());
}
4. 抛出异常 —— throw 和 throws
① throw —— 主动“扔”出异常
你可以在代码中手动抛出异常,使用 throw 关键字。这通常用于检测到某种错误条件时,主动告知调用方。
public void withdraw(double amount) {
if (amount > balance) {
throw new IllegalArgumentException("余额不足,当前余额:" + balance);
}
balance -= amount;
}
你可以抛出 JDK 内置的异常,也可以抛出自定义异常(后面讲)。
② throws —— 声明“我可能会抛出”
如果一个方法内部可能抛出受检异常,但方法本身不处理它(即没有 try-catch),那么它必须在方法签名中用 throws 关键字声明,告诉调用方:“我可能会抛出这个异常,你看着办吧。”
public void readFile(String path) throws java.io.IOException {
// 可能抛出 IOException,但不在这里处理
java.io.FileReader reader = new java.io.FileReader(path);
// ...
}
调用方要么用 try-catch 处理,要么继续向上 throws。
public void process() throws java.io.IOException {
readFile("test.txt"); // 如果 readFile 抛异常,process 也抛出
}
// 或者
public void process() {
try {
readFile("test.txt");
} catch (java.io.IOException e) {
System.out.println("读取失败:" + e.getMessage());
}
}
规则:
- 重写的方法不能抛出比父类更宽泛的受检异常(即父类方法抛出的异常范围 >= 子类)。
throws可以声明多个异常类型,用逗号分隔。
5. 自定义异常 —— 让你的错误更有“业务味”
有时候,JDK 自带的异常不足以描述业务层面的错误(比如“库存不足”、“转账金额不能为负数”)。这时,你可以创建自己的异常类。
自定义异常的步骤:
- 继承
Exception(如果是受检异常)或RuntimeException(如果是非受检异常)。 - 提供构造方法(通常至少提供一个无参构造和一个带消息的构造)。
// 自定义受检异常
public class InsufficientStockException extends Exception {
public InsufficientStockException() {
super();
}
public InsufficientStockException(String message) {
super(message);
}
// 还可以带其他参数
public InsufficientStockException(String productName, int available, int requested) {
super(String.format("商品 '%s' 库存不足,当前库存:%d,需要:%d", productName, available, requested));
}
}
// 自定义非受检异常(继承 RuntimeException)
public class ValidationException extends RuntimeException {
public ValidationException(String message) {
super(message);
}
}
使用自定义异常:
public class Product {
private String name;
private int stock;
public void reduceStock(int quantity) throws InsufficientStockException {
if (stock < quantity) {
throw new InsufficientStockException(name, stock, quantity);
}
stock -= quantity;
}
public void setStock(int stock) {
if (stock < 0) {
throw new ValidationException("库存不能为负数:" + stock);
}
this.stock = stock;
}
}
自定义异常能让你的 API 更加清晰,调用方可以精确捕获特定业务错误,而不是笼统地捕获 Exception。
6. 异常处理的最佳实践
① 不要“吞掉”异常
try {
// ...
} catch (Exception e) {
// 空的 catch 块:什么也不做
}
这是最危险的做法之一。你把异常吞掉了,程序虽然没崩溃,但错误也悄无声息地被忽略了,后续可能出现更奇怪的问题。至少要打印日志或输出错误信息。
② 尽可能使用具体的异常类型
不要总写 catch (Exception e)。应该捕获具体子类,这样你才能针对不同异常做不同处理。
try {
// ...
} catch (NullPointerException e) {
// 处理空指针
} catch (IllegalArgumentException e) {
// 处理非法参数
}
③ 早抛出,晚捕获
- 早抛出:在错误发生的地方立刻抛出异常,带上清晰的错误信息,便于定位问题。
- 晚捕获:在合适的层(比如用户交互层或最外层)统一捕获并处理,不要在每个方法里都
catch了又吞掉。让上层决策如何处理。
④ 使用 try-with-resources 管理资源
尽量使用 try-with-resources,避免手动 finally 关闭资源。代码更简洁,也不会遗漏。
⑤ 不要用异常来控制正常流程
异常的处理成本较高,不应被用于正常的业务逻辑分支(比如用异常来判断文件是否存在,应该用 Files.exists() 检查)。异常只应用于“意外”情况。
⑥ 文档化
如果方法抛出受检异常,一定要在 JavaDoc 中用 @throws 标签说明,让调用方知道需要注意什么。
7. 异常处理综合示例
我们来写一个包含多种异常处理方式的完整程序:一个简单的银行账户类,包含取款、存款、转账功能,其中会用到受检异常、非受检异常、自定义异常、try-catch 和 throws。
// ========== 自定义异常:余额不足(受检) ==========
public class InsufficientBalanceException extends Exception {
public InsufficientBalanceException(String message) {
super(message);
}
}
// ========== 自定义异常:账户不存在(非受检) ==========
public class AccountNotFoundException extends RuntimeException {
public AccountNotFoundException(String accountId) {
super("账户不存在:" + accountId);
}
}
// ========== 银行账户类 ==========
public class BankAccount {
private String accountId;
private String owner;
private double balance;
public BankAccount(String accountId, String owner, double balance) {
this.accountId = accountId;
this.owner = owner;
this.balance = balance;
}
public String getAccountId() { return accountId; }
public double getBalance() { return balance; }
// 存款(不会抛出受检异常)
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("存款金额必须大于0");
}
balance += amount;
System.out.println("✅ 存入 " + amount + ",当前余额:" + balance);
}
// 取款(可能抛出受检异常)
public void withdraw(double amount) throws InsufficientBalanceException {
if (amount <= 0) {
throw new IllegalArgumentException("取款金额必须大于0");
}
if (amount > balance) {
throw new InsufficientBalanceException(
"账户 " + accountId + " 余额不足,当前余额:" + balance + ",需要:" + amount
);
}
balance -= amount;
System.out.println("✅ 取出 " + amount + ",当前余额:" + balance);
}
// 转账:从当前账户转到目标账户
public void transfer(BankAccount target, double amount)
throws InsufficientBalanceException {
if (target == null) {
throw new IllegalArgumentException("目标账户不能为空");
}
if (this == target) {
throw new IllegalArgumentException("不能给自己转账");
}
// 先从当前账户取款(如果余额不足,会抛出 InsufficientBalanceException)
this.withdraw(amount);
// 再存入目标账户
target.deposit(amount);
System.out.println("🔄 转账 " + amount + " 从 " + this.accountId + " 到 " + target.accountId);
}
}
// ========== 主程序(包含异常处理演示) ==========
public class Main {
public static void main(String[] args) {
// 创建两个账户
BankAccount acc1 = new BankAccount("A001", "张三", 1000.0);
BankAccount acc2 = new BankAccount("A002", "李四", 500.0);
// ---- 1. 正常操作 ----
System.out.println("=== 正常操作 ===");
try {
acc1.withdraw(200); // 正常
acc1.transfer(acc2, 300); // 正常
} catch (InsufficientBalanceException e) {
System.out.println("❌ 错误:" + e.getMessage());
}
// ---- 2. 余额不足(受检异常) ----
System.out.println("\n=== 余额不足 ===");
try {
acc1.withdraw(10000); // 余额不足,抛出 InsufficientBalanceException
} catch (InsufficientBalanceException e) {
System.out.println("❌ 取款错误:" + e.getMessage());
}
// ---- 3. 非法参数(非受检异常) ----
System.out.println("\n=== 非法参数 ===");
try {
acc1.deposit(-50); // 抛出 IllegalArgumentException
} catch (IllegalArgumentException e) {
System.out.println("❌ 参数错误:" + e.getMessage());
}
// ---- 4. 空指针检查(非受检异常) ----
System.out.println("\n=== 空转账 ===");
try {
acc1.transfer(null, 100); // 抛出 IllegalArgumentException
} catch (IllegalArgumentException | InsufficientBalanceException e) {
System.out.println("❌ 错误:" + e.getMessage());
}
// ---- 5. 显示最终余额 ----
System.out.println("\n=== 最终余额 ===");
System.out.println("张三账户:" + acc1.getBalance());
System.out.println("李四账户:" + acc2.getBalance());
// ---- 6. try-with-resources 演示 ----
System.out.println("\n=== try-with-resources 演示 ===");
try (java.io.FileWriter writer = new java.io.FileWriter("log.txt", true)) {
writer.write("程序运行完毕,时间:" + System.currentTimeMillis() + "\n");
System.out.println("日志写入成功");
} catch (java.io.IOException e) {
System.out.println("❌ 写入日志失败:" + e.getMessage());
}
}
}
运行这个程序,你会看到各种异常被优雅地捕获和处理,程序没有因为任何错误而崩溃。
8. 异常与性能
异常的处理是有开销的。当异常发生时,JVM 需要创建异常对象、填充堆栈轨迹(fillInStackTrace()),这比较耗时。因此:
- 不要用异常来控制正常的业务流程(比如循环中用异常来终止)。
- 在性能敏感的场景,尽量减少异常的抛出。
但也要注意:不要为了“优化性能”而牺牲代码的正确性和可维护性。在合理的范围内使用异常,代码会更清晰、更可靠。
9. 今天的总结
今天我们系统地学习了 Java 异常处理:
- 异常体系:
Throwable→Error/Exception→RuntimeException(未受检)和 Checked Exception(受检)。 try-catch-finally:捕获并处理异常,finally确保资源释放。try-with-resources:自动关闭资源,JDK 7+ 引入,更简洁。throw和throws:手动抛出异常和方法声明异常。- 自定义异常:继承
Exception或RuntimeException,携带业务信息。 - 最佳实践:不吞异常、使用具体类型、早抛出晚捕获、用异常处理意外情况。
异常处理是程序健壮性的重要保障。学会它,你的程序就能在各种意外情况下依然保持“风度和体面”——即使出错,也是友好地告知用户,而不是留下一堆难以理解的红字。
动手试试
- 写一个
divide(int a, int b)方法,如果b == 0抛出ArithmeticException(可以用内置的),并调用方用try-catch处理。 - 定义一个
NegativeNumberException(受检异常),在sqrt(double x)方法中,如果x < 0抛出该异常,并捕获处理。 - 写一个
FileProcessor类,用try-with-resources读取一个文本文件,统计文件中的行数,处理可能发生的FileNotFoundException和IOException。 - (挑战)改造第 16 篇的计算器项目,为其添加异常处理。当用户输入非数字时,用异常处理提示“输入无效,请重新输入”,而不是让程序崩溃。同时,为除法添加自定义
DivisionByZeroException(受检),并捕获处理。
异常处理看似简单,但用得好与不好,差距很大。多练习,培养“预见错误”的思维习惯。
我们下一篇会介绍 Record 的深入用法——这是 JDK 21 中非常重要的特性,值得专门写一篇来讲解它的高级应用和模式匹配。
我们下一篇见。😃
📌 获取本系列示例代码请访问 GitCode。
1044



被折叠的 条评论
为什么被折叠?



