Day16 | Java异常机制详解

35 阅读14分钟

Java异常机制是其健壮性的核心。

提供了一种标准化的方式来处理程序运行时发生的错误或非预期情况。

允许我们捕获这些问题并合理的进行处理,而不是让程序意外挂了。

这个机制通过 try、catch、finally、throw 和 throws 等关键字实现。

确保错误处理逻辑与主要业务逻辑分离,从而提高代码的可读性和可维护性。

本文,我们一起来了解一下Java异常机制。

一、什么是异常

想象一下我们的日常生活:你正在走路,突然脚下一滑,崴了脚——这就是一个异常情况,打断了你正常的行走。

或者,你正在做饭,结果火开大了,锅烧糊了——打断了你做饭计划的异常。

在程序世界,异常就是程序在运行期间发生的一些非预期的、不正常的事件,它会中断程序正常的指令流。

出现这些异常的情况有很多种,我举几个例子大致说下:

程序试图干一些不可能的事情:

你手里没有遥控器(对象引用null),但是又想用它操作电视(调对象方法),这种情况Java虚拟机会抛出一个NullPointerException。

外部环境发生了不可控的变化

你写的程序想读取一个重要的配置文件,结果用户不小心把这个文件删掉了。当你尝试打开它时,FileNotFoundException就出现了。

用户输入了意料之外的数据

你以为用户看到"年龄"就会输入数字,结果用户输入了"abc",当你尝试把"abc"转换成数字的时候,NumberFormatException就出现了。

如果不处理这些异常,程序就撂挑子不干了,直接停止运行,并打印出一堆错误信息(堆栈跟踪信息,stack trace),用户看到了一脸懵。

我们作为开发者,就要学会识别它们、捕获它们、并以合理的方式处理它们。

二、try-catch-finally

try

我们会把那些我们预感可能会抛出异常的代码段(比如操作文件、进行网络连接、做一些复杂的计算等)放进try{...}的大括号里。

这相当于告诉Java虚拟机:“这里面可能会出问题”。

package com.lazy.snail.day16;

/**
 * @ClassName TryDemo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/5/30 14:21
 * @Version 1.0
 */
public class TryDemo {
    public static void main(String[] args) {
        try {
            int[] numbers = {1, 2, 3};
            System.out.println(numbers[0]);
            System.out.println(numbers[10]);
            System.out.println("这段代码不会被执行到,因为上面出错了");
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("数组下标越界了!");
        }
        System.out.println("程序继续执行...");
    }
}

在上面的例子中,访问numbers[10]显然会越界,因为数组numbers只有3个元素(下标0, 1, 2)。

我们把可能出问题的代码用try{...}包裹起来。

catch

如果try块里的代码真的出问题了(抛出了一个异常),那么catch块就该登场了。

它专门负责捕获并处理特定类型的异常。

一个try块后面可以跟一个或多个catch块。每个catch块都指定了它能处理的异常类型。

package com.lazy.snail.day16;

/**
 * @ClassName CatchDemo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/5/30 14:27
 * @Version 1.0
 */
public class CatchDemo {
    public static void main(String[] args) {
        try {
            String text = null;
            //System.out.println(text.length());

            int result = 10 / 0;
            System.out.println("结果:" + result);

        } catch (NullPointerException e) {
            System.err.println("捕获到空指针异常啦!错误信息:" + e.getMessage());
        } catch (ArithmeticException e) {
            System.err.println("捕获到算术异常啦!错误信息:" + e.getMessage());
        } catch (Exception e) {
            System.err.println("捕获到一个未知类型的异常:" + e.getMessage());
        }
        System.out.println("程序处理完异常,继续执行...");
    }
}

如果try块中发生异常,Java虚拟机会从上到下依次检查每个catch块,看哪个catch块声明的异常类型和实际抛出的异常类型匹配或者是其父类型。

一旦找到匹配了,就会执行该catch块里面的代码,其他的catch块就会被忽略。

