1. 完整回答
整个异常体系的根类是 Throwable,下面主要分为 Error 和 Exception 两大类。
Error 通常表示比较严重的系统级问题,比如 OutOfMemoryError 和 StackOverflowError,一般不建议在业务代码中捕获处理。
Exception 表示应用程序可能遇到的异常,又可以分为受检异常和非受检异常。
受检异常,比如 IOException、SQLException,编译器要求必须捕获或者通过 throws 声明抛出。
非受检异常主要是 RuntimeException 及其子类,比如 NullPointerException、IllegalArgumentException,编译器不强制处理,通常和参数不合法、程序逻辑错误等问题有关。
在处理机制上,Java 通过 throw 抛出异常,通过 try-catch 捕获异常,通过 finally 执行清理逻辑。如果当前方法没有处理异常,异常就会沿着方法调用栈向上查找匹配的处理器。
另外,Java 7 引入了 try-with-resources,可以自动关闭实现了 AutoCloseable 接口的资源,减少资源泄漏。
实际开发中,异常处理一般遵循能够处理就捕获,无法处理就向上抛出的原则。比如参数校验失败可以抛出业务异常,再由全局异常处理器统一转换成接口错误响应,同时保留异常原因和日志,方便问题排查。
2. 核心原理与关键概念
2.1 Java 异常体系结构
Throwable
├── Error
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── LinkageError
│
└── Exception
├── IOException
├── SQLException
├── ClassNotFoundException
│
└── RuntimeException
├── NullPointerException
├── IllegalArgumentException
├── IndexOutOfBoundsException
├── ClassCastException
└── ArithmeticException
主要分为三类:
| 类型 | 典型异常 | 特点 |
|---|---|---|
Error |
OutOfMemoryError、StackOverflowError |
严重错误,通常难以在业务层恢复 |
| 受检异常(Checked Exception) | IOException、SQLException |
编译器强制捕获或声明 |
| 非受检异常(Unchecked Exception) | RuntimeException 及其子类,以及 Error |
编译器不强制捕获或声明 |
这里需要区分两个维度:
-
继承体系:
Throwable分为Error和Exception。 -
编译检查:
RuntimeException和Error及其子类属于非受检异常,其余Throwable子类属于受检异常。
2.2 异常传播的底层机制
异常处理的核心是方法调用栈展开和异常处理器匹配。
流程如下:
异常发生 → 创建或获取异常对象 → 抛出异常 → 查找匹配的 catch → 沿调用栈向上传播 → 处理异常或交给未捕获异常处理机制
具体过程:
-
抛出异常:程序执行
throw,或者 JVM 在运行过程中检测到异常,例如空指针访问。 -
中断当前流程:异常抛出后,当前执行路径中剩余的普通语句不会继续执行。
-
查找处理器:先检查当前作用域是否存在能够匹配异常类型的
catch。 -
向上传播:如果当前方法无法处理,就退出当前方法,继续查找调用方的异常处理器。
-
最终处理:找到匹配的
catch后执行处理逻辑;如果始终没有找到,就进入线程的未捕获异常处理机制。
例如:
Controller
↓
Service
↓
Repository
↓
抛出 SQLException
↓
Service 未处理,继续传播
↓
Controller 或统一异常处理层处理
异常向上传播时,不会重新执行出错的方法,而且栈展开过程中仍然会执行相应的 finally 清理逻辑。
2.3 异常对象包含哪些信息?
Throwable 主要保存以下信息:
| 信息 | 对应方法 | 作用 |
|---|---|---|
| 异常类型 | getClass() |
确定异常种类 |
| 异常消息 | getMessage() |
描述错误原因 |
| 堆栈轨迹 | getStackTrace() |
定位异常发生和调用的位置 |
| 原始原因 | getCause() |
查看引发当前异常的底层异常 |
| 被抑制异常 | getSuppressed() |
查看资源关闭等过程中产生的附加异常 |
例如,数据库访问失败后,可以封装成业务层异常,同时通过 cause 保留原始异常,方便沿着异常链排查问题。
2.4 常见异常处理方式
| 关键字或机制 | 作用 |
|---|---|
try |
包围可能发生异常的代码 |
catch |
捕获并处理匹配的异常 |
finally |
执行清理逻辑 |
throw |
主动抛出异常对象 |
throws |
声明方法可能向外传播的异常 |
try-with-resources |
自动关闭资源 |
在实际项目中,通常使用 try-catch 处理能够恢复的问题,使用 throws 让上层决定处理策略,使用 try-with-resources 管理可关闭资源。
3. 高频面试追问
3.1 受检异常和非受检异常有什么区别?为什么 Java 要这样设计?
核心区别是编译器是否强制处理。
受检异常要求调用方通过 try-catch 捕获,或者使用 throws 声明向上抛出,比如 IOException。
非受检异常不要求显式处理,比如 NullPointerException、IllegalArgumentException。
Java 这样设计,主要是为了区分不同的错误处理责任。
受检异常通常用于调用方有机会采取恢复措施的情况,比如文件读取失败,可以更换文件或者提示用户重试。
非受检异常通常用于程序逻辑错误、参数不合法等情况,这类问题往往需要修改代码或者由上层统一处理。
不过,受检和非受检只是编译期的分类,不代表异常一定能否恢复。实际选择时,还需要考虑调用方有没有合理的处理方式,以及异常声明会不会让接口过于复杂。
3.2 Error 和 Exception 有什么区别?Error 能被捕获吗?
Error 和 Exception 都继承自 Throwable,但表达的问题性质不同。
Error 通常代表严重的运行环境或系统级问题,比如堆内存不足导致的 OutOfMemoryError,或者线程调用栈溢出导致的 StackOverflowError。
Exception 更多表示应用程序运行过程中出现的异常,比如文件读取失败、参数不合法等。
从语法上看,Error 可以被 catch 捕获,catch(Throwable) 也能捕获它。
但业务代码通常不建议捕获这类错误,因为程序可能已经处于不可靠的状态,捕获后继续运行也未必安全。
3.3 throw 和 throws 有什么区别?
throw 用于真正抛出异常,throws 用于声明方法可能向外传播的异常。
throw 写在方法体内部,后面跟一个异常对象。例如参数不合法时,可以通过 throw new IllegalArgumentException() 主动终止当前流程。
throws 写在方法声明上,后面跟异常类型,例如 throws IOException,表示方法可能把这个异常交给调用方处理。
另外,throws 本身不会产生异常,实际是否抛出取决于方法执行过程。
对于受检异常,如果方法可能向外传播,就必须进行相应的声明;对于非受检异常,声明是可选的。
3.4 finally 一定会执行吗?try 中有 return 会怎样?
finally 通常会执行,包括 try 中发生异常或者执行 return 的情况,但不能保证所有情况下都执行。
正常情况下,执行顺序是:
try 执行 → 产生返回结果或异常 → 执行 finally → 完成返回或异常传播
例如,try 中执行 return 1,finally 中只是打印日志,那么最终仍然返回 1。
但如果 finally 中又执行了 return 2,最终返回值就会变成 2。
同样,如果 try 中抛出了异常,而 finally 又抛出新的异常,新的异常会覆盖原来的异常。
另外,调用 System.exit() 导致 JVM 退出、进程被强制终止等情况下,finally 可能来不及执行。
所以,不建议在 finally 中使用 return 或主动抛出新异常,避免覆盖原有的执行结果。
继续追问:finally 中修改变量,会影响 try 的返回值吗?
如果返回的是基本类型或者不可变引用值,return 表达式的结果已经在执行 finally 前确定,修改局部变量不会改变这个结果。
如果返回的是对象引用,finally 修改了该对象的内部状态,调用方拿到的对象可能体现这些修改。
3.5 try-with-resources 和 finally 有什么区别?为什么推荐前者?
两者都能用于资源清理,但 try-with-resources 更适合管理需要关闭的资源。
传统方式需要在 finally 中手动调用 close(),并考虑空值判断、关闭异常等问题。
try-with-resources 会自动关闭实现了 AutoCloseable 接口的资源,例如文件流、数据库连接等。
执行流程是:
初始化资源 → 执行业务代码 → 按初始化的相反顺序关闭资源 → 传播异常或正常结束
它还有一个重要特性,就是被抑制异常(Suppressed Exception)。
假设读取文件时发生异常,同时关闭文件流也发生异常,try-with-resources 会优先保留读取过程中的原始异常,把关闭时产生的异常记录为被抑制异常,可以通过 getSuppressed() 获取。
这样能够保留更多错误信息,避免资源关闭异常覆盖真正的业务异常。
3.6 多个 catch 的执行顺序是什么?为什么子类异常要放前面?
多个 catch 按照代码顺序进行匹配,匹配成功后只执行对应的处理块。
例如,FileNotFoundException 是 IOException 的子类。
如果先捕获 IOException,再捕获 FileNotFoundException,后面的分支永远不可能执行,编译器会直接报错。
因此,处理顺序应该是:
具体异常 → 父类异常 → 更通用的异常
Java 7 还支持多异常捕获,可以通过 catch (IOException | SQLException e) 合并处理逻辑相同的异常。
但多异常捕获中的异常类型不能存在父子继承关系。
实际开发中,优先捕获能够明确处理的异常类型,避免过度使用 catch(Exception),否则可能掩盖程序缺陷。
3.7 子类重写父类方法时,可以抛出更多异常吗?
子类重写方法时,不能新增或扩大父类方法不允许的受检异常范围。
例如,父类方法声明了 throws IOException,子类可以继续声明 IOException,也可以声明它的子类 FileNotFoundException,或者不声明任何异常。
但不能新增 SQLException 这样的无关受检异常,也不能扩大成 Exception。
原因是为了保证多态调用时的异常契约。
调用方按照父类方法的声明编写处理逻辑,实际运行时即使调用的是子类方法,也不应该突然出现额外的受检异常处理要求。
不过,非受检异常不受这一限制,子类可以抛出新的 RuntimeException。
3.8 实际项目中如何设计自定义异常?异常应该在哪一层处理?
自定义异常主要用于表达明确的业务失败原因,通常在能够统一处理的位置捕获。
比如订单库存不足,可以定义 InsufficientStockException,让调用方明确知道失败原因。
如果异常需要调用方强制处理,可以继承 Exception;如果希望由统一异常处理机制处理,通常继承 RuntimeException。
在典型的 Spring 项目中,可以采用这样的流程:
Service 检测业务异常 → 抛出自定义异常 → 全局异常处理器捕获 → 转换为统一错误响应
例如库存不足时,Service 抛出业务异常,Controller 层通过 @RestControllerAdvice 配合 @ExceptionHandler 返回明确的业务错误码和提示信息。
如果异常来自数据库或远程调用,封装成上层异常时,应该通过 cause 保留原始异常,方便排查。
另外,异常日志应当在合适的边界统一记录,避免每层都重复打印完整堆栈。无法处理的异常也不要简单捕获后忽略。
继续追问:为什么不建议使用异常控制正常业务流程?
因为异常适合表达异常情况,而常规分支可以通过条件判断或明确的返回结果处理。
频繁创建异常对象和收集堆栈信息存在额外开销,而且会增加控制流程的理解难度。
例如查询数据为空是正常业务结果时,可以返回空集合或者 Optional,没有必要每次都抛异常。
3.9 Spring 事务遇到异常一定会回滚吗?
不一定。Spring 声明式事务是否回滚,取决于异常类型、回滚规则,以及异常是否传播到事务拦截器。
在默认配置下,@Transactional 遇到 RuntimeException 或 Error 会触发回滚,遇到受检异常默认不会回滚。
如果希望受检异常也触发回滚,可以配置:
@Transactional(rollbackFor = Exception.class)
此外,事务方法内部如果通过 try-catch 把异常捕获后正常返回,事务拦截器通常感知不到这次异常,也就不会因为该异常触发回滚。
这时可以继续向外抛出异常,或者显式将事务标记为回滚。
实际排查事务没有回滚的问题时,通常会检查异常是否被捕获、回滚规则是否正确,以及调用是否经过 Spring 事务代理。
4. 易混淆概念对比
4.1 Error 与 Exception
| 对比维度 | Error | Exception |
|---|---|---|
| 共同父类 | Throwable |
Throwable |
| 主要含义 | 严重的系统级错误 | 应用程序中的异常情况 |
| 典型示例 | OutOfMemoryError |
IOException |
| 编译检查 | 非受检 | 根据具体子类确定 |
| 处理策略 | 一般不在业务层捕获 | 按业务需要捕获或传播 |
4.2 Checked Exception 与 Unchecked Exception
| 对比维度 | 受检异常 | 非受检异常 |
|---|---|---|
| 编译器强制处理 | 是 | 否 |
| 典型类型 | IOException、SQLException |
RuntimeException、Error 及其子类 |
| 是否必须声明 | 向外传播时必须声明 | 不强制 |
| 常见用途 | 需要调用方明确考虑的失败 | 程序错误、参数错误或无需强制处理的失败 |
4.3 throw 与 throws
| 对比维度 | throw | throws |
|---|---|---|
| 作用 | 主动抛出异常 | 声明可能传播的异常 |
| 使用位置 | 方法体中 | 方法声明中 |
| 后面跟随 | 异常对象 | 异常类型 |
| 是否直接触发异常 | 是 | 否 |
4.4 finally 与 try-with-resources
| 对比维度 | finally | try-with-resources |
|---|---|---|
| 主要用途 | 通用清理逻辑 | 自动关闭资源 |
| 资源关闭 | 手动调用 | 自动调用 close() |
| 多资源关闭 | 手动控制顺序 | 自动逆序关闭 |
| 关闭异常 | 可能覆盖原异常 | 支持保留被抑制异常 |
| 使用场景 | 通用收尾处理 | 文件流、数据库连接等资源管理 |
4.5 cause 与 Suppressed Exception
| 对比维度 | cause | Suppressed Exception |
|---|---|---|
| 含义 | 引起当前异常的原始原因 | 处理过程中附带产生的其他异常 |
| 典型场景 | 底层异常封装为业务异常 | 资源关闭时再次出现异常 |
| 获取方法 | getCause() |
getSuppressed() |
| 主要作用 | 追踪异常产生的因果关系 | 保留多个异常的信息 |
5. 总结
Java 异常体系主要用于统一处理程序运行过程中的错误,它以 Throwable 为根类,分为 Error 和 Exception,其中 Exception 又可以区分为受检异常和非受检异常。核心机制是异常抛出后沿方法调用栈向上查找匹配的处理器,由能够处理的代码负责恢复、转换或者统一返回错误信息。Java 通过 try-catch-finally 实现异常捕获和清理,也提供了 try-with-resources 自动管理资源。实际开发中,关键是合理选择异常类型、保留原始异常原因,并在合适的层次处理异常。另外,受检异常和非受检异常的区别主要体现在编译期,具体是否需要捕获、能否恢复以及事务是否回滚,还需要结合业务场景和框架规则判断。