主题
界面
主题
《读懂 Java 错误信息》 教了"怎么读异常",本节教"怎么设计异常":什么时候抛、抛什么类型、什么时候捕获、什么时候包装、绝不能做什么。
这是从"能跑"到"可维护"的分界线。异常处理写得好,线上问题能一眼定位;写得差,就是"出错了但什么也不知道"。
完成本节后,你应该能够:
Error、受检异常、非受检异常的区别与用途;try-catch-finally,并知道 finally 的语义;try-with-resources 自动关闭资源;(message, cause) 构造异常链,保留根因;RuntimeException 与普通 Exception 的区别吗?(《读懂 Java 错误信息》 提过一句,本节展开)异常处理最容易走两个极端:
极端一:到处 try-catch,catch 里什么都不做。
try {
processOrder(order);
} catch (Exception e) {
// 什么都不做
}后果:问题消失了,业务也坏了。订单没处理成功,日志里什么都没有,用户投诉时你只能猜。
极端二:throws Exception 一路往上甩。
public void handle() throws Exception { }后果:调用方完全不知道要处理什么,只能同样一路抛上去,最终在 main 里崩掉。
正确的做法:异常是 API 的一部分——它告诉调用方"可能会出什么问题、你需要处理什么"。本节讲的就是怎么把这件事做好。
Throwable
├── Error JVM 层面的严重问题,一般不捕获
│ ├── OutOfMemoryError
│ └── StackOverflowError
└── Exception
├── RuntimeException ← 非受检异常(unchecked),编译器不强制处理
│ ├── NullPointerException
│ ├── IllegalArgumentException
│ └── IllegalStateException
└── 其他 Exception ← 受检异常(checked),编译器强制处理
├── IOException
└── SQLException| 类别 | 编译期检查 | 典型代表 | 用途 |
|---|---|---|---|
Error | 无 | OutOfMemoryError | JVM 内部错误,不要捕获 |
| 受检异常 | 强制 | IOException | 调用方有能力恢复的问题 |
| 非受检异常 | 无 | IllegalArgumentException | 编程错误(参数不对、状态不对) |
怎么选:受检还是非受检
判断标准是"调用方能不能合理地恢复"。
实践趋势:现代 Java 框架(Spring 等)大量使用非受检异常,因为受检异常会污染整条调用链的签名。
try {
System.out.println("try 块执行");
throw new IllegalStateException("演示异常");
} catch (IllegalStateException exception) {
System.out.println("catch 块捕获: " + exception.getMessage());
} finally {
System.out.println("finally 块总会执行");
}执行顺序固定:try → catch(如有异常)→ finally(总是)。
finally 的语义:无论是否发生异常、是否 return、是否被 break,都会执行——唯一例外是 System.exit() 或 JVM 崩溃。
它的用途:释放资源(关文件、关连接、解锁)。但现代写法更推荐 try-with-resources。
try (StringWriter writer = new StringWriter()) {
writer.write("资源会被自动关闭");
System.out.println(writer);
}只要资源实现了 AutoCloseable,写在 try (...) 里就会自动关闭(无论正常结束还是抛异常)。多个资源用分号分隔,关闭顺序与声明顺序相反。
为什么它比 finally 好:
// 手动关闭:容易漏,且可能覆盖原始异常
FileReader reader = null;
try {
reader = new FileReader(file);
return read(reader);
} finally {
if (reader != null) {
reader.close(); // 如果这里抛异常,会覆盖 try 里的异常
}
}
// try-with-resources:不用管,且内部处理了"异常覆盖"问题
try (FileReader reader = new FileReader(file)) {
return read(reader);
}Java 9 之后,如果资源是 final 或等效 final 的变量,可以直接写变量名:try (reader)。
static class InsufficientBalanceException extends RuntimeException {
private final long shortfallInCents;
InsufficientBalanceException(String message, long shortfallInCents) {
super(message);
this.shortfallInCents = shortfallInCents;
}
long shortfallInCents() {
return shortfallInCents;
}
}自定义异常的价值:除了消息,还能携带结构化的业务数据(这里是"还差多少钱"),让上层能做判断,而不是去解析消息字符串。
设计要点:
| 要点 | 说明 |
|---|---|
| 继承哪个 | 业务错误(不可恢复)→ RuntimeException;可恢复的外部问题 → Exception |
| 命名 | 以 Exception 结尾,如 InsufficientBalanceException |
| 构造方法 | 提供 (String message) 与 (String message, Throwable cause) |
| 携带数据 | 把关键上下文做成字段(如 orderId、shortfallInCents) |
try {
Integer.parseInt("abc");
} catch (NumberFormatException exception) {
throw new IllegalStateException("参数解析失败", exception); // 把原异常作为 cause
}这样抛出后的信息里会包含完整的因果链:
包装后的异常: 参数解析失败
原因: NumberFormatException - For input string: "abc"三个必须遵守的点:
cause:new IllegalStateException("...", exception);new IllegalStateException(exception.getMessage()) 会丢掉类型与调用栈;log.error("处理失败", exception) 而不是 log.error("处理失败: " + exception.getMessage())。| 原则 | 含义 | 反例 |
|---|---|---|
| 早抛晚捕 | 发现问题就抛(不要让错误值继续传);在有上下文能处理的地方捕 | 返回 -1 表示错误,让调用方猜 |
| 保留原因 | 包装时带上 cause | 吞掉原始异常 |
| 不要吞异常 | 捕获后必须有动作(记日志、转换、上报) | catch (Exception e) { } |
static String misleadingMethod() {
try {
return "try 的返回值";
} finally {
return "finally 的返回值"; // 覆盖了上面的 return
}
}调用结果是 "finally 的返回值"——try 里的 return 被静默丢弃。
规则:finally 里不要写 return(也不要 throw),否则会掩盖真实结果或异常。IDE 会给出警告。
// 受检异常:不处理就编译不过
public void read() throws IOException {
Files.readString(Path.of("a.txt"));
}
// 非受检异常:编译器不管,但要在逻辑上处理
if (amount <= 0) {
throw new IllegalArgumentException("金额必须为正");
}void withdraw(long amountInCents) {
if (amountInCents <= 0) {
throw new IllegalArgumentException("取款金额必须大于 0");
}
if (amountInCents > balanceInCents) {
throw new InsufficientBalanceException("余额不足", amountInCents - balanceInCents);
}
balanceInCents -= amountInCents;
}三种情况对应三种处理:
| 情况 | 异常 | 说明 |
|---|---|---|
| 参数不合法(负数) | IllegalArgumentException | 编程错误,JDK 现成的异常够用 |
| 业务规则不满足(余额不足) | 自定义异常 + 数据 | 上层可能要展示"还差多少钱" |
| 状态不对(账户已注销) | IllegalStateException | 《读懂 Java 错误信息》 讲过两者的区别 |
注意"失败时状态不变":抛异常之前不能改状态,否则会出现"报错了但钱扣了"的事故。第 7 节的代码里有一行验证:失败后余额未变: 1000。
// 反模式一:吞异常
try {
process();
} catch (Exception e) {
// 什么都不做
}
// 反模式二:catch Exception(捕获过宽)
try {
int value = Integer.parseInt(input);
} catch (Exception e) { // 连 NullPointerException 都吞了
return 0;
}
// 反模式三:用异常做流程控制
try {
return map.get(key).intValue();
} catch (NullPointerException e) {
return 0; // 用"抛异常"代替判空,性能差且掩盖问题
}第三个反模式的正确写法:
return map.getOrDefault(key, 0);判断标准:异常应该是"例外情况",不是"预料之中的分支"。如果某个分支经常发生,就应该用 if 判断,而不是靠异常。
文件:labs/common-classes/src/main/java/cn/kb/learn/common/ExceptionLesson.java
package cn.kb.learn.common;
import java.io.StringWriter;
public class ExceptionLesson {
/** 自定义异常:除了消息,还可以携带业务数据 */
static class InsufficientBalanceException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final long shortfallInCents;
InsufficientBalanceException(String message, long shortfallInCents) {
super(message);
this.shortfallInCents = shortfallInCents;
}
long shortfallInCents() {
return shortfallInCents;
}
}
static class Account {
private long balanceInCents;
Account(long balanceInCents) {
this.balanceInCents = balanceInCents;
}
void withdraw(long amountInCents) {
if (amountInCents <= 0) {
throw new IllegalArgumentException("取款金额必须大于 0");
}
if (amountInCents > balanceInCents) {
throw new InsufficientBalanceException("余额不足", amountInCents - balanceInCents);
}
balanceInCents -= amountInCents;
}
long balanceInCents() {
return balanceInCents;
}
}
public static void main(String[] args) {
// 1. 异常层次
System.out.println("RuntimeException 继承 Exception: "
+ (RuntimeException.class.getSuperclass() == Exception.class));
System.out.println("Exception 继承 Throwable: "
+ (Exception.class.getSuperclass() == Throwable.class));
// 2. try-catch-finally 的执行顺序
try {
System.out.println("try 块执行");
throw new IllegalStateException("演示异常");
} catch (IllegalStateException exception) {
System.out.println("catch 块捕获: " + exception.getMessage());
} finally {
System.out.println("finally 块总会执行");
}
// 3. 自定义异常携带业务数据
Account account = new Account(1_000);
try {
account.withdraw(5_000);
} catch (InsufficientBalanceException exception) {
System.out.println("业务异常: " + exception.getMessage()
+ ",缺口 " + exception.shortfallInCents() + " 分");
}
System.out.println("失败后余额未变: " + account.balanceInCents());
// 4. 异常链:包装时保留原因
try {
wrapAndThrow();
} catch (IllegalStateException exception) {
System.out.println("包装后的异常: " + exception.getMessage());
System.out.println("原因: " + exception.getCause().getClass().getSimpleName()
+ " - " + exception.getCause().getMessage());
}
// 5. try-with-resources:自动关闭
try (StringWriter writer = new StringWriter()) {
writer.write("资源会被自动关闭");
System.out.println("try-with-resources: " + writer);
} catch (Exception exception) {
System.out.println("不会走到这里: " + exception.getMessage());
}
// 6. 多重 catch
for (String input : new String[]{"42", "abc", null}) {
try {
String safe = input.trim();
System.out.println("解析 " + safe + " -> " + Integer.parseInt(safe));
} catch (NumberFormatException | NullPointerException exception) {
System.out.println("解析 " + input + " 失败: " + exception.getClass().getSimpleName());
}
}
// 注意:Integer.parseInt(null) 抛的是 NumberFormatException 而不是 NullPointerException
try {
Integer.parseInt(null);
} catch (NumberFormatException exception) {
System.out.println("parseInt(null) 抛的是: " + exception.getClass().getSimpleName()
+ " - " + exception.getMessage());
}
// 7. finally 中的 return 会覆盖 try 中的 return
System.out.println("finally 覆盖返回值: " + misleadingMethod());
}
static void wrapAndThrow() {
try {
Integer.parseInt("abc");
} catch (NumberFormatException exception) {
throw new IllegalStateException("参数解析失败", exception);
}
}
/** 刻意演示:finally 中的 return 会覆盖 try 中的 return */
@SuppressWarnings("finally")
static String misleadingMethod() {
try {
return "try 的返回值";
} finally {
return "finally 的返回值";
}
}
}运行命令:
cd labs
./mvnw -pl common-classes compile
java -cp common-classes/target/classes cn.kb.learn.common.ExceptionLesson预期输出:
RuntimeException 继承 Exception: true
Exception 继承 Throwable: true
try 块执行
catch 块捕获: 演示异常
finally 块总会执行
业务异常: 余额不足,缺口 4000 分
失败后余额未变: 1000
包装后的异常: 参数解析失败
原因: NumberFormatException - For input string: "abc"
try-with-resources: 资源会被自动关闭
解析 42 -> 42
解析 abc 失败: NumberFormatException
解析 null 失败: NullPointerException
parseInt(null) 抛的是: NumberFormatException - Cannot parse null string
finally 覆盖返回值: finally 的返回值四处值得停留:
| 输出 | 含义 |
|---|---|
失败后余额未变: 1000 | 抛异常前没有改状态,这是业务代码的硬要求 |
原因: NumberFormatException - ... | 异常链保留了根因 |
解析 null 失败: NullPointerException | input.trim() 对 null 抛 NPE |
parseInt(null) 抛的是: NumberFormatException | 反直觉:parseInt 对 null 抛的不是 NPE |
finally 覆盖返回值: finally 的返回值 | try 里的 return 被静默丢弃 |
最后一行的 parseInt(null) 是个很好的例子:不要凭"应该抛什么"来写代码,要看实际行为——这也是为什么本知识库的每段代码都要实际运行。
catch (InsufficientBalanceException exception) 处断点,展开 exception 观察它的字段(shortfallInCents)与 cause(这里是 null);wrapAndThrow() 抛异常处断点,然后在 Frames 面板里从 wrapAndThrow 点回调用方,观察栈的层次——这就是"异常链之外,调用栈本身也在记录路径";misleadingMethod() 的 try 里 return 处断点,按 F7/Step Over 观察执行流跳到 finally 而不是返回——finally 优先于 return 执行;IllegalStateException,观察它停在 throw 的那一行而不是 catch 处。try {
process();
} catch (Exception e) {
}修复:至少要记日志,且要传异常对象:
} catch (Exception e) {
log.error("处理订单失败,orderId={}", orderId, e);
throw new IllegalStateException("订单处理失败", e);
}try {
// ...
} catch (Exception e) { }问题:连 NullPointerException、IllegalArgumentException 这些"代码 bug"也一起吞了。
修复:按需捕获具体异常类型;确实要兜底时,在捕获块里重新抛出(并带上原因)。
throw new IllegalStateException(exception.getMessage()); // 丢失类型与栈
throw new IllegalStateException("参数解析失败", exception); // 正确见 5.7 节。它会掩盖 try 里的返回值或异常,让排查变得极其困难。
// 不推荐:异常是"例外",不是"分支"
try {
return list.get(0);
} catch (IndexOutOfBoundsException e) {
return null;
}
// 推荐
return list.isEmpty() ? null : list.get(0);理由:异常构造与栈填充的成本远高于 if 判断;而且它掩盖了"空列表是正常情况"这个事实。
目标:设计一套业务异常并接入调用链。
步骤:
OrderException(业务异常)与 OrderNotFoundException(具体场景),要求携带 orderId;OrderService:void cancel(String orderId),订单不存在抛 OrderNotFoundException,订单已完成抛 IllegalStateException;main 里调用它,分别触发两个异常,观察捕获与输出;void processWithLog(String orderId):捕获业务异常后记录日志并重新抛出(带上原因),说明为什么"记录后还要抛"。验收方式:能解释第 5 步"既记录又抛出"与"只记录不抛"的区别。
扩展任务:给 OrderService 加一个方法 Optional<Order> find(String orderId),对比"返回 Optional"与"抛异常"两种表达"找不到"的方式,各自适合什么场景。
基础题
Error 应该被捕获并处理。(对 / 错)IllegalArgumentException 与 IllegalStateException 的区别?代码阅读题
static int divide(int a, int b) {
try {
return a / b;
} catch (ArithmeticException e) {
throw new IllegalArgumentException("除数不能为 0", e);
} finally {
System.out.println("finally");
}
}
System.out.println(divide(6, 2));
System.out.println(divide(6, 0));编程题
FileConfig 类: Map<String, String> load(Path path):读取 key=value 格式的配置文件(每行一条,# 开头为注释);ConfigLoadException,并携带路径;=)→ 抛 ConfigLoadException,携带行号;save(Path path, Map<String, String> config)。综合任务
PaymentService:PaymentResult pay(PaymentRequest request);提示 1(方向):第 4 题的关键是 finally 在 return 之前执行。
提示 2(关键点):第 5 题的 ConfigLoadException 可以携带 Path 与行号,方便定位。
提示 3(接近答案):第 6 题可以用一个 isRetryable(Throwable) 方法判断,只有 GatewayTimeoutException 返回 true。
Error 表示 JVM 层面的严重问题(内存耗尽、栈溢出),程序一般无法恢复,捕获它通常没有意义,反而会掩盖问题。IllegalArgumentException 表示"传入的参数不合法"(你给我的东西不对);IllegalStateException 表示"对象当前状态不允许这个操作"(时机不对)。两者都是非受检异常。finally
3
finally
Exception in thread "main" java.lang.IllegalArgumentException: 除数不能为 0
... Caused by: java.lang.ArithmeticException: / by zero过程:
divide(6, 2):try 里 return 计算完成 → finally 先执行(打印 finally)→ 然后才真正返回 3;divide(6, 0):a / b 抛 ArithmeticException → 进入 catch → 构造新异常(带上 cause)→ finally 执行(打印 finally)→ 然后抛出 IllegalArgumentException;main 里没有捕获,异常最终打印到控制台,并带有 Caused by 链。这个例子还说明:finally 在 return 与 throw 之前都会执行。
package cn.kb.learn.common;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
public class FileConfig {
/** 配置加载异常:携带路径或行号,便于定位 */
static class ConfigLoadException extends RuntimeException {
private final Path path;
private final int lineNumber;
ConfigLoadException(String message, Path path, int lineNumber, Throwable cause) {
super(message, cause);
this.path = path;
this.lineNumber = lineNumber;
}
Path path() {
return path;
}
int lineNumber() {
return lineNumber;
}
}
public Map<String, String> load(Path path) {
if (!Files.exists(path)) {
throw new ConfigLoadException("配置文件不存在", path, -1, null);
}
Map<String, String> config = new LinkedHashMap<>();
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
String line;
int lineNumber = 0;
while ((line = reader.readLine()) != null) {
lineNumber++;
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
continue;
}
int index = trimmed.indexOf('=');
if (index <= 0) {
throw new ConfigLoadException("配置格式错误(缺少 = )", path, lineNumber, null);
}
config.put(trimmed.substring(0, index).trim(), trimmed.substring(index + 1).trim());
}
} catch (IOException exception) {
throw new ConfigLoadException("读取配置失败", path, -1, exception);
}
return config;
}
public void save(Path path, Map<String, String> config) {
StringBuilder builder = new StringBuilder();
config.forEach((key, value) -> builder.append(key).append('=').append(value).append(System.lineSeparator()));
try {
Files.writeString(path, builder.toString(), StandardCharsets.UTF_8);
} catch (IOException exception) {
throw new ConfigLoadException("写入配置失败", path, -1, exception);
}
}
public static void main(String[] args) throws IOException {
Path path = Path.of("target", "io-demo", "app.conf");
Files.createDirectories(path.getParent());
FileConfig fileConfig = new FileConfig();
Map<String, String> config = new LinkedHashMap<>();
config.put("app.name", "知识库");
config.put("app.lessons", "32");
fileConfig.save(path, config);
System.out.println("保存并读回: " + fileConfig.load(path));
try {
fileConfig.load(Path.of("target", "io-demo", "missing.conf"));
} catch (ConfigLoadException exception) {
System.out.println("预期内的失败: " + exception.getMessage() + " -> " + exception.path());
}
Path bad = Path.of("target", "io-demo", "bad.conf");
Files.writeString(bad, "good=1\nbad-line\n", StandardCharsets.UTF_8);
try {
fileConfig.load(bad);
} catch (ConfigLoadException exception) {
System.out.println("预期内的失败: " + exception.getMessage() + " -> 第 " + exception.lineNumber() + " 行");
}
}
}设计要点:
ConfigLoadException,调用方只需要处理一种异常;Path 与行号,让错误信息直接指向问题位置;BufferedReader 自动关闭,即使中途抛异常;IOException),保留了底层原因。预期输出:
保存并读回: {app.name=知识库, app.lessons=32}
预期内的失败: 配置文件不存在 -> target\io-demo\missing.conf
预期内的失败: 配置格式错误(缺少 = ) -> 第 2 行package cn.kb.learn.common;
public class PaymentService {
/** 余额不足:业务问题,提示用户,不重试 */
static class InsufficientBalanceException extends RuntimeException {
InsufficientBalanceException(String message) {
super(message);
}
}
/** 网关超时:外部问题,可重试 */
static class GatewayTimeoutException extends RuntimeException {
GatewayTimeoutException(String message, Throwable cause) {
super(message, cause);
}
}
record PaymentRequest(String orderId, long amountInCents) {
}
record PaymentResult(boolean success, String message) {
}
private final Gateway gateway;
private final long balanceInCents;
PaymentService(Gateway gateway, long balanceInCents) {
this.gateway = gateway;
this.balanceInCents = balanceInCents;
}
interface Gateway {
void charge(PaymentRequest request) throws Exception;
}
PaymentResult pay(PaymentRequest request) {
if (request.amountInCents() <= 0) {
throw new IllegalArgumentException("金额必须为正"); // 编程错误,不重试
}
if (request.amountInCents() > balanceInCents) {
throw new InsufficientBalanceException("余额不足"); // 业务问题,不重试
}
int attempts = 0;
while (true) {
attempts++;
try {
gateway.charge(request);
return new PaymentResult(true, "支付成功(第 " + attempts + " 次尝试)");
} catch (Exception exception) {
if (!isRetryable(exception) || attempts >= 3) {
throw new GatewayTimeoutException("支付网关异常,已尝试 " + attempts + " 次", exception);
}
}
}
}
/** 只有外部可恢复的问题才值得重试 */
private boolean isRetryable(Throwable throwable) {
return throwable instanceof java.util.concurrent.TimeoutException
|| throwable instanceof java.io.IOException;
}
public static void main(String[] args) {
Gateway flaky = request -> {
if (Math.random() < 0.7) {
throw new java.util.concurrent.TimeoutException("网关超时");
}
};
try {
new PaymentService(flaky, 100_000).pay(new PaymentRequest("O1", 1_000));
} catch (GatewayTimeoutException exception) {
System.out.println("预期内的失败: " + exception.getMessage());
}
try {
new PaymentService(flaky, 100_000).pay(new PaymentRequest("O1", -1));
} catch (IllegalArgumentException exception) {
System.out.println("预期内的失败: " + exception.getMessage());
}
}
}为什么"参数非法"不该被重试:
重试的判断标准:重试能否改变结果。网络超时、临时不可用可以重试;参数错误、业务规则不满足不能重试。
(message, cause) 保留根因;finally 里不该写 return。try-with-resources 的主要应用场景《文件与 IO 流》:读写文件、处理大文件、理解字节流与字符流的区别。