☕ 第五章 异常

5.4 抛出异常

📄 一句话概括

5.3 认过了 throw(动作)和 throws(声明)的脸,本节把"甩"讲成体系:throw 一执行方法立刻中断(后面连可执行代码都不能写);throws 让异常沿着调用链一层层往上,直到有人接住,或甩到 main 都没人管、交给 JVM 打报告收场——这就是甩锅链条。外加两条规矩:两派在"甩"上的待遇差异、重写方法不能甩得比父类更多。

1. throw 甩出后,方法立刻中断

先看 throw 的"杀伤力"——它和 return 一样会结束方法,但方式完全不同:

static void check(int score) {
    if (score < 0) {
        throw new IllegalArgumentException("分数不能为负");
    }
    System.out.println("检查通过");   // score < 0 时执行不到
}

由此还有一条硬规矩:throw 之后不能再写"可达"的代码——

static void check(int score) {
    throw new IllegalArgumentException("分数不能为负");
    System.out.println("这行永远跑不到");   // 编译报错:无法访问的语句
}

编译器直接报错 unreachable statement——和 return 后面不能写代码同理(1.x~2.x 老规矩的翻版)。想甩完还做事?把事写在 catch 那一层去。

2. throws 甩出去,谁调用谁负责

方法签名上 throws 之后,本方法对这种异常免责——但它没消失,只是换人接手。调用者拿到"可能被甩"的预告,二选一:

static void readFile() throws FileNotFoundException {
    // 自己不写预案——编译器看签名上有声明,放行
}

// 调用者 A:接住
try {
    readFile();
} catch (FileNotFoundException e) { ... }

// 调用者 B:不想接,继续甩
static void main2() throws FileNotFoundException {
    readFile();
}

可以一次声明多个:throws FileNotFoundException, IOException(逗号隔开)。

3. 甩锅链条:一层层往上,直到有人负责

把第 2 节的调用者 B 再被调用、再甩……异常就形成一条链条:

divide()  throw 甩出
   ↓ 甩
calc()    throws 不接,继续甩
   ↓ 甩
main()    二选一:接住(结局 A)/也甩给 JVM(结局 B)
   ↓ 结局 B
JVM       打印事故报告 → 程序中断(5.1 的老路)

完整代码看一遍(结局 A):

public class Demo05 {
    public static void main(String[] args) {
        try {
            int r = calc(10, 0);
            System.out.println("结果:" + r);
        } catch (ArithmeticException e) {
            System.out.println("main 接住:" + e.getMessage());
        }
        System.out.println("程序继续");
    }

    static int calc(int a, int b) {
        return divide(a, b);              // 不接,异常穿透这层往上走
    }

    static int divide(int a, int b) {
        if (b == 0) {
            throw new ArithmeticException("除数不能为零");  // 甩出
        }
        return a / b;
    }
}

注意一个细节:calc 什么都没写却"参与"了链条——异常会自动穿透没有 try-catch 的每一层,一路找下一个调用者。而 divide 抛的是运行时异常,连 throws 都可以不写(下一节说为什么)。输出:

main 接住:除数不能为零
程序继续

如果把 main 里的 try-catch 删掉、改成 main(String[] args) throws ArithmeticException——异常甩到 main 都没人接,就交给 JVM:打印事故报告,程序在炸点中断(结局 B,5.1 见过全套)。

4. 两派在"甩"上的待遇差异(5.2 收网)

5.2 埋的问题"为什么有的异常不声明也不报错",现在能完整回答了:

运行时异常(unchecked) 编译时异常(受检)
甩出(throw) 直接甩,不需要任何手续 必须先有手续
声明(throws) 可写可不写 必须 throws 或 try-catch,二选一
编译器态度 完全放手 死盯每个调用者
static void a() {
    throw new RuntimeException("运行时异常:甩就甩了");   // 不声明,编译通过
}

static void b() throws FileNotFoundException {            // 受检异常:必须声明
    throw new FileNotFoundException("受检异常:没声明不让过");
}

编译器的账本还是那本:运行时异常多半是你自己的 bug(改代码去,甩不甩随你);受检异常是外部风险(甩可以,但必须让每个经手人知情——知情了才好安排预案)。理解了 5.2 的"怪谁",这一节全是自然推论。

5. 重写方法的 throws 规矩(会认)

4.1 学过方法重写,现在补一条和异常有关的规矩:子类重写方法声明的异常,不能比父类的更大——只能甩同样的、更具体的(子类异常),或者干脆不甩。

原因用 4.4 的多态一想就通:调用者拿着父类引用写代码,预案是按父类签名准备的——catch (父类异常 e)。如果子类实现甩出一个更大的、父类没预告的异常,调用者的预案就接不住了。"编译看左"管的不只是能调什么方法,还包括会甩什么异常。

