Java 异常处理一览 | 基础篇

818 阅读13分钟

这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战

作者:白色蜗牛

什么是异常

我们日常生活中经常会遇到一些意外的事情,比如坐火车没带身份证,那你就无法顺利上车。 ​

计算机世界也有类似的情形,术语是异常(Exception),其实是异常事件(Exception Event)的缩写。 ​

一个异常就是一个事件,它发生在程序执行过程中,会中断程序的正常运行。好比你上火车没有身份证,这就是个异常事件,这个事件会阻挡你正常上火车。 ​

计算机程序运行会有个主入口,一般我们称为 main 方法,main 方法内部也可能调用各种其它方法。当某个方法发生错误时,这个方法就会创建一个对象,并把它移交给运行时的系统。这个对象就称为**异常对象 **,它包含了错误相关的信息,包括错误类型和程序状态。 ​

创建异常对象并将其交给运行时系统这个操作就称为抛出异常。 ​

当方法抛出异常后,运行时系统会尝试找到处理异常的方法。首先系统会判断,错误发生的方法有没有处理,如果没有,会把异常往上层方法抛,直到找到有异常处理的方法。这样的话,从错误发生的方法到异常处理的方法之间,就会形成调用方法的有序列表。 ​

这个方法列表就称为调用堆栈(call stack)。应用程序的每个方法会按调用顺序进栈,栈是先进后出的,比如 main 方法先进栈,开始执行程序,遇到其他方法的调用,其他方法也进栈,其他方法执行完毕,其他方法出栈,继续执行 main 方法,main 方法执行完毕就出栈,栈空,程序运行结束。 ​

运行时系统会在调用堆栈中寻找包含可以处理异常的代码块的方法,这段代码就称为异常处理程序。通过调用堆栈,从错误发生的方法开始,按照方法调用相反的顺序寻找(栈有先进后出的特点)。当找到合适的异常处理程序时,运行时系统就会把异常传递给处理程序。如果抛出的异常对象的类型和处理程序可以处理的类型相匹配,就认为异常处理程序是适当的。 ​

选中异常处理程序的过程就称为捕获异常。 ​

如果运行时系统找遍了调用堆栈上的所有方法,依然没有找到适当的异常处理程序,那么运行时系统(以及随后的程序)将终止。 ​

异常处理程序.png

观察以下代码,想想运行情况是怎样的? ​

package com.springtest.demo;

public class Test {

    /**
     * 程序主方法
     *
     * @param args 程序入参
     */
    public static void main(String[] args) {

        // 用户输入字符串 woniu
        String woniu = "woniu";

        int num = str2number(woniu);

        System.out.println(num);
    }

    /**
     * str 转 整数
     *
     * @param str 字符串
     * @return 整数
     */
    private static int str2number(String str) {

        // 解析成数字,抛 NumberFormatException
        return Integer.parseInt(str);
    }

}

输出是这样的: ​

Exception in thread "main" java.lang.NumberFormatException: For input string: "woniu"
	at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
	at java.lang.Integer.parseInt(Integer.java:580)
	at java.lang.Integer.parseInt(Integer.java:615)
	at com.springtest.demo.Test.str2number(Test.java:29)
	at com.springtest.demo.Test.main(Test.java:15)

观察运行的结果信息,我们发现应用主程序出现异常了,并且程序终止掉了,因为 num 的值并没有打印。 ​

结果里也告知我们是出现了 NumberFormatException,也就是数字格式异常,后边也给到了提示,woniu 这个字符串是转换不了数字的。这符合我们的预期。 ​

然后就是调用堆栈,调用堆栈里的每一行信息都标明了异常流转过程中所在的方法路径类名以及代码行数。 ​

其中第一行信息就是异常最先发生的地方,这也可以作为我们异常排查的依据。 ​

很明显,在 forInputString 抛出异常后,parseIntstr2number 都只是转发异常,并没有捕获异常,甚至在 main 方法中,也没捕获异常。最后因为没有异常处理程序,而导致程序运行终止。

