跳到内容

11.1 异常机制

掌握控制流、方法和调用栈后,就可以用约 35 分钟跑完这篇 Java 17 示例。读完应能分清 checked 与 unchecked exception,并沿调用栈解释一次失败会在哪里被捕获。

找不到 mysteryBag

旅行者来取背包,登记册里却没有 mysteryBag。旧接口用 -1 表示“背包不存在”:

java
int count = findItemCount("mysteryBag");
if (count == -1) {
    // -1 是错误,还是某种合法状态?
}

返回值适合表达正常结果,OptionalInt 也能表达“可能没有”。异常适合另一类情况:方法无法履行自己的契约,调用方需要在更合适的边界处理失败。两种通道分开后,成功路径不必在每一步传递错误码。

异常也有成本。它会改变控制流,创建堆栈信息;若调用方本来就经常查询“有没有这个背包”,Optional 比异常更贴近业务。先判断失败是否属于正常分支,再决定接口。

1. 三分钟跑通异常流

先准备临时目录:

bash
chapter11_flow_root=$(mktemp -d)
readonly chapter11_flow_root
mkdir -p "$chapter11_flow_root/out"
cd "$chapter11_flow_root"

保存为 ExceptionFlowDemo.java

java
import java.util.Map;
import java.util.Objects;

public class ExceptionFlowDemo {
    private static final Map<String, Integer> BAGS = Map.of("starterBag", 3);

    public static void main(String[] args) {
        BagAccessException accessFailure = expect(
                BagAccessException.class,
                () -> openBag("mysteryBag"));
        check(accessFailure.getCause() instanceof BagNotFoundException,
                "包装异常必须保留原始 cause");
        System.out.println("背包读取失败:" + accessFailure.getMessage());
        System.out.println("原始类型:" + accessFailure.getCause().getClass().getSimpleName());

        try {
            divide(10, 0);
        } catch (ArithmeticException exception) {
            System.out.println("运行时异常:" + exception.getMessage());
        } finally {
            System.out.println("finally:离开风险区");
        }

        checkEquals(3, openBag("starterBag"));
        System.out.println("异常流检查通过");
    }

    static int openBag(String bagName) {
        try {
            return getItemCount(bagName);
        } catch (BagNotFoundException exception) {
            throw new BagAccessException("无法读取背包 " + bagName, exception);
        }
    }

    static int getItemCount(String bagName) throws BagNotFoundException {
        Integer count = BAGS.get(bagName);
        if (count == null) {
            throw new BagNotFoundException("背包 " + bagName + " 不存在");
        }
        return count;
    }

    static int divide(int dividend, int divisor) {
        return dividend / divisor;
    }

    static final class BagNotFoundException extends Exception {
        private static final long serialVersionUID = 1L;

        BagNotFoundException(String message) {
            super(message);
        }
    }

    static final class BagAccessException extends RuntimeException {
        private static final long serialVersionUID = 1L;

        BagAccessException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    static <T extends Throwable> T expect(Class<T> type, Runnable action) {
        try {
            action.run();
        } catch (Throwable throwable) {
            if (type.isInstance(throwable)) return type.cast(throwable);
            throw new AssertionError(
                    "expected=" + type + ", actual=" + throwable, throwable);
        }
        throw new AssertionError("expected exception=" + type.getName());
    }

    static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }

    static void checkEquals(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) {
            throw new AssertionError("expected=" + expected + ", actual=" + actual);
        }
    }
}

编译并运行:

bash
javac --release 17 -Xlint:all -d out ExceptionFlowDemo.java
java -cp out ExceptionFlowDemo

预期输出:

text
背包读取失败:无法读取背包 mysteryBag
原始类型:BagNotFoundException
运行时异常:/ by zero
finally:离开风险区
异常流检查通过

保存需要的输出后清理:

bash
cd
ls -ld -- "$chapter11_flow_root"
rm -r -- "$chapter11_flow_root"

程序验证了三件事:checked exception 可以被上层转换,转换时能保留 cause;RuntimeException 不要求出现在 throws 中;finally 在这次正常的栈展开中执行。

2. Throwable 家族树

Java 只能抛出 Throwable 或其子类:

text
Throwable
├── Error                         // unchecked
│   ├── OutOfMemoryError
│   ├── StackOverflowError
│   └── AssertionError
└── Exception
    ├── RuntimeException          // unchecked
    │   ├── NullPointerException
    │   ├── IllegalArgumentException
    │   └── IndexOutOfBoundsException
    ├── IOException               // checked
    └── SQLException              // checked

