背景与动机
异常处理用于表达程序运行中出现的非正常情况。
没有异常机制时,错误往往只能靠返回值一层层传递;有了异常,错误可以沿调用栈向上传播,并在合适的位置统一处理。
对初学者来说,异常处理最容易学成“会写 try-catch”,但真正重要的是再往前理解一步:
- 什么情况适合抛异常
- 什么情况应该在当前层处理
- 什么情况应该继续往上抛
- 为什么有些异常编译器强制你处理,有些却不强制
把这些边界理清楚,后面看文件操作、数据库代码、网络调用、框架源码时就不会乱。
核心原理拆解
1. 异常不是“语法块”,而是对象
Java 里的异常本质上也是对象,通常都继承自 Throwable。
可以先记住这条主线:
ThrowableErrorExceptionRuntimeException
其中:
Error通常表示更严重的系统级问题,一般不作为日常业务处理重点Exception是程序开发里最常见的异常父类RuntimeException是运行时异常,很多常见错误都在这一支上
也就是说,try-catch 捕获的不是一段“特殊信号”,而是某个具体异常对象。

2. try-catch 用来捕获异常
try { int n = 10 / 0; System.out.println(n);} catch (ArithmeticException e) { System.out.println("divide by zero");}try 中发生异常后,当前异常点后面的语句会被跳过,程序会去找能匹配它的 catch。
这里最重要的不是把语法背下来,而是理解执行过程:
- 先执行
try - 一旦某行抛出异常,
try中它后面的代码通常不再继续 - JVM 会拿这个异常对象去匹配后面的
catch - 匹配上了,就执行对应
catch - 没有匹配上,就继续往外层调用者传播
2.1 一个 try 可以有多个 catch
try { String s = null; System.out.println(s.length());} catch (NullPointerException e) { System.out.println("null error");} catch (Exception e) { System.out.println("other error");}多个 catch 的意义是:不同类型的问题,可以分开处理。
通常建议:
- 先写更具体的异常类型
- 再写更宽泛的异常类型
因为异常匹配也是“从上往下”看的。如果你先写了过大的父类异常,后面更具体的 catch 可能就失去意义了。
2.2 也可以用多异常合并写法
try { // do something} catch (java.io.IOException | java.sql.SQLException e) { System.out.println("io or sql error");}如果这几个异常在当前层的处理方式完全一样,可以合并写。
这样做的重点不是“写得更短”,而是明确表达:
- 它们类型不同
- 但当前处理策略相同
3. 异常会沿调用栈向上传播
很多人第一次学异常时,会误以为异常只在当前方法里解决。其实更常见的情况是:当前层不处理,继续往上抛。
看一个简单例子:
static void m1() { m2();}
static void m2() { m3();}
static void m3() { int n = 10 / 0;}如果 m3() 抛出了异常,而 m3()、m2()、m1() 都没处理,那么异常会沿着调用链一直往上找能处理它的位置。
这就是为什么异常经常和“调用栈”一起出现。
你可以把它理解成:
- 普通返回值是“方法正常返回结果”
- 异常是“方法无法按正常路径继续,改走失败通道”

4. finally 通常用于释放资源
try { System.out.println("work");} finally { System.out.println("cleanup");}finally 通常无论是否发生异常都会执行,适合放清理逻辑。
常见场景包括:
- 关闭文件
- 关闭网络连接
- 释放数据库连接
- 做收尾日志记录
不过这里要补一层理解:
finally关注的不是“处理错误”- 它关注的是“收尾动作要不要执行”
4.1 finally 和 return 一起出现时要小心
static int test() { try { return 1; } finally { System.out.println("cleanup"); }}这个方法返回前,finally 仍然会先执行。
所以初学时最好先记住:
return不代表立刻什么都不管就结束finally往往还会在退出前执行一次
实际开发里,不建议在 finally 里再去写复杂返回逻辑,不然可读性会很差。
4.2 try-with-resources 往往比手写 finally 更适合资源关闭
处理文件、流、连接这类资源时,现代 Java 更常见的是 try-with-resources:
正常写法
java.io.BufferedReader reader = null;try { reader = java.nio.file.Files.newBufferedReader(java.nio.file.Path.of("a.txt")); System.out.println(reader.readLine());} catch (java.io.IOException e) { System.out.println("read fail: " + e.getMessage());} finally { if (reader != null) { try { reader.close(); } catch (java.io.IOException e) { e.printStackTrace(); } }}try-with-resources写法
try (java.io.BufferedReader reader = java.nio.file.Files.newBufferedReader(java.nio.file.Path.of("a.txt"))) { System.out.println(reader.readLine());} catch (java.io.IOException e) { System.out.println("read fail: " + e.getMessage());}它的优势是:
- 结构更清晰
- 少写很多手动关闭代码
- 更不容易遗漏资源释放
所以可以把理解顺序记成:
- 先学
finally的收尾思想 - 再学
try-with-resources的更推荐写法
5. throw 主动抛出异常
static void setAge(int age) { if (age < 0) { throw new IllegalArgumentException("age must be >= 0"); }}throw 抛出的是一个具体异常对象。
它通常用于两种场景:
- 参数不合法
- 当前状态不满足方法继续执行条件
也就是说,异常不一定都来自 JVM 自动报错,程序员自己也可以在业务边界上主动抛出异常。
5.1 什么时候适合主动抛异常
比如这些情况就很典型:
- 金额不能小于 0
- 用户名不能为空
- 订单状态不允许重复支付
- 文件路径不能为空
这类错误往往不是“程序自己突然崩了”,而是“输入或状态不符合规则”。
这时主动抛异常,反而能更清晰表达失败原因。
6. throws 声明方法可能抛出的异常
static void readFile(String path) throws java.io.IOException { java.nio.file.Files.readString(java.nio.file.Path.of(path));}throws 写在方法签名上,表示调用方需要处理或继续声明该异常。
这里最关键的不是背语法,而是理解它在表达什么:
- 当前方法知道这里可能失败
- 但当前方法不决定怎么收尾
- 它把处理责任交给上层调用者
所以 throw 和 throws 要分开看:
throw是“真的抛了一个异常对象”throws是“提前声明我这里可能抛什么”
6.1 当前层处理,还是继续 throws 往上交
可以用一个很实用的判断标准:
- 如果当前层知道怎么恢复、怎么提示、怎么降级,那就当前层处理
- 如果当前层不知道业务上该怎么办,那就继续往上抛
比如:
- 底层文件读取方法:通常只负责读,失败后可以
throws - 控制层或主流程层:更适合统一提示用户、记录日志、决定是否终止流程
7. 受检异常和运行时异常
Java 异常大致可以分为:
- 受检异常:编译器要求处理,例如
IOException - 运行时异常:编译器通常不强制处理,例如
NullPointerException、IllegalArgumentException
这两类异常的差别,不只是“一个强制一个不强制”,更重要的是它们表达的问题性质不同。
7.1 受检异常更像“外部失败风险”
受检异常通常意味着:
- 这件事本身就有失败可能
- 失败不一定是你代码写错了
- 调用方应该正面面对这种失败
例如:
- 读文件时文件可能不存在
- 网络请求时连接可能失败
- 数据库访问时查询可能报错
它强调的是“这件事有风险,你不能假装它一定成功”。
7.2 运行时异常更像“程序使用不当或逻辑问题”
运行时异常很多时候意味着:
- 参数传错了
- 对象状态不对
- 代码逻辑没处理好
- 空指针、数组越界、除零这类问题出现了
它更像是在提醒开发者:
- 这里的代码假设出了问题
- 这里的边界没有保护好
所以它通常不是鼓励你“到处去 catch”,而是提醒你把代码写正确。··

8. 自定义异常的意义
当 JDK 自带异常类型不足以准确表达业务问题时,可以自定义异常。
class BalanceNotEnoughException extends RuntimeException { public BalanceNotEnoughException(String message) { super(message); }}它的意义通常不是“显得高级”,而是让错误语义更明确。
比如:
IllegalArgumentException只能表达“参数不对”BalanceNotEnoughException能直接表达“余额不足”
这样读代码、看日志、做统一异常处理时,都会更容易定位问题。
8.1 什么时候值得自定义异常
通常在下面这些场景里更有价值:
- 一个业务错误会反复出现
- 你希望上层按业务类型区分处理
- 通用异常名已经表达不清含义
如果只是一次性的简单参数校验,直接用现成运行时异常往往就够了。
最小可运行代码示例
public class ExceptionDemo { public static void main(String[] args) { try { processOrder(-1); System.out.println("after process"); } catch (IllegalArgumentException e) { System.out.println("参数错误:" + e.getMessage()); } finally { System.out.println("done"); } }
static void processOrder(int amount) { validateAmount(amount); System.out.println("process success"); }
static void validateAmount(int amount) { if (amount < 0) { throw new IllegalArgumentException("amount must be >= 0"); } }}这个例子里:
validateAmount()负责发现非法参数throw负责主动抛出问题processOrder()没处理,异常继续向上走main()用catch统一接住finally在最后做收尾输出
这比只看一段孤立的 try-catch 更能体现异常传播流程。
自定义异常例子
下面这个例子专门演示三件事一起出现时是什么感觉:
- 自定义异常
- 底层方法用
throws往上交 - 上层统一
catch处理
public class CustomExceptionDemo { public static void main(String[] args) { OrderService orderService = new OrderService();
try { orderService.createOrder("1001", 1200); System.out.println("订单创建成功"); } catch (OrderException e) { System.out.println("下单失败:" + e.getMessage()); } }}
class OrderService { private final AccountService accountService = new AccountService();
public void createOrder(String userId, int amount) throws OrderException { accountService.checkBalance(userId, amount); System.out.println("开始创建订单..."); }}
class AccountService { public void checkBalance(String userId, int amount) throws OrderException { int balance = 500;
if (amount > balance) { throw new OrderException( "用户 " + userId + " 余额不足,当前余额=" + balance + ",订单金额=" + amount ); } }}
class OrderException extends Exception { public OrderException(String message) { super(message); }}这个例子可以按“分层职责”来理解:
AccountService负责发现业务失败条件,余额不够时主动throwcreateOrder(...) throws OrderException说明当前层知道这里可能失败,但它自己不决定怎么提示用户main()作为更上层入口,统一接住OrderException,集中输出失败信息
这就是很多真实项目里很常见的思路:
- 底层负责发现问题
- 中间层负责传递问题
- 上层负责统一处理问题
如果你想再往前理解一步,这个例子里把 OrderException 设计成受检异常 extends Exception,其实也是在强调:
- “下单失败”这件事是调用方必须正面处理的风险
- 调用方不能假装它一定成功
如果把它改成 extends RuntimeException,则更偏向:
- 这是程序运行中的业务非法状态
- 编译器不强制你显式处理
两种写法都能成立,但表达的设计态度不完全一样。
受检异常版 vs 运行时异常版
如果把同一个“下单失败”场景分别写成受检异常和运行时异常,代码上的感受会不一样。
可以先看这个并排对照:
| 对比项 | 受检异常版 | 运行时异常版 |
|---|---|---|
| 继承关系 | extends Exception | extends RuntimeException |
| 方法签名 | 通常要写 throws OrderException | 通常不用强制写 throws |
| 编译器态度 | 强制调用方处理或继续声明 | 编译器通常不强制处理 |
| 设计倾向 | 强调“这是必须正面面对的失败风险” | 强调“这是运行时业务非法状态或使用不当” |
| 上层代码风格 | 往往更显式地 try-catch 或继续上抛 | 可以统一捕获,也可以不在当前层显式处理 |
| 常见场景 | 文件、网络、支付结果、外部依赖失败 | 参数非法、状态非法、逻辑前置条件不满足 |
下面用最小差异对照一下。
1. 受检异常版
class OrderException extends Exception { public OrderException(String message) { super(message); }}
class OrderService { public void createOrder(String userId, int amount) throws OrderException { if (amount > 500) { throw new OrderException("余额不足,无法下单"); } }}
public class CheckedDemo { public static void main(String[] args) { OrderService orderService = new OrderService();
try { orderService.createOrder("1001", 1200); System.out.println("下单成功"); } catch (OrderException e) { System.out.println("统一处理下单失败:" + e.getMessage()); } }}这个版本的特点是:
createOrder(...)明确写了throws OrderException- 调用方很难忽略这个失败风险
- 更适合强调“这件事失败是正常可能性,调用方必须做决定”

2. 运行时异常版
class OrderRuntimeException extends RuntimeException { public OrderRuntimeException(String message) { super(message); }}
class OrderService { public void createOrder(String userId, int amount) { if (amount > 500) { throw new OrderRuntimeException("余额不足,无法下单"); } }}
public class RuntimeDemo { public static void main(String[] args) { OrderService orderService = new OrderService(); orderService.createOrder("1001", 1200); System.out.println("这行通常不会执行"); }}这个版本的特点是:
- 方法签名上通常不用显式写
throws - 编译器不会逼着调用方处理
- 更适合表达“这里的调用前提本来就应该满足,否则就是非法状态”

怎么选更合适
可以先用一个很实用的思路判断:
- 如果你想强调“这是调用方必须正面处理的失败结果”,更偏受检异常
- 如果你想强调“这是不该发生的非法状态或错误使用”,更偏运行时异常
再说得贴近项目一点:
- 读文件失败、远程调用失败、支付网关返回失败 更容易让人接受受检异常思路
- 金额非法、对象状态不对、方法调用前提没满足 更容易让人接受运行时异常思路
不过现实项目里,不同团队风格会不一样。很多现代项目会更偏向运行时异常 + 统一异常处理机制,但理解受检异常仍然很重要,因为你看 JDK 和很多老代码时一定会遇到它。
进阶原理
1. Error 与 Exception 怎么区分
Error 表示 JVM 无法通过代码恢复的严重问题(如 OutOfMemoryError、StackOverflowError),一般不捕获。
Exception 表示程序可处理的异常,分为受检异常与 RuntimeException。
2. JVM 如何处理异常
发生异常时创建异常对象(含堆栈快照)→ 沿调用栈向上查找匹配的 catch → 找到则处理;否则交给默认未捕获处理器,打印堆栈并终止线程。
3. NoClassDefFoundError vs ClassNotFoundException
| NoClassDefFoundError | ClassNotFoundException | |
|---|---|---|
| 类型 | Error | 受检 Exception |
| 典型原因 | 编译期存在类,运行时找不到(依赖 jar 缺失等) | Class.forName 等加载时类路径无该类 |
4. catch 里 return 与 finally 的执行顺序
catch 中 return 时 finally 仍会执行(在 return 之前)。
基本类型返回值在 finally 里修改往往无效(return 值已确定);finally 里再 return 会覆盖 try/catch 的返回值——应避免。
5. try-with-resources 与 suppressed 异常
try (BufferedReader reader = new BufferedReader(new FileReader(path))) { // 使用 reader} catch (IOException e) { // 主异常}close() 若也抛异常,会作为 suppressed 附在主异常上,可用 getSuppressed() 查看,比只在 finally 里 close 更清晰。
6. 业务里如何统一异常处理(Spring 项目)
实际项目很少在每个 Controller 里 try-catch,而是:
- 业务层抛出运行时异常或自定义
BusinessException - Web 层用
@RestControllerAdvice+@ExceptionHandler统一转成 JSON
@RestControllerAdvicepublic class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class) public Result<?> handleBusiness(BusinessException e) { return Result.fail(e.getCode(), e.getMessage()); }
@ExceptionHandler(Exception.class) public Result<?> handleOther(Exception e) { log.error("unexpected", e); return Result.fail(500, "系统繁忙"); }}受检 vs 非受检在业务中的常见做法:
| 受检异常 | 非受检异常(更常见) | |
|---|---|---|
| 编译器 | 必须处理或声明 throws | 不强制 |
| 项目风格 | 老代码、IO 库 | Service 抛 RuntimeException,由全局处理器兜底 |
→ Boot 示例见 Spring Boot Web 与运维。
7. 异常处理最佳实践(摘要)
- 资源关闭放
finally或 try-with-resources - 抛语义明确的异常,文档写清
@throws - 先捕获子类再捕获父类
- 不要捕获
Throwable、不要空 catch、不要 log 后又原样抛出 - 包装异常时保留 cause
- 不用异常做正常分支;NPE 等用 if 预检查(阿里规约)
33 道面试速查:Java 异常面试题 33 道。
常见陷阱与错误示例
1. 捕获异常后什么都不做
错误示例:
try { doWork();} catch (Exception e) {}空 catch 会吞掉问题,让排查变困难。至少应记录日志、返回明确信息或重新抛出。
2. 用异常处理正常流程
异常适合表达异常情况,不适合替代普通 if 判断。比如校验用户输入时,能直接判断就不要故意制造异常。
3. 过度捕获 Exception
直接捕获 Exception 范围太大,容易把不该处理的问题也吞掉。通常优先捕获更具体的异常类型。
4. 在错误的层级过早吞掉异常
比如底层工具方法里一旦出错,立刻吃掉异常然后只返回 null,上层往往就失去了真实失败原因。
很多时候真正合理的做法是:
- 底层保留异常语义
- 上层统一决定提示、重试、记录日志还是结束流程
5. 把 finally 当成异常处理主逻辑区
finally 更适合做收尾,不适合堆太多业务判断。否则读代码时会很难分清:
- 哪部分在处理成功路径
- 哪部分在处理失败路径
- 哪部分只是做清理
6. 看到运行时异常就只想着 catch
像 NullPointerException、IndexOutOfBoundsException 这类问题,很多时候重点不是补一个 catch,而是回去修正:
- 空值判断
- 集合边界判断
- 调用时机
- 对象状态约束
面试高频问题
完整 33 道速查见 Java 异常面试题 33 道。
1. throw 和 throws 有什么区别
throw 用在方法体内,主动抛出一个异常对象;throws 用在方法声明上,说明这个方法可能抛出某些异常。
2. 受检异常和运行时异常有什么区别
受检异常编译器要求处理或声明;运行时异常编译器通常不强制处理。更本质的区别是:前者更强调外部失败风险,后者更常表示代码逻辑问题或参数错误。
3. finally 一定会执行吗
通常会执行,但如果 JVM 直接退出、进程崩溃等极端情况发生,finally 也可能没有机会执行。
4. 为什么不推荐空 catch
因为它会直接吞掉错误,让调用方和排查者都看不到真实问题,后面往往只剩下“结果不对,但不知道为什么”。
5. 什么情况下适合自定义异常
当通用异常类型已经无法准确表达业务错误,并且这个错误值得被上层识别和区分处理时,就适合自定义异常。
6. try-with-resources 和 finally 的关系是什么
try-with-resources 可以理解为资源关闭场景下更推荐的写法,它继承了“收尾释放资源”的思想,但代码通常比手写 finally 更清晰。
一句话总结
Java 异常处理的核心不是“把 try-catch 写出来”,而是分清:哪里负责发现问题,哪里负责抛出问题,哪里负责接住问题,哪里负责做最后的收尾。