本节目标:分清
Error、受检异常和RuntimeException的职责,知道什么时候该throws、什么时候该抛运行时异常,并建立library自己的LibraryException体系,把借阅规则接进去。
适用版本:JDK 25 LTS(Temurin 25.0.5);异常体系与 JDK 21 一致。
上一节留下的问题
LibraryService.borrow 至少有四种失败方式:
- ISBN 对应的书不存在。
- 会员号对应的会员不存在。
- 这本书已经被别人借走。
- 会员同时借阅数达到上限。
12.3 用 Optional 表达了「找不到」,但 Optional 只有「空」和「有值」两种状态,装不下「为什么失败」。而「书已被借走」「达到上限」也不是「没有值」,而是「规则不允许」。
另一个方向是受检异常。假设服务层要记录操作日志,用 Thread.sleep 模拟一下等待:
static void pause() {
Thread.sleep(100);
}
这段代码编译不过:
错误: 未报告的异常错误InterruptedException; 必须对其进行捕获或声明以便抛出
Thread.sleep(100);
^
同样是「可能失败」,为什么 Thread.sleep 强迫调用方处理,而 Integer.parseInt("abc") 可以直接抛 NumberFormatException?答案在异常体系的分类里。
Throwable 家族:三类失败
Java 的异常都继承自 Throwable,顶层分成两支:
Throwable
├── Error JVM 级问题:OutOfMemoryError、StackOverflowError
└── Exception
├── RuntimeException 非受检:NullPointerException、IllegalArgumentException
└── 其他 受检:IOException、InterruptedException、SQLException
| 类型 | 是否强制处理 | 典型例子 | 应该怎么对待 |
|---|---|---|---|
Error | 否 | OutOfMemoryError、StackOverflowError | 不要捕获,记录后让进程退出或重启 |
| 受检异常 | 是 | IOException、InterruptedException | 在能恢复的层处理,或向上声明 |
RuntimeException | 否 | NullPointerException、IllegalStateException | 修代码,或在边界统一兜底 |
Error 表示 JVM 自身出了问题,比如内存耗尽、栈溢出。捕获它通常没有意义,因为程序已经处于不可信状态。Exception 才是应用代码要处理的部分。
受检异常:调用方必须表态
受检异常(checked exception)是 Exception 的子类,但不是 RuntimeException 的子类。编译器要求调用方二选一:
- 用
try/catch当场处理。 - 在方法签名上写
throws,把责任交给上一层。
static void pause() throws InterruptedException {
Thread.sleep(100);
}
或者:
static void pause() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("等待被中断", e);
}
}
第二种写法里有一个容易漏掉的细节:捕获 InterruptedException 后要调用 Thread.currentThread().interrupt() 恢复中断标志。否则上层永远不知道线程被中断过。卷三讲并发时会展开,现在先记住这个动作。
受检异常的设计意图是:把「可恢复、调用方应该考虑」的失败写进方法签名。 文件读不到、网络断开、线程被中断,这些都属于调用方可以决定「重试、降级还是放弃」的情况。
代价也很明显:
- 方法签名会变长,调用链上每一层都要
throws或catch。 - Lambda 和 Stream 的函数式接口大多不允许抛受检异常,处理起来很别扭。
- 很多失败其实无法在调用点恢复,强迫处理只会催生空的
catch块。
所以现代 Java 代码的常见做法是:受检异常留给真正的外部资源边界(IO、网络、并发中断),领域规则和编程错误用运行时异常。
非受检异常:规则违反与编程错误
RuntimeException 及其子类不要求声明。常见的几个:
| 异常 | 含义 | 例子 |
|---|---|---|
NullPointerException | 对 null 调用方法或访问字段 | book.title() 但 book 是 null |
IllegalArgumentException | 参数不合法 | new Book("", ...) |
IllegalStateException | 对象状态不允许当前操作 | 对已归还的借阅再次归还 |
IndexOutOfBoundsException | 下标越界 | list.get(10) |
UnsupportedOperationException | 操作不被支持 | 修改 List.of(...) |
这些异常通常表示「代码写错了」或「调用方违反了契约」,不应该被逐层捕获。真正该做的是在程序边界(CLI、HTTP 入口、消息消费者)统一兜底,记录日志并返回用户能理解的错误。
library 的领域规则属于后一类:书被借走、达到上限、会员不存在,都是业务上的「不允许」,不是 JVM 故障,也不是每个调用点都能恢复。把它们做成 RuntimeException,可以让服务层保持干净的方法签名,由 CLI 统一转换成错误码。
建立 LibraryException 体系
先定义错误码。每个错误码带一个稳定的字符串和进程退出码:
public enum ErrorCode {
BOOK_NOT_FOUND("E_BOOK_NOT_FOUND", 2),
MEMBER_NOT_FOUND("E_MEMBER_NOT_FOUND", 2),
BOOK_ALREADY_BORROWED("E_BOOK_ALREADY_BORROWED", 3),
DUPLICATE_ISBN("E_DUPLICATE_ISBN", 3),
LOAN_NOT_FOUND("E_LOAN_NOT_FOUND", 2),
LOAN_LIMIT_EXCEEDED("E_LOAN_LIMIT_EXCEEDED", 3),
INVALID_COMMAND("E_INVALID_COMMAND", 2),
INTERNAL("E_INTERNAL", 1);
private final String code;
private final int exitCode;
ErrorCode(String code, int exitCode) {
this.code = code;
this.exitCode = exitCode;
}
public String code() {
return code;
}
public int exitCode() {
return exitCode;
}
}
退出码的约定是:0 成功,1 内部错误,2 输入或查找失败,3 业务规则拒绝。这个约定会在 13.3 接到 CLI 上。
基类继承 RuntimeException,额外携带 ErrorCode:
public class LibraryException extends RuntimeException {
private final ErrorCode errorCode;
public LibraryException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public LibraryException(ErrorCode errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public ErrorCode errorCode() {
return errorCode;
}
public String userMessage() {
return "[" + errorCode.code() + "] " + getMessage();
}
}
userMessage() 把错误码和消息拼成用户可见的一行,getCause() 保留底层异常,方便日志排查。具体异常只需要传固定的错误码和可读消息:
public final class BookNotFoundException extends LibraryException {
public BookNotFoundException(String isbn) {
super(ErrorCode.BOOK_NOT_FOUND, "找不到 ISBN 为 " + isbn + " 的图书");
}
}
public final class BookAlreadyBorrowedException extends LibraryException {
public BookAlreadyBorrowedException(String isbn, String memberId) {
super(ErrorCode.BOOK_ALREADY_BORROWED,
"图书 " + isbn + " 已被借出,会员 " + memberId + " 无法借阅");
}
}
MemberNotFoundException、LoanLimitExceededException、InvalidCommandException 同理。所有领域异常都继承同一个基类,CLI 只需要 catch (LibraryException e) 一次,就能统一拿到错误码和消息。
在 library 里落地
把异常接到借阅规则上。borrow 先确认书和人存在,再检查「书是否在借」和「会员是否达到上限」:
public Loan borrow(String isbn, String memberId) {
books.findById(isbn).orElseThrow(() -> new BookNotFoundException(isbn));
members.findById(memberId).orElseThrow(() -> new MemberNotFoundException(memberId));
if (loans.findActiveByIsbn(isbn).isPresent()) {
throw new BookAlreadyBorrowedException(isbn, memberId);
}
if (loans.findActiveByMember(memberId).size() >= MAX_ACTIVE_LOANS) {
throw new LoanLimitExceededException(memberId, MAX_ACTIVE_LOANS);
}
LocalDate today = LocalDate.now(clock);
Loan loan = new Loan(nextLoanId.getAndIncrement(), isbn, memberId,
today, today.plusDays(LOAN_DAYS), null);
return loans.save(loan);
}
四个检查对应四个异常,方法签名里没有 throws,因为它们是运行时异常。调用方如果不处理,异常会一路抛到 CLI;如果只想处理其中一种,可以单独 catch 子类。
归还也有对应的检查:
public Loan returnBook(String isbn) {
Loan active = loans.findActiveByIsbn(isbn)
.orElseThrow(() -> new LibraryException(ErrorCode.LOAN_NOT_FOUND,
"图书 " + isbn + " 当前没有在借记录"));
return loans.save(active.returnOn(LocalDate.now(clock)));
}
这里直接用了基类 LibraryException,因为「没有在借记录」没有更细的分类需求。需要区分时再拆子类,不要为了每个消息都建一个类。
跑一遍四种失败:
LibraryService service = DemoData.service();
Loan first = service.borrow("978-7-111-54742-6", "M001");
System.out.println("borrowed = " + first.isbn() + " due " + first.dueOn());
attempt("重复借出", () -> service.borrow("978-7-111-54742-6", "M002"));
attempt("会员不存在", () -> service.borrow("978-7-111-11091-5", "M999"));
service.borrow("978-7-111-11091-5", "M002");
service.borrow("978-7-115-54608-1", "M002");
service.borrow("978-7-121-35736-7", "M002");
attempt("超过上限", () -> service.borrow("978-7-115-52106-2", "M002"));
本机 JDK 25 输出:
borrowed = 978-7-111-54742-6 due 2026-10-27
重复借出 -> E_BOOK_ALREADY_BORROWED : 图书 978-7-111-54742-6 已被借出,会员 M002 无法借阅
会员不存在 -> E_MEMBER_NOT_FOUND : 找不到编号为 M999 的会员
超过上限 -> E_LOAN_LIMIT_EXCEEDED : 会员 M002 同时借阅数已达上限 3
attempt 是一个只捕获 LibraryException 的小工具,把错误码和消息打印出来;它不会吞掉其他异常,这一点很重要——如果代码里出现 NullPointerException,应该让它暴露出来,而不是被当成业务失败。
常见错误与边界
错误一:漏声明受检异常
前面 Thread.sleep 的例子就是典型。受检异常要么 catch,要么 throws,没有第三种选择。如果一个方法确实无法处理,就把 throws 往上写,让更有能力的层决定;不要写一个空的 catch 块把异常吞掉。
错误二:捕获顺序写反
try {
System.out.println("x");
} catch (Exception e) {
System.out.println("e");
} catch (RuntimeException e) {
System.out.println("r");
}
错误: 已捕获到异常错误RuntimeException
} catch (RuntimeException e) {
^
RuntimeException 是 Exception 的子类,前面的 catch (Exception) 已经把它接住了,后面的分支永远不可达。捕获顺序必须从具体到宽泛:先子类,再父类。
错误三:在 finally 里 return
static int value() {
try {
throw new RuntimeException("boom");
} finally {
return 1;
}
}
本机 JDK 25 输出:
1
finally 里的 return 会覆盖 try 中正在抛出的异常,异常被静默吞掉。编译器和运行时不报错,但调用方永远看不到 boom。finally 只应该做清理(关闭资源、恢复标志),不要写 return、break、continue。
边界:不要 catch Throwable
catch (Throwable t) 会连 OutOfMemoryError、StackOverflowError 一起接住。程序此时可能已经无法安全分配对象,继续执行只会制造更难排查的问题。应用代码最多捕到 Exception,而且优先捕具体的子类。
边界:受检异常进不了 Stream
Stream.map 接收的 Function 不允许抛受检异常。如果转换逻辑里有 IOException,必须在 Lambda 内部 try/catch 并转成运行时异常,或者换用普通的 for 循环。第 14 章读文件时会遇到这个取舍。
小结
Throwable分Error、受检Exception和RuntimeException;Error不应捕获,受检异常必须处理或声明。- 受检异常适合外部资源边界(IO、网络、中断),代价是签名污染和 Lambda 不友好。
RuntimeException表示规则违反或编程错误,由程序边界统一兜底;library的领域异常采用这一支。LibraryException携带ErrorCode和userMessage(),子类只负责固定错误码和消息。borrow的四个检查分别抛出四个领域异常,方法签名保持干净,CLI 可以一次捕获基类。- 不要空
catch、不要把宽泛的catch写在前面、不要在finally里return。
异常能表达「失败了」,但失败之后往往还有必须执行的清理:关闭文件、释放连接、写回缓冲。下一节讲 try-with-resources,以及多个资源关闭时到底谁先谁后、关闭失败又会发生什么。
阅读导航:上一节:12.3 Optional 与惰性求值 · 下一节:13.2 try-with-resources 与关闭顺序 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。