这里有一个重要的规则:

如果多个catch块捕获的异常类型之间有继承关系(比如IOException是Exception的子类)。

那么子类异常的catch块必须写在父类异常的catch块之前。

否则,父类异常的catch块会先把子类异常截胡了,导致子类异常的catch块永远没有机会执行,编译器会报错。

finally

不管try块里面的代码发不发生异常,不管发生的异常有没有被catch块成功捕获。

finally块里面的代码几乎都要被执行。

例外的情况就是:要么在try或者catch里面执行了System.exit(),强制退出了JVM,要么JVM直接崩溃了,finally根本没机会执行。

finally最常见的用途就是清理资源。

比如,你在try块里打开了一个文件流、一个数据库连接或一个网络连接,这些都是宝贵的系统资源。

不管后续操作是否成功,都应该确保这些资源在使用完后被正确关闭,以避免资源泄漏。

package com.lazy.snail.day16;

import java.io.FileInputStream;
import java.io.IOException;

/**
 * @ClassName FinallyDemo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/5/30 14:41
 * @Version 1.0
 */
public class FinallyDemo {
    public static void main(String[] args) {
        FileInputStream fis = null;
        try {
            fis = new FileInputStream("xxx.txt");
            int data = fis.read();
        } catch (IOException e) {
            System.err.println("文件操作出错了:" + e.getMessage());
        } finally {
            if (fis != null) {
                try {
                    fis.close();
                } catch (IOException e) {
                    System.err.println("关闭文件流出错了:" + e.getMessage());
                }
            }
        }
    }
}

在上面的例子里,即使文件xxx.txt不存在导致FileInputStream构造失败或read()失败。

finally块中的关闭逻辑也会尝试执行,确保资源得到释放。

三、异常家族

在Java里,所有的错误和异常都有一个共同的父类,它就是java.lang.Throwable类。

家族虽然庞大,但主要可以分为两大分支:Error(错误)和Exception(异常)。

Error

Error类及其子类通常表示Java虚拟机运行时发生的严重问题,这些问题通常是灾难性的,应用程序自身基本没法解决。

图中列举的两个Error(OutOfMemoryError、StackOverflowError),是比较常见的Error类型。

OutOfMemoryError:JVM内存不足,没办法再给新对象分配空间,并且垃圾回收器也无法释放更多内存的时候就会抛出这个Error。

StackOverflowError:方法调用栈太深,超出了JVM分配给线程的栈空间时抛出。通常发生在无限递归调用的情况。

对于Error,我们的应用程序通常不应该(也没有能力)去捕获和处理它。

遇到这类错误,最好的办法是让程序尽快终止,然后检查程序逻辑或调整JVM配置(比如增加堆内存大小)。

Exception

Exception类和其子类表示的是应用程序层面可以预料和处理的问题。

这是我们程序员主要打交道和关注的范畴。Exception又可以细分成两大类:

1、Checked Exception(受检异常)

这些异常通常是由外部因素引起的,比如文件不存在 (FileNotFoundException,是IOException的子类)、网络连接中断 (SocketException,是IOException的子类)、SQL操作错误 (SQLException)等。

为什么叫受检异常?

因为Java编译器会严格检查你的代码。如果一个方法内部可能会抛出受检异常,那么这个方法必须做两件事之一:

  • 要么,使用try-catch块在方法内部捕获并处理这个异常。
  • 要么,在方法签名里使用throws关键字声明这个方法可能会抛出此异常,将处理责任传递给调用者。
package com.lazy.snail.day16;

import java.io.File;
import java.io.FileReader;
import java.io.IOException;

/**
 * @ClassName CheckedExceptionDemo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/5/30 15:30
 * @Version 1.0
 */
