异常体系与分类
本节定位:系统掌握 Java 异常机制的顶层设计——Throwable 继承体系。理解 Error、受检异常(Checked Exception)、非受检异常(RuntimeException)三个分支的设计意图与使用场景,为后续的异常处理打下理论基础。
场景引入
周一早会,飞翔科技架构师白歌在代码审查中发现实习生李眉的代码里到处是 catch (Exception e)。
"李眉,你为什么把所有异常都捕获成 Exception?"白歌皱着眉头问。
"因为……反正都是异常嘛,抓了不就行了?"李眉小声回答。
大翔接过话:"异常是分门别类的,就像医院分内科外科。头疼吃感冒药,骨折打石膏,你得先搞清楚异常的类型,才能对症下药。"
Throwable 体系全景图
Java 中所有异常和错误的根类是 java.lang.Throwable。整个体系分为三个分支:Error、受检异常、非受检异常。
三大分支详解
一、Error:JVM 的"不可抗力"
Error 表示 JVM 层面的严重问题,通常是程序无法处理的。原则:不要捕获 Error。
| Error 类型 | 触发原因 | 能否恢复 |
|---|---|---|
OutOfMemoryError | 堆内存耗尽,无法分配新对象 | 通常不可恢复 |
StackOverflowError | 方法递归调用过深,栈空间耗尽 | 通常不可恢复 |
NoClassDefFoundError | 编译时存在的类在运行时找不到 | 检查 classpath 可能恢复 |
NoSuchMethodError | 调用的方法在运行时不存在(版本不匹配) | 修正 jar 包版本 |
原则:Error 是 JVM 的"不可抗力",就像地震、海啸,程序不应该也不需要在代码层面处理。
// ❌ 错误示范:捕获 OutOfMemoryError
try {
byte[] huge = new byte[Integer.MAX_VALUE];
} catch (OutOfMemoryError e) {
// OOM 后 JVM 状态不确定,继续运行更危险
System.out.println("内存不够了,但程序继续...");
}
// 这段代码不仅无法真正解决问题,还可能让程序在不可预测的状态下继续执行
// ✅ 正确做法:预防而非治疗
// 1. 合理设置 JVM 参数(-Xmx, -Xms)
// 2. 避免内存泄漏(及时解除引用)
// 3. 使用软引用/弱引用
// 4. 如果真的 OOM,让程序崩溃并重启是更安全的选择
二、受检异常(Checked Exception):编译器强制检查
受检异常 = Exception 的子类,但不是 RuntimeException 的子类。
编译器强制要求程序处理这类异常:要么 try-catch,要么 throws 声明。如果不处理,编译不通过。
| 常见受检异常 | 触发场景 | 飞翔科技场景 |
|---|---|---|
IOException | I/O 操作失败 | 学生数据文件读写失败 |
FileNotFoundException | 文件不存在 | 导入的 Excel 学生名单找不到 |
SQLException | 数据库操作异常 | 查询学生信息时数据库连接断开 |
ClassNotFoundException | 动态加载类失败 | 加载数据库驱动类时出错 |
InterruptedException | 线程被中断 | 异步发送成绩通知时线程被中断 |
// 受检异常:编译器强制处理
public void importStudents(String filePath) {
// 编译错误!未处理 IOException
FileReader reader = new FileReader(filePath); // FileNotFoundException
}
// 必须处理:
// 方式一:try-catch
public void importStudents(String filePath) {
try {
FileReader reader = new FileReader(filePath);
// 读取文件逻辑...
} catch (FileNotFoundException e) {
System.err.println("文件不存在: " + filePath);
}
}
// 方式二:throws 声明
public void importStudents(String filePath) throws IOException {
FileReader reader = new FileReader(filePath);
// 读取文件逻辑...
}
三、非受检异常(Unchecked Exception / RuntimeException)
非受检异常 = RuntimeException 及其子类。
编译器不强制要求处理,但会在运行时抛出。通常源于程序逻辑错误。
| 常见非受检异常 | 触发原因 | 典型代码 |
|---|---|---|
NullPointerException | 对 null 引用调用方法 | str.length() 当 str == null |
ArrayIndexOutOfBoundsException | 数组索引越界 | arr[arr.length] |
IllegalArgumentException | 方法参数不合法 | Thread.sleep(-1) |
NumberFormatException | 字符串格式无法转为数字 | Integer.parseInt("abc") |
ArithmeticException | 算术运算异常 | int x = 1 / 0 |
ClassCastException | 类型转换失败 | (String) new Object() |
// 编译器不强制处理,但运行时可能炸
public double calculateAvgScore(int[] scores) {
int sum = 0;
for (int i = 0; i <= scores.length; i++) { // 注意:<=
sum += scores[i]; // 最后一次循环:ArrayIndexOutOfBoundsException
}
return (double) sum / scores.length;
}
受检异常 vs 非受检异常的哲学之争
受检异常(Checked):"编译器帮你发现潜在问题,你必须提前想好怎么处理"
非受检异常(Unchecked):"这些是你代码逻辑的问题,修好代码就行,不要硬 capture"
| 对比维度 | 受检异常 | 非受检异常 |
|---|---|---|
| 编译器检查 | 强制检查,不处理编译不通过 | 不检查 |
| 设计意图 | 可预见的可恢复的异常 | 程序逻辑错误、不可预见 |
| 典型场景 | IO 失败、数据库异常、网络异常 | NPE、数组越界、类型转换 |
| 处理策略 | 捕获并恢复,或向上抛 | 修复代码逻辑 |
| 是否可恢复 | 通常可以恢复 | 通常不应恢复 |
核心原理:异常对象的构造与捕获流程
异常对象从创建到被捕获的全过程
关键步骤:
- 创建异常对象:
new FileNotFoundException("...") - 填充调用栈:JVM 在构造异常对象时调用
fillInStackTrace(),记录当前线程的方法调用链 - 栈展开(Stack Unwinding):JVM 沿着调用栈从当前方法开始向上搜索
- 匹配 catch:检查每个方法的异常表(Exception Table),找到匹配的 catch 块
- 执行处理:跳转到 catch 块,将异常对象传递给 catch 参数
- 未捕获终止:如果整个调用栈都没有匹配的 catch,线程终止,JVM 打印异常栈
字节码层面的异常表
每个方法在编译后都会携带一个异常表(Exception Table),这是 JVM 实现 try-catch 的底层机制。
public void readStudentFile(String path) {
try {
FileReader reader = new FileReader(path); // ①
BufferedReader br = new BufferedReader(reader); // ②
String line = br.readLine(); // ③
System.out.println(line); // ④
} catch (FileNotFoundException e) { // ⑤
System.err.println("文件不存在");
} catch (IOException e) { // ⑥
System.err.println("读取失败");
}
}
编译后生成的异常表(伪代码):
| from | to | target | type |
|---|---|---|---|
| ① | ④ | ⑤ | FileNotFoundException |
| ① | ④ | ⑥ | IOException |
| ① | ④ | ⑥ | any(finally 块) |
编译原理详解:当异常在 from-to 范围内被抛出,如果异常类型与 type 匹配,JVM 将控制权跳转到 target 处的指令。这就是 "从上到下,精确匹配" 规则的字节码级实现。
飞翔科技实战:学生数据导入的异常分类
场景
小崔正在开发学生数据导入功能。用户上传 Excel 文件,系统解析后批量写入数据库。
架构师白歌帮他梳理可能出现的异常:
public class StudentImporter {
/**
* 导入学生数据
* @param filePath Excel 文件路径
* @throws StudentImportException 导入失败的自定义异常
*/
public void importStudentFromExcel(String filePath) throws StudentImportException {
// === 可能的异常分类 ===
// ① 受检异常:文件不存在 → 必须处理
File file = new File(filePath);
if (!file.exists()) {
throw new StudentImportException("文件不存在: " + filePath,
new FileNotFoundException(filePath));
}
// ② 受检异常:数据库连接失败 → 必须处理
try (Connection conn = DataSource.getConnection()) {
// ③ 非受检异常:空指针 → 代码逻辑问题
List<String> rows = ExcelParser.parse(filePath);
for (String row : rows) {
Student student = parseRow(row);
// ④ 非受检异常:参数不合法 → 提前校验
if (student.getName() == null || student.getName().isEmpty()) {
throw new IllegalArgumentException("学生姓名不能为空");
}
saveToDatabase(conn, student);
}
} catch (SQLException e) {
throw new StudentImportException("数据库操作失败", e);
}
}
}
| 编号 | 异常类型 | 分类 | 处理策略 |
|---|---|---|---|
| ① | FileNotFoundException | 受检异常 | 包装为业务异常抛出 |
| ② | SQLException | 受检异常 | 包装为业务异常抛出 |
| ③ | NullPointerException | 非受检异常 | 提前判空,不让它发生 |
| ④ | IllegalArgumentException | 非受检异常 | 提前校验参数,按需抛出 |
易错场景
反例一:用 catch (Exception) 一锅端
小崔初学时最喜欢的写法:
// ❌ 错误:捕获所有异常,掩盖了真实的错误类型
try {
studentDao.save(student);
emailService.sendNotification(student);
fileService.exportReport(student);
} catch (Exception e) {
// 简单打印,无法区分是数据库错误、邮件错误还是文件错误
e.printStackTrace();
}
问题:
- 无法区分异常类型,无法做针对性恢复
- 可能捕获了 RuntimeException 掩盖代码 bug
- 违反了"只捕获你知道怎么处理的异常"原则
// ✅ 正确:分别处理不同类型的异常
try {
studentDao.save(student);
emailService.sendNotification(student);
fileService.exportReport(student);
} catch (SQLException e) {
// 数据库异常:记录日志,通知 DBA
logger.error("数据库保存学生失败: {}", student.getId(), e);
throw new StudentServiceException("保存学生信息失败", e);
} catch (MessagingException e) {
// 邮件异常:降级处理,不影响主流程
logger.warn("邮件通知发送失败,学生已保存: {}", student.getId(), e);
} catch (IOException e) {
// 文件异常:记录日志,稍后重试
logger.error("导出报告失败: {}", student.getId(), e);
retryQueue.add(new ExportTask(student));
}
反例二:捕获 Error
// ❌ 错误:不应该捕获 Error
try {
recursiveMethod(0); // 可能 StackOverflowError
} catch (Error e) { // 捕获 Error 是极其危险的行为
System.out.println("继续运行");
}
// ✅ 正确:不要捕获 Error,让 JVM 处理
// 如果确实需要捕获,只能作为最后的防线(如记录日志后重新抛出)
try {
recursiveMethod(0);
} catch (Error e) {
logger.fatal("JVM 发生严重错误,即将退出", e);
throw e; // 必须重新抛出!
}
反例三:混淆受检和非受检
// ❌ 错误:对所有异常都用 try-catch 包起来
public String getStudentName(int id) {
try {
return studentDao.findById(id).getName();
} catch (NullPointerException e) { // 应该提前判空,而不是捕获 NPE
return "未知";
}
}
// ✅ 正确:用代码逻辑防御非受检异常
public String getStudentName(int id) {
Student student = studentDao.findById(id);
return student != null ? student.getName() : "未知";
}
面试考点
Q1:受检异常和非受检异常的本质区别是什么?
受检异常(Checked Exception)是
Exception的子类(但不含RuntimeException及其子类),编译器强制要求程序必须处理(try-catch 或 throws),否则编译不通过。设计意图是提示"这里有一个可以预见且可能恢复的异常情况",如文件不存在可以提示用户重新选择。非受检异常(Unchecked Exception)是RuntimeException及其子类,编译器不强制处理,通常由程序逻辑错误导致(如 NPE、数组越界),正确的处理方式不是捕获而是修复代码本身。
Q2:Error 和 Exception 有什么区别?
Error 表示 JVM 层面的严重问题(如
OutOfMemoryError、StackOverflowError),通常是程序无法处理的,不应该被捕获。Exception 表示程序可以处理的异常,分为受检和非受检。简单记忆:Error 是"天灾",Exception 是"人祸"——天灾挡不住,人祸可以防。
Q3:什么场景下使用受检异常,什么场景使用非受检异常?
受检异常用于"可预见且可恢复"的场景:文件不存在、网络超时、数据库连接失败——调用方有义务处理这些情况。非受检异常用于"程序逻辑错误"场景:空指针、参数非法、状态不一致——调用方无法通过代码逻辑恢复,应该修复代码本身。Spring 框架选择了"全部使用非受检异常"的策略(
DataAccessException继承RuntimeException),但 JDK 标准库仍大量使用受检异常。
Q4:catch (Exception e) 有什么风险?
① 捕获了
RuntimeException,掩盖了代码 bug(如 NPE),让程序在错误状态下继续运行;② 无法区分异常类型,无法做针对性处理;③ 可能意外捕获ThreadDeath等不应捕获的异常(尽管ThreadDeath继承 Error)。原则是:只捕获你知道怎么处理的异常,不知道的让它向上抛。
Q5:为什么 finally 块中的代码几乎总是执行?
finally 由 JVM 保证执行,机制是在编译时将 finally 块的代码复制到每个出口(正常 return、异常抛出)之前。唯一不执行的情况:① 在 try 或 catch 中调用了
System.exit();② 守护线程中所有非守护线程都结束了;③ JVM 崩溃。注意,即使 try 中有 return,finally 也会在 return 之前执行(这是字节码层面的保证)。