2.1 Error

Error 表示运行环境或程序状态中的严重问题。业务代码通常无法在当前操作中恢复,例如内存耗尽或栈溢出。AssertionError 也在这个分支,所以“Error 都是 JVM 自身的 bug”并不准确。

应用代码一般不捕获 Error 后继续业务流程。顶层线程、任务或服务边界可能记录失败并终止当前单元,具体策略由运行环境决定。不要用 catch (Throwable) 把 Error 和正常业务异常一起吞掉。

2.2 Checked exception

Exception 中不属于 RuntimeException 的子类是 checked exception。方法若可能把它抛给调用方,就要捕获或在方法签名中声明:

java
static String loadMap(Path path) throws IOException {
    return Files.readString(path, StandardCharsets.UTF_8);
}

调用方有两个选择:

java
try {
    String map = loadMap(Path.of("map.txt"));
    System.out.println(map);
} catch (NoSuchFileException exception) {
    System.out.println("map.txt 不存在,使用默认地图");
}

或者继续声明:

java
static void startGame() throws IOException {
    String map = loadMap(Path.of("map.txt"));
    System.out.println(map);
}

编译器强制你做出这个决定,但它不判断你的处理是否合理。空 catch 同样能通过编译,却会丢掉失败信息。

2.3 Unchecked exception

RuntimeException 及其子类不需要捕获或声明。空引用、非法参数、错误索引和违反对象状态约束常用这一分支表达:

java
static void setCapacity(int capacity) {
    if (capacity <= 0) {
        throw new IllegalArgumentException("capacity 必须为正数");
    }
}

Error 也属于 unchecked throwable。这里的 “unchecked” 只描述编译器规则,不代表异常轻微,也不代表调用方应该忽略。

2.4 选 checked 还是 unchecked

问题倾向 checked倾向 unchecked
调用方能否采取明确恢复动作能选择备用文件、重试来源或请求新输入只能修正调用代码或终止当前操作
是否希望恢复义务出现在 API 签名里
失败是否违反方法前置条件通常不是常用 IllegalArgumentException 等表达
现有框架/API 约定延续 checked 契约延续 runtime 契约

“外部环境问题都用 checked,代码 bug 都用 unchecked”只能当粗略提示。网络失败有些 API 用 checked,有些异步 API 把它放进 Future;领域规则失败也可能设计成 checked,让调用方明确选择补救路径。选择要看调用者能做什么。

3. throwthrows

两个词只差一个字母,作用完全不同:

java
static int getItemCount(String bagName) throws BagNotFoundException {
    if (bagName.isBlank()) {
        throw new BagNotFoundException("背包名不能为空");
    }
    return 3;
}
  • throw expression 在当前位置抛出一个 Throwable 对象。
  • throws Type 写在方法签名上,声明 checked exception 可能离开该方法。

throws 不会抛出异常,也不会处理异常。它把契约暴露给调用方。

4. 异常怎样沿调用栈传播

假设调用链是:

text
main -> startGame -> openBag -> getItemCount

getItemCount 抛出异常后,JVM 查找当前方法中能接住它的 catch。没有匹配项,就退出当前栈帧并回到 openBag。这个过程继续向上,直到遇到第一个类型兼容的 catch;若一直没有,线程的未捕获异常处理器会接手,常见命令行程序随后终止该线程并打印堆栈。

栈展开时,各层的 finally 会按规则运行。局部变量随栈帧退出而不可再访问。已经发生的外部副作用不会倒退:写入的文件、发出的网络请求或已经扣掉的金币都要由业务设计补偿。

4.1 catch 按类型匹配

java
try {
    loadMap(Path.of("map.txt"));
} catch (NoSuchFileException exception) {
    useDefaultMap();
} catch (IOException exception) {
    reportReadFailure(exception);
}

NoSuchFileExceptionIOException 的子类,必须放在父类 catch 前。反过来写时,后面的子类分支永远到不了,编译器会报错。

Java 7+ 支持 multi-catch:

java
catch (NumberFormatException | IOException exception) {
    reportBadInput(exception);
}

两个备选类型不能存在父子关系,例如 NoSuchFileException | IOException 不合法。更完整的 catch 顺序和资源关闭放在下一篇实战中。

5. 翻译异常时保留 cause

openBag 不想把底层的 BagNotFoundException 暴露给界面层,于是换成 BagAccessException。转换异常没有问题,丢掉原异常才会让排查失去线索:

java
// 丢失根因
throw new BagAccessException("无法读取背包 " + bagName);