public class CheckedExceptionDemo {
    public void readFileWithTryCatch(String filePath) {
        File file = new File(filePath);
        FileReader fr = null;
        try {
            fr = new FileReader(file);
            System.out.println("文件内容:" + (char)fr.read());
        } catch (IOException e) {
            System.err.println("读取文件失败:" + e.getMessage());
        } finally {
            if (fr != null) {
                try {
                    fr.close();
                } catch (IOException e) {
                    System.err.println("关闭文件读取器失败:" + e.getMessage());
                }
            }
        }
    }

    public void readFileWithThrows(String filePath) throws IOException {
        File file = new File(filePath);
        FileReader fr = new FileReader(file);
        System.out.println("文件内容:" + (char)fr.read());
        if (fr != null) {
            fr.close();
        }
    }

    public static void main(String[] args) {
        CheckedExceptionDemo demo = new CheckedExceptionDemo();
        demo.readFileWithTryCatch("xxx.txt");
        try {
            demo.readFileWithThrows("xxx.txt");
        } catch (IOException e) {
            System.err.println("调用readFileWithThrows时出错:" + e.getMessage());
        }
    }
}

上面的例子中,readFileWithTryCatch方法使用try-catch-finally合理的处理了受检异常IOException,并关闭资源。

而readFileWithThrows方法并没有在方法体内捕获处理受检异常,直接使用了throws关键字把IOException抛出去了,意味着交由调用方处理(此例中是main方法对该异常进行了处理)。

2、RuntimeException (运行时异常 / 非受检异常)

RuntimeException及其子类表示的是程序在运行时可能发生的逻辑错误或API使用不当等问题。

它们通常是可以避免的。

为什么叫非受检呢?

因为Java编译器不会强制你必须显式处理(try-catch或throws)这些异常。

运行时异常通常表示代码里有bug。如果到处强制捕获这些异常,代码会变得非常臃肿。

更好的做法是修复这些bug,而不是仅仅捕获它们。

比如,对于NullPointerException,你应该确保对象在使用前被正确初始化,而不是简单地catch它。

四、throw和throws

前面我们谈到的异常,很多时候是Java虚拟机在执行代码时发现问题并自动抛出的。

但有时,我们会在自己的业务逻辑中发现某些情况是不符合预期的,这时我们也需要一种机制来主动发出警报——这就是throw关键字的作用。

而throws关键字,则与受检异常的处理方式相关,它更像是一种责任声明。

throw

throw关键字用于在代码里显式地抛出一个异常对象。

这个异常对象可以是Java内置的异常类型实例,也可以是我们自定义的异常类型实例。

那什么情况下使用throw呢?

当你的方法检测到一个错误条件,使得方法没办法正常完成它预期的功能时,就可以创建一个合适的异常对象并将其抛出。

package com.lazy.snail.day16;

/**
 * @ClassName BankAccount
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/5/30 15:51
 * @Version 1.0
 */
public class BankAccount {
    private double balance;

    public BankAccount(double initialBalance) {
        if (initialBalance < 0) {
            throw new IllegalArgumentException("初始余额不能是负数: " + initialBalance);
        }
        this.balance = initialBalance;
    }

    public void withdraw(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("取款金额必须是正数: " + amount);
        }
        if (amount > balance) {
            throw new RuntimeException("余额不足!当前余额:" + balance + ", 需要取款:" + amount);
        }
        balance -= amount;
        System.out.println("成功取出:" + amount + ", 余额:" + balance);
    }

    public static void main(String[] args) {
        try {
            BankAccount account1 = new BankAccount(100);
            account1.withdraw(50);
            // account1.withdraw(60); // 这会抛出余额不足的RuntimeException
            // BankAccount account2 = new BankAccount(-10); // 这会抛出IllegalArgumentException
            BankAccount account3 = new BankAccount(200);
            account3.withdraw(-10); // 这会抛出IllegalArgumentException
        } catch (IllegalArgumentException e) {
            System.err.println("参数错误:" + e.getMessage());
        } catch (RuntimeException e) {
            System.err.println("运行时发生错误:" + e.getMessage());
        }
    }
}

