Java SE基础巩固(十一):异常

499 阅读11分钟

1 什么是异常

Oracle官方对异常给出了如下定义:

Definition: An exception is an event, which occurs during the execution of a program, that disrupts the normal flow of the program's instructions.

简单翻译就是一个异常是在程序在执行过程中出现的事件,它扰乱了正常的指令流(翻译的不好,见谅)。程序在运行的过程中会因为各种各样的因素导致程序无法继续执行,例如找不到文件、网络连接超时、解析文件失败等等,Java将这种导致程序无法正常执行的因素抽象成“异常”,并以此细分各种各样的“异常”,再结合“异常处理”构成了整个异常体系,所谓“异常处理”指的就是当程序发生异常的时候,程序能自己处理异常,并尝试恢复异常,使程序能继续正常的运行而不需要外界认为的干预。下面我将逐步深入的介绍Java异常体系中几个重要的点,包括但不限于:

  • Java异常类继承体系结构
  • 异常的分类
  • 异常处理机制

实际上,异常和异常处理机制在计算机硬件上就有的机制,各种编程语言对其做了抽象,使得异常的检测、处理更加方便、高效。

2 Java异常类继承体系结构

iNBBoq.png

上图是Java异常类结构图,从图中可以看到Throwable是整个异常类体系的父类,它有两个最主要的子类,分别是Error和Exception。

2.1 Exception

Exception即异常,是应用程序本身可以处理的,Java将其分为两大类:

  • 非受检异常。可以理解为运行时异常,即运行时会发生的异常,这种类型的异常不强求程序必须捕获或者抛出,实际上也非常不建议在程序中捕获运行时异常,因为运行时异常往往指代了某种系统异常,难以处理,如果捕获了还很有可能导致程序不打印错误堆栈,使得错误难以排查。
  • 受检异常。除了RuntimeException及其子类,其他Exception都是受检异常,Java编译器要求程序必须捕获(是否处理取决于需求)或者在方法签名上加上throws声明抛出该类型异常。

2.2 Error

Error即错误,因为Error往往是虚拟机相关的比较严重的错误,应用程序一般是没有能力恢复的,例如StackOverflowError(栈溢出)、OutOfMemoryError(内存溢出)等,虚拟机对这种错误的处理方法一般是直接停止相关线程(也就是说,如果应用程序是多线程并发程序,那么即使出现了Error,应用程序也很可能不会直接退出)。实际上,Java虽然没有禁止应用程序捕获Error,但我们也应该尽量不要去做这事,因为这种错误并不是程序逻辑错误,而是虚拟机发生的错误,基本是不可修复的,如果捕获了但无法处理的话,我们将无法得到错误堆栈,导致难以排查问题。

3 异常处理机制

Java中异常处理机制包含三个方面:检测异常,捕获异常以及处理异常。

3.1 try-catch块检测、捕获并处理异常

我们可以使用try关键字来指定一个范围,该范围就是异常检测的范围,然后使用catch创建一个异常处理块(在Java中,如果只有try而没有catch则无法通过通过编译)假设有如下代码:

public static void method1() {
    try {
        method2();
    } catch (IOException e) {
        System.out.println("catch io exception");
    }
}

先用javac将其编译,然后使用javap -verbose XXX.class 将字节码信息翻译并打印出来,结果如下所示:

  public static void method1() throws java.io.IOException;
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=0
         0: invokestatic  #6                  // Method method2:()V
         3: goto          15
         6: astore_0
         7: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        10: ldc           #8                  // String catch io exception
        12: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        15: return
      Exception table:                          //异常表
         from    to  target type
             0     3     6   Class java/io/IOException   
      LineNumberTable:
        line 19: 0
        line 22: 3
        line 20: 6
        line 21: 7
        line 23: 15
      StackMapTable: number_of_entries = 2
        frame_type = 70 /* same_locals_1_stack_item */
          stack = [ class java/io/IOException ]
        frame_type = 8 /* same */

主要看看 Exception table(即异常表)标签,发现只有一行数据,有from、to、target、type等字段。from、to即构成了异常检测的范围(例子中即0~3),target代表异常处理开始的字节码索引(例子中即索引为6的字节码),type表示异常处理器所处理的异常类型(例子中是IOException)。

现在来看看 Exception table(异常表),异常表里的一行数据表示一个异常处理器,每行数据有from、to、target、type四个字段,前三个字段的值都是字节码的索引,type的值是一个符号引用,代表了异常处理器所处理的类型。每个方法都会有一个异常表,但有时候我们没有在javap的打印结果中看到,这是因为对应的方法没有异常处理器,即异常表中没有任何数据,javap只是将其省略了而已。

当有异常发生的时候,虚拟机会遍历异常表,首先检查出现异常的位置是否在异常表中某个条目的检测范围内(from-to字段),如果有这样的一个条目,将继续检查所抛出的异常是否是和type字段描述的异常匹配,如果匹配,就跳转到target值所指向的字节码进行异常处理。如果遍历完整个表也没有找到匹配的行,那么就会弹出栈,并在此时的栈帧上继续执行如上操作,最坏的情况就是虚拟机需要遍历整个方法调用栈中所有的异常表,如果最后还是没有找到匹配的异常表条目,虚拟机将直接将异常抛出,并打印异常堆栈信息。

上面的文字描述可能会有点绕,不用担心,看看下面这张逻辑流程图,结合文字描述,应该就可以理解异常处理的流程了。

iUKU2V.png

其实从上面的流程描述中,还隐含了一个重要的知识点:异常传播机制。即当前方法无法处理的时候,异常会传播到调用方,继续尝试处理异常,如此往复,知道最顶层的调用方,如果还是没有合适的异常处理,那么就直接停止线程,抛出异常并打印异常堆栈。下面的代码演示了异常传播机制:

