Skip to content

第11章:异常处理 —— 当程序生病了

本章语言侧重:Java ⭐⭐⭐ | Python 和 C++ 的对比在末尾统一给出。

你将学会
try-catch·异常类型·finally·try-with-resources
前置知识
第7章 类与对象 + 第8章 继承
预计时间
40 分钟
需要准备
JDK + 编辑器
完成标志
写出能优雅处理错误的代码

一个故事开头

小王花了三天写了一个奶茶店点单系统,今天第一天上线。

第一位顾客走到柜台前:"你好,我要一杯陨石拿铁。"

小王自信满满地在键盘上输入 "陨石拿铁"——菜单里没有这个饮品。

程序崩溃了。

黑色的终端窗口吐出一串红色的字,像是在嘲笑他。顾客站在柜台前,盯着一脸尴尬的小王,场面一度非常尴尬。

小王盯着屏幕上那一大坨红色文字,心想:"为什么我的程序不能好好地说一声'没有这个商品',然后继续跑?"

这就是本章要解决的问题。


1. 你一定见过这个

在正式开始之前,我们先回忆一下你已经见过的场面。

编译错 vs 运行错

你的代码会遇到的错误分两种:

编译错(Compile-time Error):程序还没跑就被编译器拦下来了——像你还没出门就被妈妈拽回来说"鞋穿反了"。