上面的例子里面,构造函数在初始余额为负时抛出IllegalArgumentException。withdraw方法在取款金额无效或余额不足时抛出异常。

throw抛出的是一个异常对象,所以必须先new一个异常类的实例。

throws

我们之前在第三章讨论受检异常时已经接触过throws关键字。

它用在方法签名上,用于声明方法可能会抛出的受检异常类型。

public void myMethod() throws IOException, SQLException { ... }

有点像"我搞不定(我没搞),告诉我老大"一样的"免责声明"或"责任转移"。

myMethod告诉他的调用者,我内部可能出现IOException,SQLException。

我自己不打算用try-catch处理他们,你调了,要么你处理,要么你也通过throws告诉你的调用者。

为什么只针对受检异常呢?

因为运行时异常(RuntimeException及其子类)和错误(Error)默认就是可以沿着调用栈向上传播的,不需要(也不应该)在方法签名中用throws显式声明。

如果你在throws中声明了RuntimeException,编译器不会报错,但不推荐你这么干,因为这么干除了堆点*山,没有任何用处。

package com.lazy.snail.day16;

import java.io.FileWriter;
import java.io.IOException;

/**
 * @ClassName ThrowsDemo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/5/30 16:09
 * @Version 1.0
 */
public class ThrowsDemo {
    public void writeFile(String filePath, String content) throws IOException {
        FileWriter writer = null;
        try {
            writer = new FileWriter(filePath);
            writer.write(content);
        } finally {
            if (writer != null) {
                System.out.println("关闭writer");
                writer.close();
            }
        }
    }

    public void myMethod() {
        try {
            writeFile("xxx.txt", "throws");
        } catch (IOException e) {
            System.err.println("myMethod中捕获到IO异常:" + e.getMessage());
        }
    }

    public static void main(String[] args) {
        ThrowsDemo demo = new ThrowsDemo();
        demo.myMethod();
    }
}

在writeFile方法中,因为涉及到文件操作,可能会抛出IOException。

这个方法选择不自己处理,而是通过throws IOException声明出去。

那么,调用writeFile的myMethod方法就必须处理这个IOException(通过try-catch)。

如果myMethod也不try-catch,就只能在方法签名上通过throws IOException继续声明出去。

等着main方法处理,当然main方法也可以选择处理,或者继续声明出去(相当于直接​​传播到JVM),一般就打印堆栈信息,然后程序退出了。

五、自定义异常

在实际的开发场景中,Java内置的那些异常没办法直观的表达或者描述具体的业务异常。

就像上面我们提到的银行账户案例,“余额不足”、“用户密码错误”这种业务异常情况。

这种情况下,我们就需要自定义异常体系,来充分的、细致的描述系统中可能出现的异常。

创建一个自定义异常其实只需要两步:

1、选择父类:

当你认为调用者应该被强制处理这种异常时(比如,这是一个可预期的、业务流程中可能发生的、且调用者有办法恢复或妥善处理的情况)。选Exception。

当这种异常更多指示的是编程错误或不应该发生的前提条件未满足,且不希望强制调用者到处try-catch时。选RuntimeException。

2、提供构造方法

至少提供两个构造。

一个无参构造,一个接受字符串(错误描述信息)。

下面两个都是可选的,根据实际情况来。

同时接收错误信息message和根本原因cause (另一个Throwable对象) 的构造方法,用于实现异常链。

其他构造方法,以接收更多特定于该异常的上下文信息。

下面我们自定义一个“余额不足”异常:

package com.lazy.snail.day16;

/**
 * @ClassName NoMoreMoneyException
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/5/30 16:32
 * @Version 1.0
 */
public class NoMoreMoneyException extends Exception {
    private double currentBalance;
    private double amountToWithdraw;

    public NoMoreMoneyException(String message) {
        super(message);
    }

