跳到内容

第 1 卷 / 第 11 章 / 第 1 课

11.1 异常机制 ​

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

找不到 missingBatch ​

导入任务要读取一个批次,登记表里却没有 missingBatch。旧接口用 -1 表示“批次不存在”:

java
int count = findItemCount("missingBatch");
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> BATCHES = Map.of("starterBatch", 3);

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

        try {
            divide(10, 0);
        } catch (ArithmeticException exception) {
            System.out.println("运行时异常:" + exception.getClass().getSimpleName());
        } finally {
            System.out.println("finally:结束本次计算");
        }

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

    static int openBatch(String batchName) {
        try {
            return getItemCount(batchName);
        } catch (BatchNotFoundException exception) {
            throw new BatchAccessException("无法读取批次 " + batchName, exception);
        }
    }

    static int getItemCount(String batchName) throws BatchNotFoundException {
        Integer count = BATCHES.get(batchName);
        if (count == null) {
            throw new BatchNotFoundException("批次 " + batchName + " 不存在");
        }
        return count;
    }

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

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

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

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

        BatchAccessException(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 -encoding UTF-8 -Xlint:all -d out ExceptionFlowDemo.java
java -cp out ExceptionFlowDemo

预期输出:

text
批次读取失败:无法读取批次 missingBatch
原始类型:BatchNotFoundException
运行时异常:ArithmeticException
finally:结束本次计算
异常流检查通过

保存需要的输出后清理:

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

辅助方法 expect 中的 <T extends Throwable> 表示返回指定的异常子类型;class 参数用于运行时检查,泛型细节在下一章展开。其他短片段只展示相关方法,不要把重复定义全部粘进同一个类。

程序验证了三件事: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 ​

Throwable 层次中,除 RuntimeException、Error 及其各自的子类外,其余异常类型都属于 checked。通常通过继承 Exception 定义受检异常。方法若可能让受检异常离开,就必须捕获它或在签名中声明:

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

调用方可以只恢复文件缺失,其余 IOException 继续通过 throws 传播。以下片段需要 java.io、java.nio.file 和 StandardCharsets 的相关导入:

java
static void startWithFallback() throws IOException {
    String map;
    try {
        map = loadMap(Path.of("map.txt"));
    } catch (NoSuchFileException exception) {
        map = "默认区域,0,0";
    }
    System.out.println(map);
}

也可以不在这一层恢复,让所有读取失败继续传播:

java
static void startApplication() 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. throw 与 throws ​

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

java
static int getItemCount(String batchName) throws BatchNotFoundException {
    if (batchName.isBlank()) {
        throw new BatchNotFoundException("批次名不能为空");
    }
    return 3;
}
  • throw expression 在当前位置抛出一个 Throwable 对象。
  • throws Type 写在方法签名上,声明某种异常可能离开;受检异常必须声明或处理,非受检异常也允许写在 throws 中。

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

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

假设调用链是:

text
main -> startApplication -> openBatch -> getItemCount

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

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

4.1 catch 按类型匹配 ​

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

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

Java 7+ 支持 multi-catch:

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

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

5. 翻译异常时保留 cause ​

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

java
// 丢失根因
throw new BatchAccessException("无法读取批次 " + batchName, null);

// 保留根因
throw new BatchAccessException("无法读取批次 " + batchName, exception);

Throwable 常用信息:

方法用途
getMessage()当前层给人的简短说明
getCause()被包装的原始异常
getStackTrace()已记录的栈帧,通常来自异常构造时的调用栈
getSuppressed()资源关闭等过程中附加的次要异常

重新 throw 同一个异常一般不会刷新它的堆栈。日志里最上方的业务方法通常是异常创建处,而不一定是最后一次抛出的位置;fillInStackTrace 或 setStackTrace 才会显式改变记录。

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

6. finally 的准确边界 ​

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

java
try {
    openBatch("missingBatch");
} 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<Batch> batch = inventory.find("missingBatch");
batch.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 -> startApplication -> openBatch -> getItemCount 四层调用,只在 startApplication 捕获 BatchNotFoundException。这个版本的 openBatch 要声明并传播受检异常,不能沿用开头把它包装成 BatchAccessException 的方法体。用方法名检查堆栈包含四层调用,不要断言行号;行号会随正文编辑变化。

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

查阅 Java 17 的正式规则 ​

下一步:异常处理实战 ​

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

继续学习异常处理实战

Built with VitePress | Software Systems Atlas