// 保留根因
throw new BagAccessException("无法读取背包 " + bagName, exception);

Throwable 常用信息:

方法用途
getMessage()当前层给人的简短说明
getCause()被包装的原始异常
getStackTrace()异常创建/抛出路径的栈帧
getSuppressed()资源关闭等过程中附加的次要异常

上层加上下文时要具体,例如材料编号或安全的文件名。不要把密码、令牌、完整个人数据写进异常。一个异常通常由真正拥有请求、任务或进程边界的一层记录;每层都“log 后再 throw”会生成多份相同日志。

6. finally 的准确边界

finally 通常在控制流离开 try/catch 时执行,包括正常结束、return 和异常传播:

java
try {
    openBag("mysteryBag");
} finally {
    releaseReservation();
}

它不是“无论宇宙发生什么都执行”。进程被强制终止、Runtime.halt、断电或 JVM 崩溃时,当前 finally 可能没有机会运行。需要可靠提交的数据应交给事务、持久化协议或外部协调,不能只押在 finally 上。

不要在 finally 中 return,也不要随手抛一个新异常。两者都可能覆盖 try/catch 原本的返回值或主异常:

java
static int broken() {
    try {
        return 1;
    } finally {
        return 2; // 调用方只能看到 2
    }
}

文件、连接等 AutoCloseable 资源优先使用 try-with-resources。它能正确处理关闭时再次失败的情况,下一篇会实际验证 suppressed exception。

7. 什么时候 catch

catch 之后至少要完成一种明确动作:

  1. 当前层能恢复,例如缺少 map.txt 时选择内置地图。
  2. 当前层能转换抽象并保留 cause,例如存储异常转成领域异常。
  3. 当前层拥有顶级边界,需要记录失败并结束这次请求或任务。

如果当前层做不了这些事,继续传播通常更诚实。catch (Exception) 在请求入口或任务调度器处可能合理,在底层工具方法里常会把调用方本该看到的错误藏起来。

异常也不适合代替普通条件判断:

java
Optional<Bag> bag = inventory.find("mysteryBag");
bag.ifPresentOrElse(this::open, this::showMissingMessage);

当“不存在”是频繁、可预期的查询结果,Optional 或明确返回类型更容易读。方法承诺“必须打开指定背包”而无法完成时,异常更合适。

8. 把旧事故逐一改正

map.txt 不存在

游戏能使用默认地图,就在最接近启动策略的一层捕获 NoSuchFileException。其他 I/O 失败未必能恢复,应保留 cause 后传播或结束启动。不要把所有 IOException 都悄悄解释成“文件不存在”。

name 为 null

旧程序让 name.length() 抛出 NullPointerException。若 null 违反参数契约,在方法入口用 Objects.requireNonNull(name, "name");若缺少名字是合法状态,用 Optional 或专门的值类型表达。无论哪种方式,都比在远处捕获 NPE 后继续运行更清楚。

除以零

整数除零抛 ArithmeticException。若除数来自用户输入,先验证并返回可理解的错误;若它来自内部计算,异常往往暴露了逻辑缺陷。catch 后打印一句“继续运行”不能修复错误数据。

9. 把恢复策略写进代码

先实现 loadMap(Path requested, Path fallback)。程序应按 UTF-8 读取 requested;只有它抛出 NoSuchFileException 时,才尝试 fallback。requested 的其他 I/O 失败直接转换成 MapLoadException 并保留 cause,不能悄悄换地图。

若两个路径都不存在或备用文件读取失败,MapLoadException 的消息要包含两个经过确认可以公开的文件名,以备用读取异常为 cause,并把第一次的 NoSuchFileException 留在 suppressed 列表。这样诊断链能回答“为什么启用备用来源”和“备用来源为什么也失败”。

再写出 main -> startGame -> openBag -> getItemCount 四层调用,只在 startGame 捕获 BagNotFoundException。用方法名检查堆栈包含四层调用,不要断言行号;行号会随正文编辑变化。

完成后,故意交换父子 catch 的顺序,确认编译器拒绝不可达分支;再移除一次异常转换中的 cause,观察测试如何失去根因。能解释这两次失败,比背一张 Throwable 家族树更接近真实排障。

查阅 Java 17 的正式规则

下一步:异常处理实战

你已经能看懂异常从 getItemCount 一路回到调用方。下一篇把这套机制放进真实文件和领域操作:多个 catch 如何排序,资源关闭又失败时怎样保留主异常,自定义异常应携带哪些上下文。

继续学习异常处理实战

Built with VitePress | Software Systems Atlas