try-catch-finally
本节定位:深入理解 try-catch-finally 的语法规则、执行流程和底层实现。掌握多个 catch 块的匹配顺序、finally 的执行时机,以及 finally 中 return 的经典陷阱。从字节码异常表的角度理解这一切是如何运作的。
场景引入
飞翔科技的实习生李眉写了一段文件读取代码,自认为万无一失:
public String readFirstLine(String path) {
try {
BufferedReader reader = new BufferedReader(new FileReader(path));
return reader.readLine();
} catch (Exception e) {
System.out.println("出错了");
} finally {
System.out.println("清理资源");
}
return null;
}
大翔审查后指出了三个问题:
- catch 块吞掉了异常,调用方完全不知道发生了什么
- reader 没有关闭,如果文件很大,会导致文件句柄泄漏
- catch (Exception) 抓了太多不该抓的东西
"try-catch-finally 看起来简单,"大翔说,"但魔鬼都在细节里。"
基础语法
try-catch 语法
try {
// 可能抛出异常的代码块
// ...
} catch (ExceptionType1 e) {
// 处理 ExceptionType1 及其子类的异常
} catch (ExceptionType2 e) {
// 处理 ExceptionType2 及其子类的异常
}
// 后续代码
| 组成部分 | 说明 | 是否必需 |
|---|---|---|
try 块 | 包含可能抛出异常的代码 | 必需 |
catch 块 | 捕获并处理指定类型的异常 | 至少一个(或 finally) |
finally 块 | 无论是否异常都会执行的代码 | 可选 |
约束规则:
| 规则 | 说明 |
|---|---|
| try 不能单独存在 | 必须至少跟随一个 catch 或一个 finally |
| catch 不能单独存在 | 必须跟在 try 之后 |
| 多 catch 顺序 | 子类异常必须在父类异常之前 |
| finally 最多一个 | 如果有,必须在所有 catch 之后 |
catch 块的匹配机制
从上到下、精确匹配
JVM 在 catch 块中按照书写顺序从上到下匹配异常类型。一旦匹配成功,后续的 catch 块不再执行。
try {
FileReader reader = new FileReader("students.txt");
} catch (FileNotFoundException e) { // ① 先匹配:子类 FileNotFoundException
System.out.println("文件未找到");
} catch (IOException e) { // ② 后匹配:父类 IOException
System.out.println("IO 异常");
}
匹配流程:
多 catch 块顺序的铁律:子类在前,父类在后
// ❌ 编译错误!父类在前,后面的子类 catch 永远不会执行
try {
FileReader reader = new FileReader("test.txt");
} catch (IOException e) { // 父类在前
System.out.println("IO 异常");
} catch (FileNotFoundException e) { // 编译错误:unreachable catch block
System.out.println("文件未找到"); // 永远不会被执行!
}
// ✅ 正确:子类在前
try {
FileReader reader = new FileReader("test.txt");
} catch (FileNotFoundException e) { // 子类在前
System.out.println("文件未找到");
} catch (IOException e) { // 父类在后
System.out.println("IO 异常");
}
JDK 7+ 多异常捕获语法:如果多个 catch 块的处理逻辑相同,可以用
|合并:catch (FileNotFoundException | SQLException e) { System.out.println("文件或数据库异常: " + e.getMessage()); } // 注意:合并的异常类型不能有父子关系,且 e 隐式为 final
finally 块:不可靠世界中的可靠保证
核心规则
finally 块中的代码无论是否发生异常都会执行(除非 JVM 崩溃或 System.exit())。
public int testFinally() {
try {
System.out.println("1. try 块");
int x = 10 / 0; // 抛出 ArithmeticException
return 100; // 这行不会执行
} catch (ArithmeticException e) {
System.out.println("2. catch 块");
return 200; // 注意:return 前会先执行 finally
} finally {
System.out.println("3. finally 块");
}
}
// 输出:
// 1. try 块
// 2. catch 块
// 3. finally 块
// 返回: 200
finally 执行的四种场景
| 场景 | try 中情况 | catch 中情况 | finally 是否执行 |
|---|---|---|---|
| 正常执行 | 无异常 | 不执行 | ✅ 执行 |
| 捕获异常 | 抛出异常 | 捕获并处理 | ✅ 执行 |
| 未捕获异常 | 抛出异常 | 未匹配 | ✅ 执行(然后异常继续传播) |
| System.exit() | 调用 exit | — | ❌ 不执行 |
| return | return 语句 | — | ✅ 在 return 之前执行 |
深度原理:异常表的字节码实现
编译前后对比
让我们深入 JVM 层面,理解 try-catch-finally 是如何被编译成字节码的。
源代码:
public static int divide(int a, int b) {
try {
int result = a / b;
return result;
} catch (ArithmeticException e) {
System.out.println("除数不能为零");
return -1;
} finally {
System.out.println("计算结束");
}
}
编译后的等价伪代码(展示 finally 的"复制"机制):
// JVM 将 finally 块代码复制到每个出口前
public static int divide(int a, int b) {
try {
int result = a / b;
// --- finally 代码复制到正常 return 前 ---
System.out.println("计算结束");
return result;
} catch (ArithmeticException e) {
System.out.println("除数不能为零");
// --- finally 代码复制到 catch 的 return 前 ---
System.out.println("计算结束");
return -1;
} catch (Throwable t) { // 隐式 catch:捕获未被匹配的异常
// --- finally 代码复制到异常抛出前 ---
System.out.println("计算结束");
throw t; // 重新抛出
}
}
字节码异常表分析:
假设上述 divide 方法编译后的字节码偏移如下:
| 字节码偏移 | 源码对应 |
|---|---|
| 0-5 | int result = a / b |
| 6-8 | 准备 return result |
| 9-12 | System.out.println("除数不能为零") |
| 13-15 | return -1 |
| 16-18 | System.out.println("计算结束") ← finally 块(被复制到多处) |
生成的异常表(Exception Table):
| from | to | target | type |
|---|---|---|---|
| 0 | 5 | 9 | ArithmeticException |
| 0 | 5 | 16 | any |
| 9 | 12 | 16 | any |
异常表解读:
- 第1行:如果 0-5 偏移处抛出
ArithmeticException,跳转到 9(catch 块)- 第2行:如果 0-5 偏移处抛出任何其他异常,跳转到 16(finally 块)
- 第3行:如果 catch 块自身抛出异常,跳转到 16(finally 块)
- 这就是 finally "无论是否异常都能执行" 的字节码级保证
核心陷阱:finally 中的 return
陷阱代码
这是 Java 异常处理中最经典的面试题之一:
public static int test() {
int x = 1;
try {
return x; // ① 准备返回 1
} finally {
x = 2; // ② 修改 x
return x; // ③ 返回 2!覆盖了 try 的返回值
}
}
public static void main(String[] args) {
System.out.println(test()); // 输出: 2
}
为什么会输出 2?
JVM 执行细节:
return x被拆分为两步:先计算返回值(将 x 的值 1 暂存到局部变量),再执行返回指令- 在返回指令执行前,JVM 先执行 finally 块
- finally 中的
x = 2修改了局部变量 x,但返回值已经暂存为 1 - 但是!finally 中又执行了
return x,这个 return 覆盖了之前的暂存返回值 - 最终方法返回 2
// 更详细的分析
public static int test2() {
int x = 1;
try {
return x; // 返回值暂存为 1,等待 finally 执行完后再返回
} finally {
x = 2; // 修改 x,但不影响已暂存的返回值
// 没有 return,使用 try 的暂存返回值
}
// 实际返回:1(finally 没有 return 时,返回暂存值)
}
public static int test3() {
int x = 1;
try {
return x; // 返回值暂存为 1
} finally {
x = 2;
return x; // 覆盖暂存值,返回 2
}
}
完整验证代码
public class FinallyReturnTrap {
public static int case1() {
try {
return 10;
} finally {
return 20; // finally 中有 return
}
}
public static int case2() {
int x = 10;
try {
return x;
} finally {
x = 20;
// finally 中没有 return
}
}
public static String case3() {
StringBuilder sb = new StringBuilder("hello");
try {
return sb.toString(); // 返回 "hello" 字符串(不可变)
} finally {
sb.append(" world"); // 修改 sb,但不影响已创建的字符串
}
}
public static StringBuilder case4() {
StringBuilder sb = new StringBuilder("hello");
try {
return sb; // 返回对象引用
} finally {
sb.append(" world"); // 修改了对象内容!返回的引用指向同一个对象
}
}
public static void main(String[] args) {
System.out.println("case1: " + case1());
// 输出:case1: 20
// 原因:finally 中的 return 覆盖了 try 的 return
System.out.println("case2: " + case2());
// 输出:case2: 10
// 原因:finally 没有 return,使用 try 的暂存返回值
System.out.println("case3: " + case3());
// 输出:case3: hello
// 原因:String 不可变,sb.toString() 已经创建了新字符串
System.out.println("case4: " + case4());
// 输出:case4: hello world
// 原因:返回的是 StringBuilder 对象引用,finally 修改了对象内容
}
}
运行输出:
case1: 20
case2: 10
case3: hello
case4: hello world
切记:永远不要在 finally 中写 return!它会让代码行为变得难以预测,且多数代码审查工具(如 SonarQube)会将此标记为严重问题。
飞翔科技实战:学生管理系统中的异常处理
场景一:学生成绩导入
小崔写了一个成绩导入方法,需要从文件读取成绩数据并写入数据库。
public class ScoreImporter {
private static final Logger logger = LoggerFactory.getLogger(ScoreImporter.class);
/**
* 导入学生成绩
* @param filePath 成绩数据文件路径
* @return 成功导入的记录数
*/
public int importScores(String filePath) {
BufferedReader reader = null;
Connection conn = null;
int importedCount = 0;
try {
// ① 打开文件
reader = new BufferedReader(new FileReader(filePath));
// ② 获取数据库连接
conn = DataSourceManager.getConnection();
conn.setAutoCommit(false); // 开启事务
String line;
while ((line = reader.readLine()) != null) {
String[] parts = line.split(",");
String studentId = parts[0];
int score = Integer.parseInt(parts[1]);
// ③ 插入成绩记录
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO scores (student_id, score) VALUES (?, ?)");
ps.setString(1, studentId);
ps.setInt(2, score);
ps.executeUpdate();
importedCount++;
}
// ④ 提交事务
conn.commit();
logger.info("成绩导入成功,共 {} 条记录", importedCount);
} catch (FileNotFoundException e) {
logger.error("成绩文件不存在: {}", filePath, e);
// 提示用户重新选择文件
throw new ImportException("文件不存在,请检查路径", e);
} catch (IOException e) {
logger.error("读取成绩文件失败", e);
throw new ImportException("文件读取失败", e);
} catch (SQLException e) {
logger.error("数据库操作失败,已回滚", e);
// 回滚事务
if (conn != null) {
try {
conn.rollback();
} catch (SQLException ex) {
logger.error("回滚失败", ex);
}
}
throw new ImportException("成绩数据保存失败", e);
} catch (NumberFormatException e) {
logger.error("成绩数据格式错误", e);
throw new ImportException("文件包含非法数字格式", e);
} finally {
// ⑤ 关闭资源(严格按照打开顺序反向关闭)
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
logger.warn("关闭文件读取器失败", e);
}
}
if (conn != null) {
try {
conn.setAutoCommit(true); // 恢复默认
conn.close();
} catch (SQLException e) {
logger.warn("关闭数据库连接失败", e);
}
}
}
return importedCount;
}
}
运行输出(正常情况):
2024-01-15 09:30:00 INFO c.f.s.importer.ScoreImporter - 成绩导入成功,共 150 条记录
运行输出(文件不存在):
2024-01-15 09:31:00 ERROR c.f.s.importer.ScoreImporter - 成绩文件不存在: /data/scores.csv
com.feixiang.exception.ImportException: 文件不存在,请检查路径
at com.feixiang.student.importer.ScoreImporter.importScores(ScoreImporter.java:45)
Caused by: java.io.FileNotFoundException: /data/scores.csv (系统找不到指定的路径。)
at java.io.FileInputStream.open0(Native Method)
at java.io.FileInputStream.open(FileInputStream.java:195)
...
场景二:finally 资源关闭的演进
// JDK 6 及之前:手动关闭,嵌套 try-catch,代码臃肿
public String readStudentName(String path) {
FileInputStream fis = null;
try {
fis = new FileInputStream(path);
byte[] data = new byte[1024];
int len = fis.read(data);
return new String(data, 0, len);
} catch (IOException e) {
logger.error("读取失败", e);
return null;
} finally {
if (fis != null) {
try {
fis.close(); // close() 也抛异常!需要嵌套 try-catch
} catch (IOException e) {
logger.warn("关闭流失败", e);
}
}
}
}
// JDK 7+:try-with-resources,自动关闭,简洁优雅
// (详见本章 try-with-resources 节)
public String readStudentName(String path) {
try (FileInputStream fis = new FileInputStream(path)) {
byte[] data = new byte[1024];
int len = fis.read(data);
return new String(data, 0, len);
} catch (IOException e) {
logger.error("读取失败", e);
return null;
}
}
易错场景
反例一:catch 块顺序错误
// ❌ 编译错误:IOException 在前,FileNotFoundException 永远匹配不到
try {
Files.readAllLines(Paths.get("students.txt"));
} catch (IOException e) { // 父类
System.out.println("IO异常");
} catch (FileNotFoundException e) { // 子类,unreachable!
System.out.println("文件未找到");
}
// 编译错误信息:
// error: exception FileNotFoundException has already been caught
反例二:捕获了异常但不处理(吞异常)
// ❌ 最糟糕的做法:吞掉异常
try {
studentDao.save(student);
} catch (SQLException e) {
// 什么都不做,异常被吞掉了!
// 数据没有保存成功,但调用方完全不知情
}
// ✅ 正确做法
try {
studentDao.save(student);
} catch (SQLException e) {
// 至少记录日志
logger.error("保存学生信息失败: {}", student.getId(), e);
// 包装为业务异常重新抛出,让调用方知情
throw new StudentServiceException("保存学生信息失败", e);
}
反例三:finally 中 return
// ❌ 经典陷阱:finally 中的 return 吞掉了异常
public int riskyMethod() {
try {
throw new RuntimeException("严重错误");
} finally {
return 0; // 异常被吞掉了!调用方拿到 0,完全不知道出了异常
}
}
// 调用方:
int result = riskyMethod(); // 返回 0,没有任何异常信息
System.out.println("结果:" + result); // 结果:0(浑然不知出过错)
// ✅ 正确:finally 只做清理,不改变控制流
public int riskyMethod() throws RuntimeException {
try {
throw new RuntimeException("严重错误");
} finally {
// 只做清理,不 return、不 throw(除非万不得已)
cleanupResources();
}
}
反例四:在 finally 中抛出异常,覆盖原始异常
// ❌ 错误:finally 中的异常覆盖了原始异常
public void processFile(String path) throws Exception {
FileInputStream fis = null;
try {
fis = new FileInputStream(path);
// 假设这里抛出了 IOException
int result = 10 / 0; // ArithmeticException(原始异常)
} finally {
if (fis != null) {
fis.close(); // close 也抛出了 IOException!
// 原始 ArithmeticException 被 finally 的 IOException 覆盖了
}
}
}
// ✅ 正确:JDK 7+ try-with-resources 利用异常抑制(suppressed)机制
// 原始异常为主异常,close() 的异常被抑制
try (FileInputStream fis = new FileInputStream(path)) {
int result = 10 / 0; // ArithmeticException(主异常)
}
// close() 的 IOException 被添加到主异常的 suppressed 列表中
面试考点
Q1:finally 块中的代码一定会执行吗?
基本是的,但有例外:①
System.exit()被调用(JVM 进程终止);② 守护线程中所有非守护线程结束,JVM 退出;③ JVM 崩溃(如OutOfMemoryError后没有足够内存执行 finally);④Runtime.halt()强制终止。注意:return、break、continue都不会阻止 finally 执行。
Q2:为什么 finally 中不应该写 return?
① finally 中的 return 会覆盖 try 或 catch 中的 return 值;② 会吞掉任何未被捕获的异常,让调用方误以为一切正常;③ 违反了 finally 的语义——finally 应该只做清理,不应该改变方法的控制流。多数静态分析工具将其标记为严重缺陷。
Q3:try-catch-finally 中,如果 try 中 return 了,finally 什么时候执行?
在 try 的 return 语句执行到一半时:JVM 先计算返回值并暂存,然后执行 finally 块,最后将暂存的返回值返回。如果 finally 中也有 return,则覆盖暂存值(这就是不应在 finally 中 return 的原因)。
Q4:多 catch 块的匹配顺序是什么?
按书写顺序从上到下匹配,匹配成功后不再检查后续 catch。因此子类异常 catch 必须写在父类之前,否则编译错误(unreachable catch block)。JDK 7+ 支持多异常合并
catch (A | B e),但 A 和 B 不能有继承关系。
Q5:异常被捕获后,调用栈是怎样的?
异常被 catch 后,调用栈仍然存在(可以通过
e.getStackTrace()获取),只是异常不再向上传播。如果 catch 中重新 throw 了新的异常,原始异常可以通过异常链(new Exception("msg", originalException))关联,此时原始异常的栈信息保留在getCause()中。