如何捕获和处理异常

为了程序能够正常运行不被意外终止,Java 编程规范就有要求:异常必须要捕获或者指定。 ​

使用 try

捕获异常的第一步是**try** 把可能引发异常的代码括起来

语法如下:

try {
    // 可能引发异常的代码
}

try 包括了一个代码块,你可以把可能引发异常的代码放里边。代码可以是一行,也可以是多行。这也意味着这个代码块可能引发多种不同的异常。

异常处理程序只有 try 是无法通过编译的。你用 javac 命令编译只有 try 的 java 文件,会报以下错误: ​

错误: 'try' 不带有 'catch', 'finally' 或资源声明
        try {
        ^
1 个错误

所以 try 代码块只是圈定了捕获异常的范围,只靠 try 做异常管理显然不够。 ​

使用 catch

catch 语法

因此捕获异常就需要第二步:**catch** 捕获异常和异常处理

语法如下:

try {
    // 可能引发异常的代码
} catch (ExceptionType1 name1) {
    // 命中异常类型1 ExceptionType1 时的异常处理代码
} catch (ExceptionType2 name2) {
    // 命中异常类型2 ExceptionType2 时的异常处理代码
}

catch 是搭配 try 使用的,不单独出现。try 后边可以跟多个 catch 代码块,以处理 try 中出现的多种类型的异常。 ​

每个 catch 代码块都是一个异常处理程序,处理的时候由 catch 的参数指定异常类型。 ​

catch 的圆括号里,参数 ExceptionType 声明了这个处理程序可以处理的异常类型,这个异常类型必须是从 Throwable 类继承的类。 ​

Java 异常的继承体系

提到 Throwable 就不得不说 Java 的异常体系。以下是 Java 异常的继承体系图。 ​

Java异常类关系.png Throwable 是异常体系的根,它继承自 Object。Throwable 又拆分成两个体系:ErrorException。 ​

Error 表示严重的错误,程序一般无法处理,比如表示栈溢出的 StackOverflowError。 ​

Exception 表示运行时的错误,它是可以被捕获并处理的。Exception 又可以拆分为两类:RuntimeExceptionChecked Exception。 ​

RuntimeException 指运行时异常,它是程序逻辑编写不对造成的,比如表示空指针异常的 NullPointerException 以及表示数组索引越界的 IndexOutOfBoundsException。出现这种异常就是代码 Bug,应该修复程序代码。 ​

int[] arrry = {0,1,2};

// 此处会抛 java.lang.ArrayIndexOutOfBoundsException,不应该出现 arrry[3] 这样的代码
System.out.println(arrry[3]);

Checked Exception 指检测型异常,它是程序逻辑的一部分。比如表示 IO 异常的 IOException 以及表示文件找不到的 FileNotFoundException。这种异常必须捕获并处理,否则编译会失败。

以下代码是不能编译通过的:

public class A {
    public static void main(String[] args) {

        FileInputStream inputStream = new FileInputStream("/");

    }
}

javac 编译会报以下错误,也会提示你必须用 try/catch 捕获或者把异常添加到声明里方便抛出。

错误: 未报告的异常错误FileNotFoundException; 必须对其进行捕获或声明以便抛出
        FileInputStream inputStream = new FileInputStream("/");
                                      ^
1 个错误

catch 使用

回到 catch 语法中,ExceptionType 对应的就是 Java 异常体系中的 Exception 类或者它的子类。 ​

name 是为异常类型起的名称,花括号里的内容就是调用异常处理程序时执行的代码,这里的代码可以通过 name 这个名称引用异常。 ​

当调用堆栈出现异常时,运行时系统会调用异常处理程序,当异常处理程序的 ExceptionType 和引发异常的类型匹配时,即命中某个 catch 块,就会把异常对象分配给异常处理程序的参数,进而执行 catch 块的异常处理代码。 ​

异常处理程序我们可以做很多事情,比如打印错误日志,暂停程序,执行错误恢复,也可以提示给用户,或者把异常往上层传递。以下是打印错误信息的示例代码: ​

public static void main(String[] args) {

    try {

        int[] arrry = {0, 1, 2};

        // 此处会抛 java.lang.ArrayIndexOutOfBoundsException,不应该出现 arrry[3] 这样的代码
        System.out.println(arrry[3]);

    } catch (IndexOutOfBoundsException e) {

        System.out.println("捕获到数组越界异常:");
        System.out.println(e);
    }
}

输出结果为: ​

捕获到数组越界异常:
java.lang.ArrayIndexOutOfBoundsException: 3

有些场景,我们的一段代码可能引发多种异常,而异常的处理会比较一致,比如都是打印日志,这种情况下,如果都单独设置一个 catch 块,写相同的代码,重复度就很高。 ​

因此在 Java 7 之后,一个 catch 块就支持处理多种类型的异常。语法如下: ​

try {
    // 可能引发异常的代码
} catch (ExceptionType1 name1 | ExceptionType2 name2) {
    // 命中异常类型1 ExceptionType1 或异常类型2 ExceptionType2 时的异常处理代码
} 

使用 finally

程序在运行的时候有时候会打开一些资源,比如文件,连接,线程等等。如果程序运行中途抛异常,程序终止,打开的资源就永远得不到释放了,这会导致资源泄漏,甚至系统崩溃。 ​

再比如,程序运行结束前,我要输出一个摘要日志做监控,但如果运行中途抛异常,程序终止,日志就不会打印,我也看不到我想要的信息。

因此需要有种机制,能够支持在异常发生,阻断流程的时候,也能把打开的资源释放掉或者执行指定的逻辑。 ​

Java 用 finally 来达成这种目的,finally 可以形成 try-finally 结构,也可以形成 try-catch-finally 结构。但是 finally 代码块总是try 退出时执行。 ​

这个「总是」可以分为以下几种情况: ​

无异常

try 执行完毕,未发生异常,然后执行 finally 代码块,像普通程序一样顺序执行。 ​

public static void main(String[] args) {

    System.out.println("main:" + fetchMyName());

}

public static String fetchMyName() {

    String me = "woniu";

    try {
        me = "woniu666";

    } finally {
        System.out.println("finally: " + me);
    }

    return me;
}

输出:

finally: woniu666
main:woniu666

有异常未捕获

try 执行过程中出现异常,会把异常对象抛出,但 finally 代码块依然会执行。

public static void main(String[] args) {

    System.out.println("main:" + fetchMyName());

}

public static String fetchMyName() {

    String me = "woniu";
    int[] arrry = {0, 1, 2};

    try {
        me = "woniu666";

        // 此处会抛 java.lang.ArrayIndexOutOfBoundsException,不应该出现 arrry[3] 这样的代码
        System.out.println(arrry[3]);

    } finally {
        System.out.println("finally: " + me);
    }

    return me;
}

fetchMyName() 未捕获到异常,就往上抛,但会把 finally 里的逻辑先执行掉,在 main 方法中同样没有捕获异常,于是就阻断了程序,打印出了调用堆栈。

finally: woniu666
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 3
	at com.springtest.demo.TryFinally.fetchMyName(TryFinally.java:28)
	at com.springtest.demo.TryFinally.main(TryFinally.java:15)

有异常有捕获

try 执行过程中出现异常,会把异常对象抛出,catch 捕获异常并正常处理,此时 finally 代码块依然会执行。 ​

public static void main(String[] args) {

    System.out.println("main:" + fetchMyName());

}

public static String fetchMyName() {

    String me = "woniu";
    int[] arrry = {0, 1, 2};

    try {
        me = "woniu666";

        // 此处会抛 java.lang.ArrayIndexOutOfBoundsException,不应该出现 arrry[3] 这样的代码
        System.out.println(arrry[3]);

    } catch (ArrayIndexOutOfBoundsException e) {
        System.out.println("命中数组索引越界异常的处理器,越界索引为:" + e.getMessage());

    } finally {
        System.out.println("finally: " + me);
    }

    return me;
}

代码正常运行,先执行了 catch 代码块中的逻辑,然后执行了 finally 代码块,最后执行 main 方法。

命中数组索引越界异常的处理器,越界索引为:3
finally: woniu666
main:woniu666

try 中 return

return 意味着方法执行结束,而 finally 是在 try 退出时执行,那如果 try 代码块中带 returnfinally 代码块还会执行到么? ​

try 代码块加个 return 试试!

public static void main(String[] args) {

    System.out.println("main:" + fetchMyName());

}

public static String fetchMyName() {

    String me = "woniu";
    int[] arrry = {0, 1, 2};

    try {
        me = "woniu666";

        // 此处不抛异常
        System.out.println(arrry[0]);

        return "try";

    } catch (ArrayIndexOutOfBoundsException e) {
        System.out.println("命中数组索引越界异常的处理器,越界索引为:" + e.getMessage());

    } finally {
        System.out.println("finally: " + me);
    }

    return me;
}

看结果依然会走到 finally 代码块的执行!

0
finally: woniu666
main:try

catch 中 return

tryreturn 我们试了,那 catchreturnfinally 的执行是啥样的呢?

public static void main(String[] args) {

    System.out.println("main:" + fetchMyName());

}

public static String fetchMyName() {

    String me = "woniu";
    int[] arrry = {0, 1, 2};

    try {
        me = "woniu666";

        // 此处会抛 java.lang.ArrayIndexOutOfBoundsException,不应该出现 arrry[3] 这样的代码
        System.out.println(arrry[3]);

    } catch (ArrayIndexOutOfBoundsException e) {
        System.out.println("命中数组索引越界异常的处理器,越界索引为:" + e.getMessage());

        return "catch";

    } finally {
        System.out.println("finally: " + me);
    }

    return me;
}

看结果依然会走到 finally 代码块的执行! ​

命中数组索引越界异常的处理器,越界索引为:3
finally: woniu666
main:catch

如何指定方法抛出的异常

异常捕获的知识介绍完之后,我想象另外一种情况,就是我当前方法抛出异常后,但是呢,当前方法不适合处理这个异常,而调用堆栈上层的方法更适合处理。那其实当前方法最好就不要捕获异常,并能够允许调用堆栈上层的方法处理它。 ​

此时,如果抛出的异常是 检查型异常,那你就必须在方法上指定它可以抛出这些异常。你需要在方法声明中添加一个 throws 语句。throws 语句包含 throws 关键字,后面跟着由该方法一引发的所有异常,多个异常用逗号分隔。throws 语句放在方法名和参数列表之后,放在定义方法范围的圆括号之前。 ​

代码示例如下: ​

public static void test() throws FileNotFoundException {

    FileInputStream inputStream = new FileInputStream("/");

}

由上层 main 方法捕获处理:

public static void main(String[] args) {

    try {
        test();

    } catch (FileNotFoundException e) {

        System.out.println("文件找不到异常:" + e.getMessage());
    }

}

可以正常输出:

文件找不到异常:/ (Is a directory)

前边说检查型异常必须要处理,是因为不处理会编译不通过,要么捕获和处理异常,要么指定方法抛出的异常, ​

那非检查型异常,也就是运行时异常也有这种要求么? ​

非检查型异常并不强制,你可以指定方法抛出的异常,也可以不指定,不指定的时候,异常对象会不停的验着调用堆栈向上层抛,直到被捕获处理或者程序终止。

小结

本文介绍了异常的概念,我们了解到了异常相关的术语,异常出现的背景以及异常的运行机制,接着我们按照 Java 编程规范分别介绍了异常如何捕获以及异常如何指定,同时也介绍了 Java 异常的继承体系。这些都是非常基础的内容,却也非常重要,写代码的时候必须要考虑这方面,甚至你可以认为,面向异常编程非常考验你的编码功底。 ​

我是蜗牛,在互联网上和你一起摸爬滚打。下期见!