java
System.out.println("Hello"  // ❌ 少写了右括号,编译不通过
// 编译器会温柔(其实并不温柔)地告诉你:missing ')'

运行错(Runtime Error):编译是过了,跑起来才炸——像你出了门走两步踩到香蕉皮一样。

java
int[] menu = new int[5];
System.out.println(menu[99]);  // 明明只有5个菜品,你非要第100号
// 编译通过 ✓ 跑起来就炸了 ✗

💥 拆了它:先炸几次过过瘾

创建一个 Crasher.java,一行一行地试:

java
public class Crasher {
    public static void main(String[] args) {
        // 场景1:数学老师的噩梦
        int result = 10 / 0;              // ← 猜猜会怎样?
        System.out.println("这行永远不会执行");
    }
}

运行它。你看到的红色文字就是 ArithmeticException——Java 告诉你:"除以0这种事,我做不到。"

现在把除以0那行注释掉,换成下面这几个,挨个跑一遍:

java
// 场景2:数错数组长度
int[] scores = {85, 90, 78};
System.out.println(scores[100]);

// 场景3:那东西根本不存在
String name = null;
System.out.println(name.length());

// 场景4:牛奶不能当电话号码
String phone = "奶茶";
int num = Integer.parseInt(phone);

你看到了什么? 每次 Java 都给了你一个大大的红色错误信息。它的格式长这样:

Exception in thread "main" java.lang.ArithmeticException: / by zero
    at Crasher.main(Crasher.java:4)

这个红色文字叫异常信息(Exception Message)。它告诉你三样东西:

  1. 什么异常——ArithmeticException(数学异常)
  2. 为什么——/ by zero(除以0造成的)
  3. 在哪——Crasher.main(Crasher.java:4)(在 Crasher.java 的第四行)

现在把 result = 10 / 0 改写成:

java
int result = 10 / 3;  // 不会报错,结果是 3 (整数除法)

10/3 可以但 10/0 不行?在计算机世界里,"除以0"没有定义——不是"接近多少"的问题,是真的没有答案。Java 很诚实:做不了就报错,不糊弄你。


2. 异常的比喻——程序感冒了

你见过上面那些"炸了"的场景了。现在正式介绍一下异常是什么。

想象一个人着凉了:

  • 他身体会发出信号——打喷嚏、发烧、流鼻涕
  • 这个信号告诉你:"我不舒服,需要处理"

程序也一样:

  • 当它遇到自己处理不了的情况(除以0、数组越界、空指针),它会抛出一个异常对象(throw an exception)
  • 这个异常对象就像是程序的"病历单",上面写着:
    • 什么病(异常类型——比如 ArithmeticException
    • 在哪感染了(代码的位置——比如 Crasher.java 第4行
    • 病因是什么(错误信息——比如 / by zero
java
// Java 内部大概是这样做的
if (denominator == 0) {
    throw new ArithmeticException("/ by zero");  // ← 抛出异常
}

异常家族谱

Java 有一张异常家族关系图,你没见过但肯定猜得到长什么样——因为 Java 里几乎所有东西都是类,异常也是一堆类。

Object
 └── Throwable                          ← 所有异常的祖宗("可以抛的东西")
      ├── Error                         ← 系统级别的严重问题(内存用完了)
      └── Exception                     ← 程序级别的错误(我们关心的)
           ├── RuntimeException         ← 运行时异常(写代码不小心造成的)
           │    ├── NullPointerException
           │    ├── ArrayIndexOutOfBoundsException
           │    ├── ArithmeticException
           │    └── NumberFormatException
           └── IOException 等           ← 编译时异常(外面的因素造成的)
                └── FileNotFoundException

你不需要背这个图。 但你要知道两件事:

  1. RuntimeException 及其子类——你写代码不小心造成的(比如数组越界、空指针)
  2. Exception其他子类——外部环境造成的(比如文件不存在、网络断了)

Java 对这两种区别对待,我们后面会讲。

💥 拆了它:看看最出名的空指针

空指针(NullPointerException)——所有 Java 程序员见得最多的异常,没有之一。

java
public class NullDemo {
    public static void main(String[] args) {
        String name = null;    // "null" 是 Java 里的"啥也没有"
        
        // 你现在试图对一个"啥也没有"的东西调用方法
        System.out.println(name.length());
    }
}

运行结果:

Exception in thread "main" java.lang.NullPointerException
    at NullDemo.main(NullDemo.java:5)

null 就是"不指向任何对象"。你把引用变量想成墙上的钥匙钩——null 就是钩子上空空如也。对着空钩子喊"拿钥匙开门",它也很无奈。

java
// 更形象的例子
public class NullFriend {
    static class Friend {
        String name;
        void sayHi() {
            System.out.println("你好,我是" + name);
        }
    }

    public static void main(String[] args) {
        Friend f = null;    // 你有一个"朋友"的名额,但还没交到朋友
        f.sayHi();          // ❌ 对一个不存在的人说"你好"
        // 结果:NullPointerException
    }
}

实际场景:

java
// 你在数据库里查用户,结果没查到
User user = findUserById(999);  // 返回 null
System.out.println(user.getName());  // ❌ NPE!

🧪 动手试试

写一个程序,故意触发以下三种异常并观察它们的报错信息:

  1. 一个 NullPointerException——任意方式
  2. 一个 ArrayIndexOutOfBoundsException
  3. 一个你之前没见到的异常(上网搜或自己猜一个)

每次看看报错信息告诉你什么。


3. try-catch:给程序穿上防弹衣

小王看着屏幕上红色的报错,心想:"有没有办法让程序出错了也不崩溃,而是优雅地说一声'出错了'然后继续跑?"

有。这就是 try-catch

最简单的用法

java
public class SafeDivider {
    public static void main(String[] args) {
        try {
            // try 块:放"可能出问题"的代码
            int result = 10 / 0;
            System.out.println("结果:" + result);  // 这行不会执行
        } catch (Exception e) {
            // catch 块:出问题后执行的代码
            System.out.println("哎呀,算错了!");
        }
        
        System.out.println("程序还在正常运行!");
    }
}

运行结果:

哎呀,算错了!
程序还在正常运行!

发生了什么?

  1. Java 执行 try 块里的代码
  2. 遇到 10 / 0,发现有问题
  3. 不再执行 try 块里剩余的行System.out.println("结果..." 没跑)
  4. 跳到 catch 块,执行里面的代码
  5. 执行完 catch,继续往下走

注意:try-catch 不是让错误消失,是让程序从崩溃中挺过来,继续往下走。

抓具体的异常

上面写的 catch (Exception e) 就像"不管什么病都挂急诊"——能解决问题但不够精确。更好的做法是抓具体的异常类型:

java
public class SpecificCatch {
    public static void main(String[] args) {
        try {
            int[] scores = {85, 90, 78};
            System.out.println(scores[5]);  // 越界了
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("数错了!数组只有 " + scores.length + " 个元素");
        }
    }
}

为什么指定具体类型?假设你的 try 块里既有数组越界又有除以0,catch (Exception e) 会把两种错误混一起处理——你分不清是哪出了事。

java
public class TooBroadCatch {
    public static void main(String[] args) {
        int[] scores = {85, 90};
        
        try {
            // 到底哪个出错了?
            scores[99] = 100;         // 数组越界
            int x = 100 / 0;          // 除以0
        } catch (Exception e) {
            // ❌ 不管哪个错,输出的都一样
            System.out.println("出了点问题:" + e.getMessage());
        }
    }
}

💥 拆了它:看看 e 到底是什么

在你的 catch 块里,把 e 打印出来,看看 Java 给了你什么:

java
try {
    int result = 10 / 0;
} catch (Exception e) {
    System.out.println(e);           // 打印异常对象
    System.out.println(e.getMessage());  // 只打原因
    e.printStackTrace();             // 打印完整堆栈——最常用!
}

运行结果:

java.lang.ArithmeticException: / by zero
/ by zero
java.lang.ArithmeticException: / by zero
    at Main.main(Main.java:4)

e.printStackTrace() 会打出错误从最里层到最外层的完整调用链——调试时靠它救命。

🧪 动手试试

写一个程序,让它在同一个 try 块里可能触发两种不同的异常(比如数组越界和空指针),用两个 catch 分别捕获并打印不同的提示信息。


4. 多级 catch:区分不同的"病"

语法:多个 catch 一块走

java
public class MultiCatch {
    public static void main(String[] args) {
        try {
            String input = args[0];   // 可能 ArrayIndexOutOfBounds(没传参数)
            int num = Integer.parseInt(input);  // 可能 NumberFormatException
            int result = 100 / num;   // 可能 ArithmeticException(传了 0)
            System.out.println("结果是:" + result);
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("你没传参数!运行方式:java MultiCatch <数字>");
        } catch (NumberFormatException e) {
            System.out.println("传的不是数字!请输入整数");
        } catch (ArithmeticException e) {
            System.out.println("不能除以0!请输入非零数字");
        }
    }
}

试试以下三种运行方式:

bash
java MultiCatch              # → 你没传参数!
java MultiCatch 奶茶          # → 传的不是数字!请输入整数
java MultiCatch 0             # → 不能除以0!请输入非零数字

Java 从上到下找第一个能匹配的 catch——像医院分诊,你说"发烧",她就指你去发热门诊,不会让你在急诊排半天。

⚠️ 顺序很重要:子类在前,父类在后

java
// ❌ 错误顺序——父类会"拦截"子类
try {
    String input = null;
    System.out.println(input.length());
} catch (Exception e) {               // Exception 是 RuntimeException 的父类
    System.out.println("万能catch");
} catch (NullPointerException e) {   // ❌ 编译错误:永远到不了这里
    System.out.println("空指针!");
}

原因:catch (Exception e) 已经覆盖了所有异常,后面的 catch 永远到不了。Java 编译器发现这个逻辑问题,直接拒绝编译。

java
// ✅ 正确顺序——子类在前
try {
    String input = null;
    System.out.println(input.length());
} catch (NullPointerException e) {
    System.out.println("空指针!细粒度处理");
} catch (Exception e) {
    System.out.println("其他异常:兜底处理");
}

💥 拆了它:试试反过来会怎样

把上面"错误顺序"的代码编译一下,看看 Java 编译器报的编译错误是什么。记住:Java 会在你跑之前就发现这种逻辑问题,不让你编译通过。

Java 7+ 的简化写法:catch 管道

java
// 如果你想对多种异常做同样的处理
try {
    String input = args[0];
    int num = Integer.parseInt(input);
} catch (ArrayIndexOutOfBoundsException | NumberFormatException e) {
    // 用 | 分隔多个异常类型
    System.out.println("参数有问题:要么没传,要么不是数字");
}

省事,不用给两种异常各写一遍同样的 catch。


5. finally:不管怎样都要做的事

为什么需要 finally?

想象一下:你去奶茶店,店员开始做你的奶茶了——突然发现牛奶用完了。店员告诉你"做不了",然后呢?

你觉得店员会不会把刚才已经拿出来的杯子、配料随便扔在台面上就回家了?不会。她应该把台面收拾干净。

程序也一样。有些操作不管出没出错都必须做完——比如关闭文件、释放连接、解锁资源。

java
public class FinallyDemo {
    public static void main(String[] args) {
        try {
            System.out.println("1. 开始做奶茶");
            int result = 10 / 0;     // 炸了
            System.out.println("2. 奶茶做好了(这条不会执行)");
        } catch (Exception e) {
            System.out.println("3. 出错了!牛奶用完了");
        } finally {
            // finally 块:不管 try 块有没有异常、catch 有没有执行——它都会执行
            System.out.println("4. 💪 收拾台面!关店门!");
        }
        
        System.out.println("5. 程序结束");
    }
}

运行结果:

1. 开始做奶茶
3. 出错了!牛奶用完了
4. 💪 收拾台面!关店门!
5. 程序结束

💥 拆了它:如果 try 里没有异常呢?

把上面的 int result = 10 / 0 改成 int result = 10 / 2,看看 finally 还会不会执行。

1. 开始做奶茶
2. 奶茶做好了
4. 💪 收拾台面!关店门!
5. 程序结束

结果:finally 仍然执行了。 这就是 finally 的特点——无论 try 正常结束还是异常结束,它都会跑。

再拆一次:如果 catch 里又出错了呢?

java
public class FinallyAlwaysRuns {
    public static void main(String[] args) {
        try {
            int result = 10 / 0;
        } catch (Exception e) {
            System.out.println("catch 块");
            int x = 10 / 0;  // catch 里又除以0!
        } finally {
            // ✅ finally 仍然会执行!神奇吧?
            System.out.println("finally 块——就算 catch 也炸了,我还是要执行");
        }
    }
}

结论:finally 是 Java 里最倔的东西——想尽办法也要跑完。

finally 的典型用途

java
import java.io.*;

public class FinallyFileDemo {
    public static void main(String[] args) {
        FileInputStream fis = null;
        try {
            fis = new FileInputStream("menu.txt");  // ← 可能 FileNotFoundException
            // ... 读取文件内容
        } catch (FileNotFoundException e) {
            System.out.println("菜单文件不存在");
        } finally {
            // 不管文件读没读到,都要释放资源
            if (fis != null) {
                try {
                    fis.close();   // close() 本身也可能抛异常...
                } catch (IOException e) {
                    System.out.println("关闭文件时出了问题");
                }
            }
        }
    }
}

注意上面的 finally 块里面还套了一层 try-catch——因为 fis.close() 自己也可能抛异常。这就是 Java 7 之前的"经典"写法,又臭又长。后面我们来看怎么改善。


6. throw:主动说"我做不了"

什么时候需要手动扔异常?

上面所有的例子都是 Java 自动帮你 throw 的——10 / 0 自动抛 ArithmeticException,越界自动抛 ArrayIndexOutOfBoundsException

但有时候你需要自己决定什么情况是"异常"

java
public class MilkTeaShop {
    public static void main(String[] args) {
        order("芒果波波", -5);  // -5 杯?你认真的?
    }
    
    static void order(String drink, int quantity) {
        if (quantity <= 0) {
            // 你自己决定:数量<=0 是异常情况
            throw new IllegalArgumentException("数量必须大于0,但你传了 " + quantity);
        }
        System.out.println("订单已接收:" + drink + " × " + quantity);
    }
}

运行结果:

Exception in thread "main" java.lang.IllegalArgumentException: 数量必须大于0,但你传了 -5
    at MilkTeaShop.order(MilkTeaShop.java:9)
    at MilkTeaShop.main(MilkTeaShop.java:4)

throw 的作用:你说"这里有问题,我不处理了,让上层去管"。

实际场景:检查参数

java
public class BankAccount {
    private double balance;  // 余额
    
    void withdraw(double amount) {
        if (amount <= 0) {
            // ❌ 取款金额不能是负数或零
            throw new IllegalArgumentException("取款金额必须为正数");
        }
        if (amount > balance) {
            // ❌ 余额不足
            throw new RuntimeException("余额不足!当前余额:" + balance + ",想取:" + amount);
        }
        
        balance -= amount;
        System.out.println("取款成功,剩余余额:" + balance);
    }
    
    public static void main(String[] args) {
        BankAccount account = new BankAccount();
        account.balance = 100;
        
        account.withdraw(200);  // 只有100,取200?
        // → Exception: 余额不足!
        
        System.out.println("这条还会执行吗?");  // ❌ 不会,程序已经炸了
    }
}

💥 拆了它:自己加的 throw 用 try-catch 接住

java
public class CatchOwnThrow {
    static void checkAge(int age) {
        if (age < 0) {
            throw new IllegalArgumentException("年龄不能是负数吧老铁");
        }
        System.out.println("年龄验证通过:" + age);
    }
    
    public static void main(String[] args) {
        try {
            checkAge(-5);  // 你主动扔的异常
        } catch (IllegalArgumentException e) {
            System.out.println("抓到了:" + e.getMessage());
        }
        
        System.out.println("程序还在跑!");
    }
}

运行结果:

抓到了:年龄不能是负数吧老铁
程序还在跑!

关键:throw 扔出来的异常和 Java 自动扔的没有本质区别——可以用 try-catch 接住,也可以让它一路炸到 JVM 那层(然后程序崩溃)。


7. throws:自己不处理,甩锅给上层

throws 是啥

throw 是你亲手扔异常。throws 是你声明"我这个方法可能会出这个错,调用我的人你看着办"。

java
public class ThrowsDemo {
    // 这个方法声明了:我可能会抛异常,不管了,交给你处理
    static void readFile(String path) throws FileNotFoundException {
        // FileNotFoundException 是"检查型异常"——必须声明或处理
        FileInputStream fis = new FileInputStream(path);
        // 如果文件不存在,new FileInputStream 会抛 FileNotFoundException
    }
    
    public static void main(String[] args) {
        // ❌ 编译错误!因为 readFile 声明了 throws
        // 调用者必须处理这个异常
        readFile("not_exists.txt");
    }
}

两种类型的异常

还记得之前的异常家族树吗?

Exception
 ├── RuntimeException 及其子类  ← 非检查异常(Unchecked)
 └── 其他 Exception            ← 检查异常(Checked)

Java 对它们的区别对待非常直接:

异常类型必须处理吗?例子
RuntimeException(运行时异常)不用强制处理空指针、数组越界、除以0
Checked Exception(检查型异常)必须处理——不处理编译不通过文件找不到、网络超时

为什么要有这种区别?Java 设计者的想法是:

  • NullPointerException 是你写代码不小心造成的——编译器假设你会好好写代码,不用强制检查
  • FileNotFoundException 是运行时环境决定的——文件可不存在,这是你无法控制的——编译器强制你处理这种情况
java
// ✅ 非检查异常(RuntimeException)——不需要 throws,也不需要 catch
void riskyMethod() {
    int x = 10 / 0;  // ArithmeticException 是 RuntimeException
    // 编译器不会强制你处理
}

// ❌ 检查型异常——编译器强制你处理
void readMenu() throws FileNotFoundException {
    // ...
}
// 或者
void readMenu() {
    try {
        // ...
    } catch (FileNotFoundException e) {
        // ...
    }
}

现实中的感受

很多 Java 开发者觉得检查型异常烦人——特别是就想快速写个小程序的时候。但它也有好处:你刚接手一个新项目,看到方法签名上写着 throws IOException,马上就知道这方法可能要读写文件,心里有个底。

💥 拆了它:看看检查异常编译不通过的样子

java
import java.io.*;

public class CheckedDemo {
    public static void main(String[] args) {
        // 直接调用可能抛检查异常的方法,但不处理
        Thread.sleep(1000);  // ← 编译错误!InterruptedException 是检查型异常
    }
}

试一下,看看编译器说什么。然后改成:

java
public static void main(String[] args) throws InterruptedException {
    Thread.sleep(1000);  // ✅ 改成 throws 声明,把责任甩给调用者
}

或者:

java
public static void main(String[] args) {
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        System.out.println("被中断了:" + e.getMessage());
    }
}

拿个说法帮你记:检查型异常就是"签了字的免责声明"——方法签名上白纸黑字说了"我可能出这问题",调用者没法装没看见。


8. 自定义异常:给错误起个有意义的名字

为什么要自定义异常?

java
// 在你的奶茶店点单系统里
throw new RuntimeException("原料不足");
throw new RuntimeException("余额不够");
throw new RuntimeException("商品不存在");

三个不同的错误,全都叫 RuntimeException。你的 catch 块区分不了它们——除非去检查那个字符串:

java
} catch (RuntimeException e) {
    if (e.getMessage().equals("原料不足")) {
        // ...
    } else if (e.getMessage().equals("余额不够")) {
        // ...
    }
    // 这写法太蠢了
}

更好的方式:给你的每种错误起一个有名字的异常类。

怎么定义

超简单——就是写一个类,继承 Exception(或 RuntimeException):

java
// 继承 RuntimeException = 非检查异常
class InsufficientStockException extends RuntimeException {
    public InsufficientStockException(String itemName, int requested, int available) {
        super("库存不足:" + itemName + " 需求" + requested + ",库存仅" + available);
    }
}

class InsufficientBalanceException extends RuntimeException {
    public InsufficientBalanceException(double balance, double required) {
        super("余额不足:当前 " + balance + " 元,需要 " + required + " 元");
    }
}

class ProductNotFoundException extends RuntimeException {
    public ProductNotFoundException(String productName) {
        super("商品不存在:" + productName);
    }
}

在项目中使用

java
public class MilkTeaOrderSystem {
    static class Product {
        String name;
        double price;
        int stock;
        
        Product(String name, double price, int stock) {
            this.name = name;
            this.price = price;
            this.stock = stock;
        }
    }
    
    static class Customer {
        String name;
        double balance;
        
        Customer(String name, double balance) {
            this.name = name;
            this.balance = balance;
        }
    }
    
    static void order(Customer customer, Product product, int quantity) {
        if (product.stock < quantity) {
            throw new InsufficientStockException(product.name, quantity, product.stock);
        }
        double total = product.price * quantity;
        if (customer.balance < total) {
            throw new InsufficientBalanceException(customer.balance, total);
        }
        
        customer.balance -= total;
        product.stock -= quantity;
        System.out.println("✅ 下单成功:" + product.name + " × " + quantity);
        System.out.println("   花费 " + total + " 元,剩余余额 " + customer.balance);
    }
    
    public static void main(String[] args) {
        Product 波波奶茶 = new Product("波波奶茶", 15.0, 10);
        Customer 小明 = new Customer("小明", 30.0);
        
        try {
            order(小明, 波波奶茶, 1);  // ✅ 正常
            order(小明, 波波奶茶, 100); // ❌ 库存不足
        } catch (InsufficientStockException e) {
            System.out.println("库存问题:" + e.getMessage());
        } catch (InsufficientBalanceException e) {
            System.out.println("余额问题:" + e.getMessage());
        }
        
        System.out.println("\n程序正常结束!");
    }
}

💥 拆了它:把自定义异常换父类

把上面两个自定义异常改成继承 Exception(检查型异常)而不是 RuntimeException——看看会发生什么。编译要怎么改?

发现没有?如果继承 Exception(检查型),order 方法签名得加 throws,调用处也得加 try-catch。这就是"检查 vs 非检查"在实战中的区别。

🧪 动手试试

为你的奶茶店系统再加一个自定义异常:PaymentTimeoutException——当支付过程超过30秒时抛出。想一想这个异常应该继承 RuntimeException 还是 Exception


9. try-with-resources:自动关门的帮手

回忆刚才的脏活

还记得我们关闭文件时要写多少代码吗?

java
// Java 7 之前的"经典"写法
FileInputStream fis = null;
try {
    fis = new FileInputStream("file.txt");
    // 读文件...
} catch (IOException e) {
    System.out.println("出错了");
} finally {
    if (fis != null) {
        try {
            fis.close();   // close 本身也可能抛异常!
        } catch (IOException e) {
            // 有什么办法呢,再抓一次呗
        }
    }
}

这段代码里,真正做事的逻辑只有两三行,剩下七八行全是在处理"收尾"。又丑又容易写错(很多人忘了在 finally 里 close)。

Java 7 的救星

java
import java.io.*;

public class TryWithResourcesDemo {
    public static void main(String[] args) {
        // try 后面的括号里放"资源"——资源必须实现 AutoCloseable 接口
        try (FileInputStream fis = new FileInputStream("menu.txt")) {
            // 读文件...
            System.out.println("文件打开成功");
        } catch (FileNotFoundException e) {
            System.out.println("菜单文件不存在");
        } catch (IOException e) {
            System.out.println("读取文件出错");
        }
        // 不需要 finally 去 close——try-with-resources 会自动关闭
    }
}

try 块结束(不管是正常结束还是异常结束),Java 自动调用 fis.close()。不用写 finally,不用自己 catch close 的异常。

同一个道理适用于其他资源

java
import java.sql.*;

public class ResourceDemo {
    public static void main(String[] args) {
        // 多个资源用分号分隔
        try (Connection conn = DriverManager.getConnection("jdbc:...");
             Statement stmt = conn.createStatement()) {
             
            ResultSet rs = stmt.executeQuery("SELECT * FROM menu");
            // ...
        } catch (SQLException e) {
            System.out.println("数据库出错:" + e.getMessage());
        }
        // conn 和 stmt 都会自动关闭——而且按创建顺序的反向关闭
    }
}

💥 拆了它:验证资源确实被关闭了

写一个自己的类,实现 AutoCloseable 接口,在 close() 方法里打印一句"我被关了":

java
class MyResource implements AutoCloseable {
    String name;
    MyResource(String name) {
        this.name = name;
    }
    public void close() {
        System.out.println(name + " 被关闭了");
    }
}

public class TestAutoClose {
    public static void main(String[] args) {
        try (MyResource r1 = new MyResource("资源1");
             MyResource r2 = new MyResource("资源2")) {
            System.out.println("正在使用资源...");
        }
        // 猜猜输出顺序?
    }
}

10. 易错点与最佳实践

❌ 错误1:吞掉异常

java
try {
    int result = 10 / 0;
} catch (Exception e) {
    // catch 块是空的!——异常被"吃掉了"
    // 程序不会崩溃,但你也完全不知道出了什么事
    // 这叫"吞异常"——最糟糕的写法之一
}

问题在于你压住了错误信息,但错误本身还在。后续代码拿着错误的结果继续跑,跑出更离奇的 bug——而且几乎没法调试,因为原始信息被你吃了。

正确的做法:至少打一行日志

java
try {
    int result = 10 / 0;
} catch (Exception e) {
    System.err.println("除法出错:" + e.getMessage());
    // 或者:e.printStackTrace();
    // 或者用日志框架
}

❌ 错误2:用异常控制程序流程

java
// ❌ 非常不推荐!
try {
    int num = Integer.parseInt(input);
    // 如果解析成功,说明是数字
} catch (NumberFormatException e) {
    // 如果解析失败,说明不是数字
    System.out.println("这不是一个数字");
}

不推荐是因为异常机制比普通 if-else 代价大得多——要构造异常对象、填充堆栈。而且代码可读性也差。

更好的做法

java
// 先检查,再做
if (input.matches("-?\\d+")) {    // 提前检查是不是数字
    int num = Integer.parseInt(input);
} else {
    System.out.println("这不是一个数字");
}

用异常做控制流,好比为了喊人出来直接拿锤子砸墙——功能上可行,但不该这么干。

❌ 错误3:在 finally 里 return

java
public static int test() {
    try {
        return 1;  // 准备返回1
    } finally {
        return 2;  // ❌ finally 里的 return 覆盖了 try 里的 return
    }
}
// 调用 test() 返回的是 2,不是 1!

finally 里别写 return——它会让 try 里的 return 原地失效。

✅ 最佳实践汇总

该做的不该做的
抓具体的异常类型只抓 ExceptionThrowable
catch 块里记录错误信息空的 catch 块
自定义异常时用有意义的类名和消息到处 throw new RuntimeException("xxx")
关闭资源用 try-with-resources手动在 finally 里 close
异常仅用于真正的异常情况用异常做正常的流程控制
保持 catch 简洁——只做必要的错误处理catch 里写几百行"处理逻辑"

11. 三语言对照

异常机制对比

特性JavaPythonC++
语法try-catch-finallytry-except-finallytry-catch
检查型异常✅ 有❌ 没有(全是非检查)❌ 没有
多异常捕获多个 catch 或 `\` 管道except (Type1, Type2)
自定义异常继承 Exception/RuntimeException继承 Exception继承 std::exception
资源自动管理try-with-resourceswith 语句RAII(析构函数)
finally✅ 有✅ 有❌ 没有(用 RAII 替代)

Python 示例

python
# Python 的异常处理
class InsufficientStockError(Exception):
    pass

class MilkTeaShop:
    stock = {"波波奶茶": 10}
    
    def order(self, name, quantity):
        if name not in self.stock:
            raise ValueError(f"商品不存在:{name}")
        if self.stock[name] < quantity:
            raise InsufficientStockError(f"库存不足")
        self.stock[name] -= quantity
        return f"下单成功:{name} × {quantity}"

try:
    shop = MilkTeaShop()
    result = shop.order("波波奶茶", 100)
except InsufficientStockError as e:
    print(f"库存问题:{e}")
except ValueError as e:
    print(f"参数问题:{e}")

C++ 示例

cpp
#include <iostream>
#include <stdexcept>
#include <string>
using namespace std;

// 自定义异常
class InsufficientStockError : public runtime_error {
public:
    InsufficientStockError(const string& msg) : runtime_error(msg) {}
};

class MilkTeaShop {
private:
    int stock = 10;
public:
    void order(int quantity) {
        if (stock < quantity) {
            throw InsufficientStockError("库存不足——需求" + 
                to_string(quantity) + ",库存" + to_string(stock));
        }
        stock -= quantity;
        cout << "下单成功 × " + to_string(quantity) << endl;
    }
};

int main() {
    MilkTeaShop shop;
    try {
        shop.order(100);
    } catch (const InsufficientStockError& e) {
        cout << "库存问题:" << e.what() << endl;
    } catch (const exception& e) {
        cout << "其他错误:" << e.what() << endl;
    }
    return 0;
}

(C++ 里没有 finally——因为 RAII(资源获取即初始化)通过对象的析构函数自动释放资源,设计上比 finally 更利落)


本章小结

从奶茶店老板的崩溃经历出发,我们走过了异常处理的整条路:

  1. 异常是什么 —— 程序感冒发烧时抛出的"病历单"
  2. try-catch —— 给程序穿上防弹衣,不让它崩溃
  3. 多级 catch —— 不同的病,不同的处理方法
  4. finally —— 不管怎样都要收拾台面
  5. throw —— 主动说"我干不了"
  6. throws —— 甩锅给上层
  7. 检查型 vs 非检查 —— 强制你考虑的 vs 信任你自己的
  8. 自定义异常 —— 给你的错误起个名字
  9. try-with-resources —— Java 自动帮你关门

下一章:泛型——把"类型"也变成参数,让你的代码更通用、更安全。


✅ 验收标准

完成本章后,你应该能:

  • [ ] 用 try-catch 捕获并处理异常
  • [ ] 区分 checked 和 unchecked 异常
  • [ ] 用 finally 做清理操作
  • [ ] 用 try-with-resources 自动关闭资源
  • [ ] 用 throws 声明方法可能抛出的异常

📌 常见卡点

  • 异常类型不匹配——捕获的是 Exception 但抛的是更具体的子类
  • finally 里的 return 会覆盖 try 里的 return——别在 finally 里 return
  • 吞掉了异常但没做任何处理——至少打日志
  • try-with-resources 的资源类必须实现 AutoCloseable

🔜 现在不需要理解

  • 自定义异常类的设计规范
  • 异常的性能开销——只在真正异常时抛
  • 断言——不是异常处理
  • 全局异常处理器——框架层面的事

🧪 练习

  1. 基础:写一个除法计算器,用户从键盘输入两个数字,程序输出结果。如果用户输入的不是数字或除以0,捕获异常并提示重新输入。

  2. 进阶:用自定义异常改造第7章或第8章的学生成绩管理系统——当设置的成绩不在0-100范围时抛 InvalidScoreException,当学生列表已满时抛 ClassFullException

  3. 挑战:实现一个简单的银行 ATM 模拟器。定义 Account 类(余额、账户名),支持 deposit()withdraw() 方法。自定义以下异常:InsufficientBalanceException(余额不足)、InvalidAmountException(金额≤0)、AccountNotFoundException(账号不存在)。然后写几个场景测试你的异常处理。


下一篇

第12章 泛型

用 ❤️ 构建 | Software Systems Atlas