Java 异常处理

背景与动机#

异常处理用于表达程序运行中出现的非正常情况。

没有异常机制时,错误往往只能靠返回值一层层传递;有了异常,错误可以沿调用栈向上传播,并在合适的位置统一处理。

对初学者来说,异常处理最容易学成“会写 try-catch”,但真正重要的是再往前理解一步:

  • 什么情况适合抛异常
  • 什么情况应该在当前层处理
  • 什么情况应该继续往上抛
  • 为什么有些异常编译器强制你处理,有些却不强制

把这些边界理清楚,后面看文件操作、数据库代码、网络调用、框架源码时就不会乱。

核心原理拆解#

1. 异常不是“语法块”,而是对象#

Java 里的异常本质上也是对象,通常都继承自 Throwable

可以先记住这条主线:

  • Throwable
  • Error
  • Exception
  • RuntimeException

其中:

  • Error 通常表示更严重的系统级问题,一般不作为日常业务处理重点
  • Exception 是程序开发里最常见的异常父类
  • RuntimeException 是运行时异常,很多常见错误都在这一支上

也就是说,try-catch 捕获的不是一段“特殊信号”,而是某个具体异常对象。

img
img

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() 都没处理,那么异常会沿着调用链一直往上找能处理它的位置。

这就是为什么异常经常和“调用栈”一起出现。

你可以把它理解成:

  • 普通返回值是“方法正常返回结果”
  • 异常是“方法无法按正常路径继续,改走失败通道”

img
img

4. finally 通常用于释放资源#

try {
System.out.println("work");
} finally {
System.out.println("cleanup");
}

finally 通常无论是否发生异常都会执行,适合放清理逻辑。

常见场景包括:

  • 关闭文件
  • 关闭网络连接
  • 释放数据库连接
  • 做收尾日志记录

不过这里要补一层理解:

  • finally 关注的不是“处理错误”
  • 它关注的是“收尾动作要不要执行”

4.1 finallyreturn 一起出现时要小心#

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 写在方法签名上,表示调用方需要处理或继续声明该异常。

这里最关键的不是背语法,而是理解它在表达什么:

  • 当前方法知道这里可能失败
  • 但当前方法不决定怎么收尾
  • 它把处理责任交给上层调用者

所以 throwthrows 要分开看:

  • throw 是“真的抛了一个异常对象”
  • throws 是“提前声明我这里可能抛什么”

6.1 当前层处理,还是继续 throws 往上交#

可以用一个很实用的判断标准:

  • 如果当前层知道怎么恢复、怎么提示、怎么降级,那就当前层处理
  • 如果当前层不知道业务上该怎么办,那就继续往上抛

比如:

  • 底层文件读取方法:通常只负责读,失败后可以 throws
  • 控制层或主流程层:更适合统一提示用户、记录日志、决定是否终止流程

7. 受检异常和运行时异常#

Java 异常大致可以分为:

  • 受检异常:编译器要求处理,例如 IOException
  • 运行时异常:编译器通常不强制处理,例如 NullPointerExceptionIllegalArgumentException

这两类异常的差别,不只是“一个强制一个不强制”,更重要的是它们表达的问题性质不同。

7.1 受检异常更像“外部失败风险”#

受检异常通常意味着:

  • 这件事本身就有失败可能
  • 失败不一定是你代码写错了
  • 调用方应该正面面对这种失败

例如:

  • 读文件时文件可能不存在
  • 网络请求时连接可能失败
  • 数据库访问时查询可能报错

它强调的是“这件事有风险,你不能假装它一定成功”。

7.2 运行时异常更像“程序使用不当或逻辑问题”#

运行时异常很多时候意味着:

  • 参数传错了
  • 对象状态不对
  • 代码逻辑没处理好
  • 空指针、数组越界、除零这类问题出现了

它更像是在提醒开发者:

  • 这里的代码假设出了问题
  • 这里的边界没有保护好

所以它通常不是鼓励你“到处去 catch”,而是提醒你把代码写正确。··

img
img

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 负责发现业务失败条件,余额不够时主动 throw
  • createOrder(...) throws OrderException 说明当前层知道这里可能失败,但它自己不决定怎么提示用户
  • main() 作为更上层入口,统一接住 OrderException,集中输出失败信息

