异常处理:程序也会'生病'
难度:中级 | 预计时间:35 分钟 | 前置:集合框架
📖 白屏背后的真相
故事背景
你的文件导入功能在生产环境挂了。用户看到的只有一个白屏。你翻遍日志,只有一行:NullPointerException。没有行号、没有堆栈、没有上下文。你花了整个下午才定位到——配置文件里少了一行。异常处理不是写几个 try-catch 就完事——抛什么、接什么、怎么翻译、怎么记录,决定了你排查问题的效率。
💻 代码演示
一个文件读取场景,演示各种异常处理模式:
import java.io.*;
import java.nio.file.*;
public class ExceptionDemo {
public static void main(String[] args) {
// 1. try-catch:处理可恢复的错误
try {
String content = Files.readString(Path.of("config.txt"));
System.out.println("配置: " + content);
} catch (NoSuchFileException e) {
System.out.println("配置文件不存在,使用默认配置");
} catch (IOException e) {
System.out.println("读取失败: " + e.getMessage());
}
// 2. try-with-resources:自动关闭资源(Java 7+)
try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
System.out.println("文件读取失败");
}
// 3. 异常翻译:把底层异常包装成业务异常
try {
loadUserData();
} catch (DataLoadException e) {
// 业务代码只关心业务异常,不关心底层是 IO 还是 SQL 错误
System.out.println("用户数据加载失败: " + e.getMessage());
}
}
static void loadUserData() throws DataLoadException {
try {
Files.readAllLines(Path.of("users.csv"));
} catch (IOException e) {
// 翻译:底层 IOException → 业务 DataLoadException
throw new DataLoadException("用户数据文件读取失败", e);
}
}
}
// 自定义业务异常
class DataLoadException extends Exception {
public DataLoadException(String message, Throwable cause) {
super(message, cause);
}
}运行输出:
配置文件不存在,使用默认配置
用户数据加载失败: 用户数据文件读取失败🎯 三个核心问题
这是什么?
Java 的异常体系分受检异常(checked exceptions,编译器强制要求处理)和非受检异常(unchecked exceptions,运行时抛出)。所有异常继承自 Throwable,下分 Error(严重问题,不应捕获)和 Exception(可以处理)。RuntimeException 及其子类是非受检异常。
为什么需要它?
受检异常的设计理念:某些操作天生可能失败(IO、网络、数据库),编译器强制你"想好怎么处理",避免遗忘。非受检异常通常是编程错误(空指针、越界),应该由代码逻辑避免。异常翻译的价值:Service 层不应该把 SQLException 抛给 Controller 层——Controller 只应看到"数据加载失败"这类业务语义的异常。
如果没有它会怎样?
如果没有受检异常(像 C#、JS 那样一切靠自觉),开发者容易忘记处理关键的错误路径。如果没有异常机制(像 C 语言用错误码返回值),错误处理代码和正常逻辑混在一起,可读性差,而且错误码可以被忽略(不检查返回值)。Java 的异常强制你至少"看到"可能出错的地方。
📝 原理讲解
异常处理黄金法则:
try-with-resources(Java 7+):任何实现了
AutoCloseable的资源(文件流、数据库连接、HTTP 连接),用这个语法自动关闭,不需要 finally 块。异常翻译:底层抛
IOException/SQLException,Service 层翻译成OrderCreateFailedException。每一层的异常应该是该层抽象层次的语言。永远不要吞异常:
catch (Exception e) {}空的 catch 块是 debug 地狱的根源。如果确实要吞,至少加一行注释说明为什么。前端对比:Java 的异常 ≈ JS 的 throw/try-catch + TypeScript 的编译期检查(但 Java 在运行时也是强制的)。Java 没有 Promise/Callback 风格的异步错误处理——这是后面并发章节的内容。
🎨 生活类比
类比理解
受检异常像机场安检——"这个东西可能有问题(IO 操作),你必须提前想好怎么处理",编译器就是那个安检员,不处理不让你登机(编译不通过)。 运行时异常像突发疾病——平时看不出,一旦发作(比如空指针),必须有人在最外层兜底(全局异常处理器),给用户一个友好的错误页面,同时记录详细日志。 异常翻译像外交翻译官——底层说"磁盘扇区损坏",翻译给上层说"文件读取失败"。上层不需要知道扇区是什么。
✏️ 动手练习
练习 1
写一个方法 readConfig(String path),用 try-with-resources 读取文件,返回 Properties 对象。处理文件不存在和格式错误两种情况。
<details> <summary>💡 查看提示</summary>
用 new FileInputStream(path) 创建流,捕获 FileNotFoundException 和 IOException 分别处理。
</details>
练习 2
定义一个业务异常类 PaymentFailedException,包含错误码和支付渠道信息。在模拟支付方法中抛它。
<details> <summary>💡 查看提示</summary>
继承 RuntimeException(非受检),在构造器里加自定义字段。
</details>
练习 3
写一个全局异常处理器的伪代码:捕获所有异常,记录日志,返回给前端友好的错误 JSON。
<details> <summary>💡 查看提示</summary>
这是一个设计思考题,不需要真的跑通。思路:用 catch (Exception e) 作为最外层兜底,根据异常类型决定 HTTP 状态码。
</details>
✅ 自检站
<details> <summary><strong>受检异常和非受检异常有什么区别?各适合什么场景?</strong></summary>
受检异常(Exception 子类,非 RuntimeException):编译器强制 try-catch 或 throws 声明。适合"可预见的、可恢复的"错误——IO 失败、网络超时、数据库连接失败。非受检异常(RuntimeException 子类):编译器不强制处理。适合"编程错误"——空指针、数组越界、非法参数。这些应该通过代码逻辑避免,而不是到处 try-catch。
</details>
<details> <summary><strong>为什么要做异常翻译?直接把 SQLException 抛到 Controller 层有什么问题?</strong></summary>
① Controller 需要依赖特定数据库驱动才能 catch,违反分层原则。② 换了数据库(MySQL → PostgreSQL),异常类全变了,Controller 也要改。③ 业务含义不清晰——"订单创建失败"比"ORA-00001 unique constraint violated"更易懂。翻译让每层只说本层的语言。
</details>