class Parent {
    void work() throws IOException { }
}
class Child extends Parent {
    @Override
    void work() throws FileNotFoundException { }   // ✅ 更小,可以
    // void work() throws Exception { }            // ❌ 更大,编译报错
}

现在会认即可,学到 IO 那章用得着。

6. 动手:亲手搭一条甩锅链

在 IDEA05 新建 Demo05(照第 3 节抄),完成三个实验:

三个 30 秒实验

  1. 链条穿透:在 calc 里加一行打印,运行确认 calc 的打印在异常发生前执行了——异常是从炸点开始向上找人的,穿越途中代码正常跑过。
  2. throw 后写代码:在 divide 的 throw 后面加一行打印——看 unreachable statement 编译报错。
  3. 结局 B 全套:把 main 的 try-catch 删掉、签名加 throws——亲眼看异常甩给 JVM 后的红字报告,和 5.1 那次一模一样(这次你知道每一行都是谁干的了)。

7. 常见误区

⚠️ 误区 1:throw 甩出去就完事了

甩出只是转手,不是解决。链条上必须有人 try-catch 接住写预案,否则最终甩给 JVM——程序中断。"甩了"和"处理了"是两码事。

⚠️ 误区 2:方法签名加了 throws 就安全了

throws 只是告知,不是处理——它把责任转嫁给调用者而已。全链路都 throws 等于没人负责,最后照样炸。

⚠️ 误区 3:运行时异常也必须 throws 声明

不必。unchecked 异常甩出、穿透、不上签名都不会编译报错——所以三大新手异常从没在教材代码的方法签名上出现过。声明与否取决于要不要提醒调用者。

⚠️ 误区 4:throw 中断方法和 return 结束方法是一回事

return 是正常收工(调用者拿结果继续);throw 是非正常中断(异常对象一路上溯找人)。两者都会结束当前方法,但后续流程天差地别。

⚠️ 误区 5:子类重写方法想甩什么异常都行

只能甩父类声明的异常或其子类,或干脆不甩。甩更大的异常编译报错——因为多态下调用者的预案是按父类签名准备的(4.4"编译看左"的延伸)。

⚠️ 误区 6:异常链甩得越远越好,显得层次分明

链条每穿一层,事故现场就离处理现场远一步,排查越难。好架构是该接的层就近接住(转化成更友好的提示或日志),而不是全体一致往上甩。

8. 自测题(先自己答,再展开)

❓ Q1:throw 执行后方法立刻发生什么?throw 后面还能写代码吗?

方法非正常中断(区别于 return 的正常收工),异常对象开始沿调用链上溯。throw 后面不能写可达代码——编译报错 unreachable statement,和 return 后不能写代码同理。

❓ Q2:异常在调用链上是怎么传播的?在 calc 里加 try-catch 会怎样?

异常从炸点出发,自动穿透没有 catch 的每一层,一层层找上一个调用者;谁先有类型匹配的 catch,谁就地接住,程序从接住处继续。calc 加了 catch 就地在 calc 层解决,main 无感。

❓ Q3:main 也把异常 throws 出去,最终会发生什么?

异常甩给 JVM:JVM 打印事故报告(异常类型、消息、堆栈跟踪),程序在炸点中断——正是 5.1 第一次见到的场景,现在能读懂其中每一步了。

❓ Q4:为什么运行时异常不用声明、受检异常必须声明?

编译器的账本:运行时异常是程序自己的 bug,逼你声明也没意义(该改代码);受检异常是外部风险,必须让每个经手人知情,知情了才能安排接住或继续转交——知情权是受检异常的核心。

❓ Q5(毕业题):设计一个 registerUser(int age)——age < 0 时 throw IllegalArgumentException("年龄不能为负");registerUser 内部不接;main 里调用它:a) 写出"接住"版本的 main;b) 写出"继续甩"版本的 main 并说明后果。
static void registerUser(int age) {
    if (age < 0) {
        throw new IllegalArgumentException("年龄不能为负");
    }
    System.out.println("注册成功:" + age);
}

// a) 接住版:
public static void main(String[] args) {
    try {
        registerUser(-5);
    } catch (IllegalArgumentException e) {
        System.out.println("注册被拦:" + e.getMessage());
    }
    System.out.println("流程继续");
}

// b) 继续甩版:
public static void main(String[] args) /* throws IllegalArgumentException */ {
    registerUser(-5);   // 异常甩给 JVM → 打印事故报告 → "流程继续"打不出来
}

合格标准:两个版本都能编译运行,输出符合预期。优秀标准:能说出 b 版"流程继续"消失的原因——异常甩到 main 仍无人接,程序在炸点中断(结局 B)。

9. 下一步学什么


相关笔记