public class Main {

    public static void main(String[] args) {
        method1();

        System.out.println("continue...");
    }

    public static void method1() {
        try {
            method2();
        } catch (IOException e) {
            System.out.println("catch io exception");
        }
    }

    public static void method2() throws IOException{
        method3();
    }

    public static void method3() throws IOException {
        throw new IOException("method3");
    }

}

代码中,main方法调用method1,method1调用method2,method2调用method3,在method3中抛出了一个IOEception,因为IOException是一个受检异常,所以method2要么使用try-catch构建一个异常处理器,要么使用throws关键字将异常继续往上抛,method2选择的是往上抛出异常,method1则是构建了一个异常处理器,如果该异常处理器能正确的捕获并处理异常,则不会再往上抛异常了,所以main方法不需要做特殊处理。运行一下,结果大致如下所示:

catch io exception
continue...

发现continue能正确输出,说明main线程没有被停止,即异常已经被正确处理了。现在来修改一下代码,如下所示:

public static void method1() throws IOException {
    method2();
}
//其他部分代码没有变化

此时再次运行,结果大致如下:

Exception in thread "main" java.io.IOException: method3
	at top.yeonon.exception.Main.method3(Main.java:26)
	at top.yeonon.exception.Main.method2(Main.java:22)
	at top.yeonon.exception.Main.method1(Main.java:18)
	at top.yeonon.exception.Main.main(Main.java:12)

发现打印了异常堆栈,但是没有打印continue,说明main线程并虚拟机停止了,没能继续执行。这是因为在整个方法调用栈中,没有在任何一个方法的异常表找到匹配的异常表条目,即没有找到合适的异常处理器,最终没有办法了,只能停止线程并抛出异常,指望程序员能处理了。

3.2 finally

到现在为止,我一直没有提到finally,但其实finally也是一个很重要的组件。finally可以结合try-catch块,无论是否发生异常,都会执行finally里的逻辑。finally的设计初衷是为了避免程序员忘记写上一些清理操作的代码,例如关闭网络连接、文件IO连接等。

finally代码块的编译也是比较复杂的,编译器(当前版本的编译器)并不是直接使用跳转指令来实现“无论是否发生异常都会执行finally”功能的。而是采用“复制”的方法,将finally块的代码复制到try-catch块所有正常执行路径以及异常执行路径的出口位置。如下图所示(图来自极客时间上关于JVM的一门课程,在最后我会标注):

iUQ1ts.png

变种1和变种2的逻辑其实是一样的,只是finally块所在的位置不太一样而已。现在假设有如下代码:

public class Main {

    public static void main(String[] args) {
        try {
            method3();
        } catch (IOException e) {
            System.out.println("catch io exception");
        } finally {
            System.out.println("execute finally block");
        }
        System.out.println("continue...");
    }

    public static void method3() throws IOException {
        throw new IOException("method3");
    }
}

同样编译后,使用javap来输出可阅读的字节码,如下所示:

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: invokestatic  #2                  // Method method3:()V
         3: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         6: ldc           #4                  // String execute finally block
         8: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        11: goto          45
        14: astore_1
        15: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        18: ldc           #7                  // String catch io exception
        20: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        23: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        26: ldc           #4                  // String execute finally block
        28: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        31: goto          45
        34: astore_2
        35: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        38: ldc           #4                  // String execute finally block
        40: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        43: aload_2
        44: athrow
        45: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        48: ldc           #8                  // String continue...
        50: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        53: return
      Exception table:
         from    to  target type
             0     3    14   Class java/io/IOException
             0     3    34   any
            14    23    34   any

注意一下6、26、38号指令和其前后两条指令,发现其实就是finally块代码的内容,即输出 execute finally block字符串。而且恰好有3份,和之前所描述的已知。然后来看看异常表,重点看看后面两行,这里比较特殊的就是type字段,该字段的值是any,javap用这个来指代所有异常,即这两个条目要处理的就是所有异常。其中的第一条form-to的范围是0~3,发现是try块的的范围,第二条from-to的范围是14~23,发现其实是catch块。为什么会这样呢?

首先说try块的,如果我们自己定义的异常处理器无法和发生的异常匹配,那么就会被捕获所有异常的异常处理器捕获,并跳转到异常处理器所在的位置,例如这里的34号指令,我们发现其实34号指令就是finally块原本所在的位置,也就是说,即使发现了没有捕获到的异常,也会走到finally块的逻辑中。对于正常的情况,则是不会走到34号开始的代码块的,而是直接goto(11号指令)到45号指令处。

然后就是catch块,因为在catch块里也有可能发生异常的,所以加上这么一个异常捕获器,并且和上面的一样,跳转到34号指令处执行finally代码,如果在catch块里没有发生异常,和try块那里一样,继续执行复制过来的finally块的代码,执行完毕后直接goto(31号指令)到45号指令处,也没有执行最后的从34号开始的finally块。

这也就是为什么在整个try-catch-finally结构中,无论是否发生异常,总是会执行finally里的逻辑。

4 小结

本文简单介绍了异常的概念、分类以及异常处理机制。尤其是异常处理机制,我们深入到字节码层面去查看整个处理机制的执行流程,相信大家会对异常处理有更深刻的认识。finally也是一个很重要的组件,其作用就是在整个try-catch-finally结构中,无论是否发生异常,都会执行finally块里的逻辑,并且我也尝试深入到字节码中分析这个功能是如何实现的。

5 参考资料

深入理解java异常处理机制

极客时间: 深入拆解 Java 虚拟机第6节 :JVM是如何处理异常的?