异常处理
Java 初级 · 进阶基础
程序运行中难免出错:文件不存在、网络中断、除零、空指针……Java 用「异常机制」把这些意外统一管理,让你在出错时优雅兜底,而不是整个程序崩溃。本节不只讲 try-catch 怎么写,更从 Throwable 体系、字节码异常表(exception table)、栈展开(stack unwinding)、try-with-resources 如何被翻译成字节码 讲透异常的本质与代价。
一、Throwable 体系:谁该被捕获
所有异常的根是 Throwable,分两支:
- Error:JVM 层面的严重故障(如
OutOfMemoryError、StackOverflowError),通常不应也不该捕获——程序已无能为力。 - RuntimeException(非受检):程序逻辑 bug 导致(空指针、越界、除零),编译不强制处理。正确做法是改代码避免,而非靠 catch 兜。
- 受检异常(Checked):外部环境导致(IO 失败、SQL 异常),编译期强制
try-catch或throws,逼你正视「这可能失败」。
受检 vs 非受检的设计哲学
受检异常用于「调用方必须处理或显式声明」的、可预期的外部失败(如文件打不开);非受检异常用于「本不该发生的编程错误」。滥用受检异常会让签名充斥 throws,所以现代做法(如 Spring)常把受检异常包装成非受检(RuntimeException)以减少噪音——但初学仍要分清两者。
二、try-catch-finally 的执行流转
try {
int[] a = {1};
System.out.println(a[1]); // 抛 ArrayIndexOutOfBoundsException
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("越界:" + e.getMessage()); // 捕获处理
} finally {
System.out.println("无论是否异常都执行"); // 收尾(关资源等)
}执行顺序铁律:try 正常完成 → finally;try/catch 中遇到 return → 先执行 finally 再真正返回。finally 用于「无论如何都要做」的事(释放锁、关流)。
三、异常的底层:字节码异常表与栈展开
try-catch 不是运行时逐层 if 判断,而是编译进 .class 的异常表(exception table):
Exception table:
from to target type
0 5 8 Class ArrayIndexOutOfBoundsException
// 若 PC 在 [0,5) 抛该类型异常,跳转到 PC=8 的 catch 处理每个 try 块在字节码里对应异常表的一行:[start_pc, end_pc) 是监控范围,handler_pc 是 catch 起点,catch_type 是捕获的异常类。运行时若抛出异常:
- JVM 沿当前方法异常表找匹配项,命中就跳到 handler。
- 若当前方法没有匹配的 catch,JVM 把当前栈帧弹出(栈展开 stack unwinding),带着异常对象回到调用者,重复查找——这就是「异常一直向上冒泡」的底层过程。
- 直到某个方法 catch 住,或一路冒到
main仍没人处理,线程终止并打印堆栈(printStackTrace的逻辑)。
异常有性能代价
抛出异常要填充栈轨迹(stack trace)——遍历整个调用栈收集每一帧,开销不小。因此绝不要用异常做正常流程控制(比如用抛异常来「跳出循环」或替代 if 判断)。异常只用于真正的「意外错误」,频繁触发的路径用返回值/Optional 表达。
四、throws 与 throw
throw new XxxException("..."):手动抛出一个异常实例。throws XxxException:在方法签名上声明「本方法可能抛出受检异常」,把处理责任交给调用方。
static int divide(int a, int b) throws ArithmeticException {
if (b == 0) throw new ArithmeticException("除数不能为 0");
return a / b;
}自定义业务异常
用继承自 RuntimeException 的自定义异常表达业务错误(如 BalanceNotEnoughException),好处是不污染方法签名(非受检,无需到处 throws),调用方可自由选择是否捕获:
if (balance < cost) throw new BusinessException("用户 " + uid + " 余额不足");异常信息要带上下文(如用户 ID),排错时才好定位。
五、try-with-resources:自动关资源的字节码真相
Java 7+ 的 try-with-resources(TWR) 让实现 AutoCloseable 的资源(流、连接、Channel)在块结束自动关闭,无需手写 finally:
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
String line = br.readLine();
System.out.println(line);
} catch (IOException e) {
e.printStackTrace();
}它编译后长什么样?
TWR 本质是编译器语法糖:编译后变成「在 try 外套 finally,依次调用 close(),并把关闭时的异常通过 addSuppressed 挂到主异常上」。大致等价:
BufferedReader br = new BufferedReader(new FileReader("data.txt"));
Exception ex = null;
try {
... 使用 ...
} catch (Exception e) {
ex = e; throw e;
} finally {
if (br != null) {
if (ex != null) br.close(); // 关闭异常被 addSuppressed
else br.close();
}
}AutoCloseable 接口只有一个方法 void close() throws Exception,任何资源类实现它就能享受 TWR。多个资源用 ; 分隔:try (A a = ...; B b = ...) 会按声明逆序关闭。
finally 里写 return 会吞异常
若 finally 中有 return,它会覆盖 try/catch 里的返回值,且会掩盖已抛出的异常(异常被丢弃,调用方毫无察觉)。永远不要在 finally 里 return,也不要在 finally 里抛异常掩盖主逻辑异常——这正是 TWR 用 addSuppressed 精心规避的问题。
六、常见误区与注意点
WARNING
- 捕获了却不处理:
catch (Exception e) {}空着,bug 被悄悄吞掉,极难排查。至少打日志或重新抛出。 - catch 顺序写反:子类异常必须写在父类(
Exception)之前,否则编译报错或永远命中宽泛分支、子类 catch 成死代码。 - 用异常做正常流程控制:开销大,不要用
try-catch代替if判断(如用异常判断是否越界)。 - finally 里 return:覆盖返回值且吞掉异常,应避免。
- 笼统 catch Exception 再忽略:过宽捕获会连
RuntimeException也兜住,掩盖真正的编程错误;尽量 catch 具体类型。
七、最佳实践
- 只捕获你能合理处理的异常,捕获具体类型而非宽泛
Exception。 - 资源一律用 try-with-resources 自动关闭,减少泄漏与样板代码。
- 业务异常自定义并继承
RuntimeException(或Exception视需),避免到处throws。 - 异常信息带上下文(如「用户ID=123 余额不足」),便于排错;用
addSuppressed保留关闭异常。 - 别用异常控制流程;别在
finally写return;catch 到就处理或明确向上抛,别静默吞掉。
📚 官方文档与参考资料
- Java SE 21 文档:https://docs.oracle.com/en/java/javase/21/
- Java 教程 · 异常处理:https://docs.oracle.com/javase/tutorial/essential/exceptions/
- Java 语言规范 · 异常:https://docs.oracle.com/javase/specs/jls/se21/html/jls-11.html
- OpenJDK 项目:https://openjdk.org/
✏️ 练习与自测
- 写一个读取数组元素的方法,用
try-catch捕获越界异常并打印友好提示;用javap -c查看生成的异常表(Exception table段)。 - 定义
AgeException(继承RuntimeException),当年龄 < 0 或 > 150 时抛出;在调用处用try-catch处理。 - 用 try-with-resources 读写一个文本文件,体会自动关闭;故意在
try块里抛异常,再在资源close()里抛异常,观察getSuppressed()如何保留两个异常。 - 写一个方法故意在
finally中return,调用它并观察:已知try块会抛异常,调用方能拿到这个异常吗?解释为什么(finally return 吞异常)。
本节小结
异常体系:Error(不 catch,如 OOM)↙ Exception → RuntimeException(非受检,靠改代码避免)与受检异常(编译强制处理)。try-catch 底层是 .class 里的异常表(监控区间 + handler_pc + catch_type),抛异常时 JVM 查表,不匹配就栈展开逐层回退直到被 catch 或线程终止。throws 声明、throw 抛出;自定义业务异常继承 RuntimeException 更清爽。Java 7+ 的 try-with-resources 是语法糖:编译为 finally 中逆序调 close(),关闭异常经 addSuppressed 保留,避免资源泄漏与异常掩盖。异常有性能代价,勿作流程控制;finally 禁 return(吞异常)。