    public NoMoreMoneyException(String message, double currentBalance, double amountToWithdraw) {
        super(message);
        this.currentBalance = currentBalance;
        this.amountToWithdraw = amountToWithdraw;
    }

    public double getCurrentBalance() {
        return currentBalance;
    }

    public double getAmountToWithdraw() {
        return amountToWithdraw;
    }

    @Override
    public String getMessage() {
        String basicMessage = super.getMessage();
        if (currentBalance != 0 || amountToWithdraw != 0) {
            return basicMessage + " (当前余额: " + currentBalance + ", 尝试取款: " + amountToWithdraw + ")";
        }
        return basicMessage;
    }
}

银行账户类做小小的修改:

取款方法,通过throw抛出我们自定义的NoMoreMoneyException,并且方法声明了NoMoreMoneyException。

package com.lazy.snail.day16;

/**
 * @ClassName CustomExceptionDemo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/5/30 16:40
 * @Version 1.0
 */
public class CustomExceptionDemo {
    public static void main(String[] args) {
        BankAccount2 account = new BankAccount2(100.0);
        try {
            account.withdraw(50.0);
            account.withdraw(70.0);
        } catch (NoMoreMoneyException e) {
            System.err.println("捕获到自定义异常 -> " + e.getMessage());
        } catch (IllegalArgumentException e) {
            System.err.println("捕获到参数错误:" + e.getMessage());
        }
    }
}

初始化了100块在账户里,然后取了50之后,想再取70,触发了"余额不足"异常。

六、try-with-resources

在前面的finally块的讨论中,我们看到了一个经典的场景:确保像文件流、数据库连接、网络套接字这样的资源在操作完成后被正确关闭。

通常需要在finally块中写一堆if (resource != null) { resource.close(); }的代码,而且close()方法本身也可能抛出IOException,所以finally块内部可能还需要嵌套try-catch。

这不仅让代码看着又臭又长,也很容易出错(比如忘记关闭,或者关闭顺序不对)。

从Java 7开始引入了try-with-resources这种语法糖,很大程度上简化了资源管理。

try-with-resources的核心思想其实是,对于实现了java.lang.AutoCloseable接口或其子接口java.io.Closeable的资源,可以在try关键字后的小括号中声明并初始化这些资源。

当try块执行完毕(不管是正常结束还是因异常退出),Java会自动按照与声明顺序相反的顺序调用这些资源的close()方法。

直接来看个例子吧:

package com.lazy.snail.day16;

import java.io.*;

/**
 * @ClassName TryWithResourcesDemo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/5/30 16:55
 * @Version 1.0
 */
public class TryWithResourcesDemo {
    public void readFile(String filePath) {
        try (FileReader fr = new FileReader(filePath);
             BufferedReader br = new BufferedReader(fr)) {
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            System.err.println("读取文件时发生错误:" + e.getMessage());
        }
    }

    public void writeFile(String filePath, String content) {
        try (FileWriter fw = new FileWriter(filePath);
             BufferedWriter bw = new BufferedWriter(fw)) {
            bw.write(content);
            bw.newLine();
        } catch (IOException e) {
            System.err.println("写入文件时发生错误: " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        TryWithResourcesDemo demo = new TryWithResourcesDemo();
        String tempFile = "xxx.txt";
        demo.writeFile(tempFile, "懒惰蜗牛");
        demo.readFile(tempFile);
    }
}

readFile和writeFile都使用了try-with-resources的写法,这样就不需要显式的finally块来关闭资源了。

结语

总结一下:

Java异常机制为程序提供了结构化的错误处理方式。

我们可以通过try-catch-finally块分离正常逻辑和错误处理,使用throw主动报告错误,并通过throws声明方法可能抛出的受检异常。

精确捕获、避免忽略、合理使用自定义异常和try-with-resources,是编写健壮、可维护Java代码的关键。

下一篇预告

Day17 | Java包与访问修饰符详解

如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!

更多文章请关注我的公众号《懒惰蜗牛工坊》