异常处理
try/catch、throws、自定义异常。
异常处理
这一课你将能做到
- 画出异常类层次(Throwable → Error / Exception),说清受检异常与非受检异常的边界
- 正确使用 try/catch/finally、throw、throws,并用 try-with-resources 安全关闭资源
- 设计自定义业务异常(如
BizException)——本站后端统一异常处理体系的地基
为什么要学
程序跑着跑着会出事:数据库连不上、参数非法、文件不存在、除以零。异常机制就是 Java 的「事故处理流程」:出事时抛出一个描述问题的对象,往上传递,直到有人接住处理。
后端视角尤其重要:用户请求打过来,Service 层抛出「库存不足」,Controller 层不能让用户看到 NullPointerException 的一堆堆栈——要转成友好的 {"code": 40001, "msg": "库存不足"}。Spring 的 @RestControllerAdvice 全局异常处理,底层完全建立在今天这课的知识上。
异常处理写得好 = 故障可定位、用户看人话、系统不崩;写得差 = 到处 catch (Exception e) {} 静默吞掉,出了问题全组抓瞎。
先建立图像
Throwable(一切可抛对象的老祖宗)
├── Error 系统级灾难,别管也管不了(OutOfMemoryError、StackOverflowError)
└── Exception
├── RuntimeException 非受检(unchecked):编译器不强制处理
│ ├── NullPointerException 空指针
│ ├── ArrayIndexOutOfBoundsException 数组越界
│ ├── ClassCastException 强转失败
│ ├── NumberFormatException 字符串转数字失败
│ └── ArithmeticException 整数除以 0
└── 其他 Exception 受检(checked):编译器强制你 catch 或 throws
├── IOException IO 出错
└── SQLException 数据库出错
传递路线:方法内 throw → 沿调用栈向上 → 谁都没接住 → JVM 打印堆栈并终止线程
跟着做
步骤 1:体验 try/catch
新建 ExceptionDemo.java:
public class ExceptionDemo {
public static void main(String[] args) {
try {
int result = 10 / 0; // 这里会抛 ArithmeticException
System.out.println("这行永远执行不到");
} catch (ArithmeticException e) {
System.out.println("捕获到异常: " + e.getMessage()); // / by zero
}
System.out.println("程序继续正常运行"); // 没被异常带崩
// 多个 catch:从子类到父类排列
try {
String s = null;
s.length();
} catch (NullPointerException e) {
System.out.println("空指针: " + e.getMessage());
} catch (RuntimeException e) { // 兜底分支放最后
System.out.println("其他运行时异常");
}
// 一个 catch 接多种异常(互为兄弟类型时用 |)
try {
int[] a = new int[2];
a[5] = 1;
} catch (NullPointerException | ArrayIndexOutOfBoundsException e) {
System.out.println("空指针或越界: " + e.getClass().getSimpleName());
}
}
}
步骤 2:finally 的执行时机
public class FinallyDemo {
public static void main(String[] args) {
System.out.println(test());
}
static String test() {
try {
int r = 10 / 0;
return "try 返回";
} catch (ArithmeticException e) {
return "catch 返回"; // 即使这里 return,finally 依然执行
} finally {
System.out.println("finally 总是执行(清理资源用)");
}
}
}
finally 总是执行(清理资源用)
catch 返回
finally 在「try/catch 返回值已确定之后、真正返回之前」执行——所以 finally 里写 return 会覆盖正常返回值,禁止这么写。
步骤 3:throw 与 throws
public class ThrowDemo {
public static void main(String[] args) {
try {
setAge(-5);
} catch (IllegalArgumentException e) {
System.out.println("业务拦截: " + e.getMessage());
}
}
static void setAge(int age) {
if (age < 0 || age > 150) {
// throw:方法体内主动抛出一个异常对象(注意是实例不是类)
throw new IllegalArgumentException("年龄非法: " + age);
}
System.out.println("设置成功: " + age);
}
// throws:方法签名上声明「本方法可能抛出受检异常,调用方必须处理」
static String readFile(String path) throws java.io.IOException {
return java.nio.file.Files.readString(java.nio.file.Path.of(path));
}
}
区分:throw 是动作(抛出去),throws 是声明(提前打招呼)。受检异常必须二选一:catch 住,或者 throws 往上传。
步骤 4:受检异常的传播链
public class CheckedExceptionDemo {
public static void main(String[] args) {
try {
loadConfig("app.properties");
} catch (java.io.IOException e) {
System.out.println("读配置失败,用默认配置启动: " + e.getMessage());
}
}
static String loadConfig(String name) throws java.io.IOException {
return readAll(name); // 受检异常逐层向上声明,最终有人处理
}
static String readAll(String name) throws java.io.IOException {
// 若文件不存在,Files.readString 抛 NoSuchFileException(IOException 子类)
return java.nio.file.Files.readString(java.nio.file.Path.of(name));
}
}
把 throws 删掉试试——编译器直接报错「未报告的异常错误,必须对其进行捕获或声明以便抛出」。这就是「受检」的含义:编译期强制你表态。
步骤 5:自定义业务异常
// 业务异常基类:非受检(继承 RuntimeException),避免调用链上层层 throws 污染
class BizException extends RuntimeException {
private final int code; // 错误码,对接前端的 code 字段
public BizException(int code, String message) {
super(message);
this.code = code;
}
public int getCode() { return code; }
}
class InsufficientStockException extends BizException {
public InsufficientStockException(long productId, int shortage) {
super(40001, "商品 " + productId + " 库存不足,缺 " + shortage + " 件");
}
}
public class BizExceptionDemo {
public static void main(String[] args) {
try {
createOrder(1001L, 5, 3); // 想买 5 件,只有 3 件
} catch (BizException e) {
// 模拟 Spring 全局异常处理:把异常翻译成统一响应体
System.out.printf("{\"code\": %d, \"msg\": \"%s\"}%n", e.getCode(), e.getMessage());
}
}
static void createOrder(long productId, int want, int stock) {
if (want > stock) {
throw new InsufficientStockException(productId, want - stock);
}
System.out.println("下单成功");
}
}
输出:{"code": 40001, "msg": "商品 1001 库存不足,缺 2 件"}。这就是本站后端异常体系的微缩模型:Service 抛业务异常,全局处理器统一翻译。
步骤 6:try-with-resources
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class TwrDemo {
public static void main(String[] args) {
Path path = Path.of("demo.txt");
try {
Files.writeString(path, "第一行\n第二行\n"); // 先造个文件(JDK 11+ API)
} catch (IOException e) {
System.out.println("写文件失败: " + e.getMessage());
}
// try-with-resources:括号里声明的资源自动关闭(JDK 7+)
try (BufferedReader reader = Files.newBufferedReader(path)) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println("读到: " + line);
}
} catch (IOException e) {
System.out.println("读文件失败: " + e.getMessage());
}
// 不需要 finally 里写 reader.close()——编译器自动生成,且异常时也保证关闭
}
}
要求:资源类必须实现 AutoCloseable。多个资源用分号隔开,关闭顺序与声明相反。
讲清楚要点
- 受检 vs 非受检的分界:受检(IO、SQL 等外部依赖故障)编译器强制处理——「可预见、可恢复」;非受检(NPE、越界等编程错误)编译器不管——「修代码而不是 catch」。
- catch 要具体,不要一把梭:
catch (Exception e) 会把 NPE 等编程 bug 也吞掉。只 catch 预期内的异常,未知异常让它炸出来暴露问题。
- 绝对禁止空 catch:
catch (Exception e) {} 让故障凭空消失,线上排查时堆栈什么都没有。最低限度也要 log.error("xx失败", e)(学习阶段 e.printStackTrace())。
- finally 负责清理,try-with-resources 是它的现代替代:新代码一律 try-with-resources。
- 异常别用来控制正常流程:用异常当 if 用(循环里靠异常跳出)性能差且难读;异常是「异常」路径的机制。
- 异常信息要带上下文:
throw new IllegalArgumentException("年龄非法: " + age) 远好于 throw new IllegalArgumentException("参数错误")——排错时前者一眼定位。
你可能会懵的地方
-
为什么自定义异常继承 RuntimeException 而不是 Exception?
继承 Exception 是受检异常,调用链上每个方法都得 throws 或 catch,层层污染。业务异常属于「可预期的错误流程」,让它非受检,只在最外层(Spring 全局处理器)统一接住。
-
finally 一定执行吗?
几乎总是,除了三种极端:System.exit() 强退、JVM 崩溃、守护线程被直接终止。另一个考点:finally 里 return 会「吃掉」try/catch 的返回值,所以规范禁止。
-
catch 顺序有什么讲究?
子类在前父类在后。catch (Exception e) 放第一个会让后面所有 catch 变成不可达代码,编译报错。兄弟类型之间顺序随意,可用 | 合并。
-
Error 和 Exception 什么区别?
Error 是 JVM 级致命错误(内存耗尽、栈溢出),程序设计上不捕获不处理;Exception 是程序可处理的异常。别写 catch (Error e),也别指望 catch OutOfMemoryError 能救回系统。
-
throw 之后的方法代码还执行吗?
不执行。throw 会立即中断当前方法,异常沿调用栈向上找 catch;如果没有任何方法捕获,最终到 main,线程终止并打印堆栈。
-
RuntimeException 为什么编译器不检查?
Java 设计者的取舍:NPE、越界这类是代码 bug,到处 throws 会让签名全是噪音;修复 bug 才是正解。所以 IDE 提示「method throws NullPointerException」是提醒你写防御代码,不是让你 catch。
错法对照
| 错法 | 问题 | 正解 |
|---|
catch (Exception e) {} 空捕获 | 故障静默消失 | 至少打印堆栈 e.printStackTrace() / 记日志 |
| catch 里只打印不处理继续跑 | 后续逻辑基于错误状态 | 恢复/重试/向上抛,明确策略 |
| finally 里写 return | 覆盖 try/catch 的返回值 | finally 只做清理 |
手写 finally { reader.close(); } | 啰嗦且 close 本身可能抛异常 | try-with-resources |
catch (Exception e) 放在第一个 catch | 后续 catch 不可达,编译错 | 子类异常在前,父类兜底在后 |
| 受检异常既不 catch 又不 throws | 编译错误 | 二选一 |
| 用异常控制正常分支 | 性能差、语义混乱 | 正常流程用 if/return |
| 吞掉异常再返回 null | 调用方 NPE,问题更隐蔽 | 抛异常或返回 Optional/默认值 |
卡点急救
- 编译报「未报告的异常错误 XXX,必须对其进行捕获或声明」:受检异常没处理。要么 try/catch,要么方法签名加 throws。IDEA 中 Alt+Enter 一键给出两个选项。
- catch 了却好像没 catch 住:检查异常类型——抛出的是子类,catch 的是不相关的类;或异常发生在 try 块之前。打印
e.getClass().getName() 确认真实类型。
- 看不到完整报错信息:只打了
e.getMessage()(可能为 null)。改打 e.printStackTrace() 或日志 log.error("ctx", e),堆栈第一行是异常类名+消息,往下是调用链。
- try-with-resources 编译错「资源必须实现 AutoCloseable」:该类没有 close 语义,手工管理;或换用支持自动关闭的标准类(Files、流、连接)。
- 自定义异常没被 catch 到:确认 catch 的类型是抛出类型的父类或本身;
catch (BizException e) 能接住子类 InsufficientStockException,反之不行。
面试常问
-
受检异常和非受检异常的区别?(必考)
受检:Exception 非 RuntimeException 分支,编译器强制 catch/throws,代表外部依赖故障,典型 IOException/SQLException;非受检:RuntimeException 和 Error,编译器不检查,代表编程错误或系统灾难,典型 NPE/越界/StackOverflowError。
-
finally 一定执行吗?什么时候不执行?
除 System.exit、JVM 崩溃、守护线程被杀外都执行。finally 在 return 值确定后、真正返回前执行;finally 中的 return 会覆盖原返回值,禁止使用。
-
throw 和 throws 的区别?
throw 是语句,在方法体内抛出异常对象,一个 throw 抛一个实例;throws 是方法签名声明,说明该方法可能抛出的受检异常类型,可声明多个,由调用方处理。
-
异常处理影响性能吗?
抛异常要填充堆栈快照,成本高(微秒级);try 本身几乎零成本。所以不要用异常做流程控制,高频路径避免靠异常「探边界」。
-
Spring 里怎么统一处理异常?(后端实战题)
@RestControllerAdvice + @ExceptionHandler(BizException.class) 全局拦截,把业务异常翻译成统一响应体 {code, msg};未知异常兜底成 500 并记录日志,用户看不到原始堆栈。
-
return 和 finally 的执行顺序?
try 中 return:先计算返回值并存好 → 执行 finally → 返回存好的值。若 finally 中改了返回的引用对象内容(不是换对象),改动能体现出来。
自测三问
- 下面三个异常哪些是受检的:
IOException、NullPointerException、SQLException?编译器分别怎么要求?
- finally 里写 return 会导致什么?为什么规范禁止?
- 设计「余额不足」业务异常:继承谁?带什么字段?在哪一层抛、哪一层接?
小结一句话
异常是向上传递的事故对象:受检的编译器逼你表态、非受检的是代码 bug 要修不要吞;throw 抛、throws 声明、try-with-resources 管清理、业务异常继承 RuntimeException——后端的统一错误码体系就从这起步。
延伸