《Java 编程入门》13.1 受检与非受检异常

从 borrow 可能失败的四种情况和 Thread.sleep 的编译错误出发,拆开 Throwable 家族里 Error、受检异常与 RuntimeException 的边界;再建立 LibraryException、ErrorCode 与四个领域异常,接到借阅规则上,并给出漏声明、捕获顺序、finally 吞异常的真实报错。

本节目标:分清 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 的子类。编译器要求调用方二选一:

  1. 用 try/catch 当场处理。
  2. 在方法签名上写 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 与关闭顺序 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发