这就是很多真实项目里很常见的思路:

  • 底层负责发现问题
  • 中间层负责传递问题
  • 上层负责统一处理问题

如果你想再往前理解一步,这个例子里把 OrderException 设计成受检异常 extends Exception,其实也是在强调:

  • “下单失败”这件事是调用方必须正面处理的风险
  • 调用方不能假装它一定成功

如果把它改成 extends RuntimeException,则更偏向:

  • 这是程序运行中的业务非法状态
  • 编译器不强制你显式处理

两种写法都能成立,但表达的设计态度不完全一样。

受检异常版 vs 运行时异常版#

如果把同一个“下单失败”场景分别写成受检异常和运行时异常,代码上的感受会不一样。

可以先看这个并排对照:

对比项受检异常版运行时异常版
继承关系extends Exceptionextends 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
  • 调用方很难忽略这个失败风险
  • 更适合强调“这件事失败是正常可能性,调用方必须做决定”

img
img

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
  • 编译器不会逼着调用方处理
  • 更适合表达“这里的调用前提本来就应该满足,否则就是非法状态”

img
img

怎么选更合适#

可以先用一个很实用的思路判断:

  • 如果你想强调“这是调用方必须正面处理的失败结果”,更偏受检异常
  • 如果你想强调“这是不该发生的非法状态或错误使用”,更偏运行时异常

再说得贴近项目一点:

  • 读文件失败、远程调用失败、支付网关返回失败 更容易让人接受受检异常思路
  • 金额非法、对象状态不对、方法调用前提没满足 更容易让人接受运行时异常思路

不过现实项目里,不同团队风格会不一样。很多现代项目会更偏向运行时异常 + 统一异常处理机制,但理解受检异常仍然很重要,因为你看 JDK 和很多老代码时一定会遇到它。

进阶原理#

1. Error 与 Exception 怎么区分#

Error 表示 JVM 无法通过代码恢复的严重问题(如 OutOfMemoryErrorStackOverflowError),一般不捕获
Exception 表示程序可处理的异常,分为受检异常与 RuntimeException

2. JVM 如何处理异常#

发生异常时创建异常对象(含堆栈快照)→ 沿调用栈向上查找匹配的 catch → 找到则处理;否则交给默认未捕获处理器,打印堆栈并终止线程。

3. NoClassDefFoundError vs ClassNotFoundException#

NoClassDefFoundErrorClassNotFoundException
类型Error受检 Exception
典型原因编译期存在类,运行时找不到(依赖 jar 缺失等)Class.forName 等加载时类路径无该类

4. catch 里 return 与 finally 的执行顺序#

catchreturnfinally 仍会执行(在 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
@RestControllerAdvice
public 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#

NullPointerExceptionIndexOutOfBoundsException 这类问题,很多时候重点不是补一个 catch,而是回去修正:

  • 空值判断
  • 集合边界判断
  • 调用时机
  • 对象状态约束

面试高频问题#

完整 33 道速查见 Java 异常面试题 33 道

1. throwthrows 有什么区别#

throw 用在方法体内,主动抛出一个异常对象;throws 用在方法声明上,说明这个方法可能抛出某些异常。

2. 受检异常和运行时异常有什么区别#

受检异常编译器要求处理或声明;运行时异常编译器通常不强制处理。更本质的区别是:前者更强调外部失败风险,后者更常表示代码逻辑问题或参数错误。

3. finally 一定会执行吗#

通常会执行,但如果 JVM 直接退出、进程崩溃等极端情况发生,finally 也可能没有机会执行。

4. 为什么不推荐空 catch#

因为它会直接吞掉错误,让调用方和排查者都看不到真实问题,后面往往只剩下“结果不对,但不知道为什么”。

5. 什么情况下适合自定义异常#

当通用异常类型已经无法准确表达业务错误,并且这个错误值得被上层识别和区分处理时,就适合自定义异常。

6. try-with-resourcesfinally 的关系是什么#

try-with-resources 可以理解为资源关闭场景下更推荐的写法,它继承了“收尾释放资源”的思想,但代码通常比手写 finally 更清晰。

一句话总结#

Java 异常处理的核心不是“把 try-catch 写出来”,而是分清:哪里负责发现问题,哪里负责抛出问题,哪里负责接住问题,哪里负责做最后的收尾。

文章目录

文章目录