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异常类结构图,从图中可以看到Throwable是整个异常类体系的父类,它有两个最主要的子类,分别是Error和Exception。
Exception即异常,是应用程序本身可以处理的,Java将其分为两大类:
Error即错误,因为Error往往是虚拟机相关的比较严重的错误,应用程序一般是没有能力恢复的,例如StackOverflowError(栈溢出)、OutOfMemoryError(内存溢出)等,虚拟机对这种错误的处理方法一般是直接停止相关线程(也就是说,如果应用程序是多线程并发程序,那么即使出现了Error,应用程序也很可能不会直接退出)。实际上,Java虽然没有禁止应用程序捕获Error,但我们也应该尽量不要去做这事,因为这种错误并不是程序逻辑错误,而是虚拟机发生的错误,基本是不可修复的,如果捕获了但无法处理的话,我们将无法得到错误堆栈,导致难以排查问题。
Java中异常处理机制包含三个方面:检测异常,捕获异常以及处理异常。
我们可以使用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值所指向的字节码进行异常处理。如果遍历完整个表也没有找到匹配的行,那么就会弹出栈,并在此时的栈帧上继续执行如上操作,最坏的情况就是虚拟机需要遍历整个方法调用栈中所有的异常表,如果最后还是没有找到匹配的异常表条目,虚拟机将直接将异常抛出,并打印异常堆栈信息。
上面的文字描述可能会有点绕,不用担心,看看下面这张逻辑流程图,结合文字描述,应该就可以理解异常处理的流程了。
其实从上面的流程描述中,还隐含了一个重要的知识点: 异常传播机制 。即当前方法无法处理的时候,异常会传播到调用方,继续尝试处理异常,如此往复,知道最顶层的调用方,如果还是没有合适的异常处理,那么就直接停止线程,抛出异常并打印异常堆栈。下面的代码演示了异常传播机制:
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线程并虚拟机停止了,没能继续执行。这是因为在整个方法调用栈中,没有在任何一个方法的异常表找到匹配的异常表条目,即没有找到合适的异常处理器,最终没有办法了,只能停止线程并抛出异常,指望程序员能处理了。
到现在为止,我一直没有提到finally,但其实finally也是一个很重要的组件。finally可以结合try-catch块,无论是否发生异常,都会执行finally里的逻辑。finally的设计初衷是为了避免程序员忘记写上一些清理操作的代码,例如关闭网络连接、文件IO连接等。
finally代码块的编译也是比较复杂的,编译器(当前版本的编译器)并不是直接使用跳转指令来实现“无论是否发生异常都会执行finally”功能的。而是采用“复制”的方法,将finally块的代码复制到try-catch块所有正常执行路径以及异常执行路径的出口位置。如下图所示(图来自极客时间上关于JVM的一门课程,在最后我会标注):
变种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里的逻辑。
本文简单介绍了异常的概念、分类以及异常处理机制。尤其是异常处理机制,我们深入到字节码层面去查看整个处理机制的执行流程,相信大家会对异常处理有更深刻的认识。finally也是一个很重要的组件,其作用就是在整个try-catch-finally结构中,无论是否发生异常,都会执行finally块里的逻辑,并且我也尝试深入到字节码中分析这个功能是如何实现的。