JVM是如何处理异常的
前言
说起异常,作为Java语言的程序员再熟悉不过了,各种意想不到的Exception和Error总会让我们猝不及防,所以本章就来看看JVM是如何处理异常的。
正文
异常处理的俩大组成要素是抛出异常和捕获异常,这俩大要素共同实现程序流的非正常转移。
抛出异常可分为显示和隐式,显示抛出异常的主体是应用程序,它指的是在程序中使用"throw"关键字,手动将异常实例抛出;而隐式抛异常的主体则是JVM,它值的是JVM在执行过程中,碰到无法继续执行的异常状态,自动抛出异常。
而捕获异常则涉及了如下三种代码块:
-
try代码块:用来标记需要进行异常监控的代码。
-
catch代码块:跟在try代码块之后,用来捕获在try代码块中触发的某种指定类型的异常。除了声明所捕获异常的类型外,catch代码块还定义了针对该异常类型的异常处理器。在Java中,try代码块可以跟多个catch代码块来捕获不同类型的异常,JVM会从上到下匹配异常处理器,所以这就说明catch代码块所捕获的异常类型不能覆盖后面的,否则会报错。
-
finally代码块:跟在try代码块和catch代码块之后,用来声明一段必定会执行的代码。
上述3种代码块,我们再熟悉不过了,不过对于这个finally代码块,必须要说一下其会运行的规则:
-
在程序正常执行的情况下,finally代码快在try代码块之后运行;
-
当try代码块触发异常时,如果该异常没有被捕获,finally代码依旧会运行,在其运行完之后重新抛出异常;
-
当try代码块触发异常时,被catch代码块捕获,finally会在catch代码块之后运行;
-
极端条件下,catch代码块在处理异常时也触发了异常,finally代码块同样会运行,会抛出catch代码块触发的异常;
-
更极端条件下,finally代码块也触发了异常,那么只好中断当前finally代码块的执行,向外抛出异常。
异常的分类
在Java语言规范中,所有的异常都是Thorwable类以及其子类的实例。
Throwable有2个子类,一个是Error,涵盖程序不应捕获的异常,当程序触发Error时,它的执行状态已经无法恢复;另一个是Exception,涵盖了可能要捕获的异常,同时它还有一个特殊子类叫做RuntimeException,是运行时异常,表示程序虽然出错了,但是还可以抢救,比如数组越界了,我们可以给捕获住,再继续执行。
检查异常和非检查异常
在对异常进行分类时,其中RuntimeException和Error属于Java中的unchecked exception异常,即非检查异常,啥意思呢 也就是这些异常会出现的地方你根本无法预测,属于你根本无法检查的类型,所以对于这种异常我们可以不处理、进行捕获或者抛出。
而对于其他异常,其实也就没几种了,比如IO异常,他们属于checked exception异常,即检查异常,在Java语法中,所有检查异常都需要程序显示的进行捕获或者在方法声明时使用throws关键字标注。
构造异常实例
虽然我们平时经常会碰到异常,但是构造一个异常实例却十分昂贵和麻烦,由于构造异常实例时,Java虚拟机便需要生成该异常的栈轨迹(stack trace),该操作会逐步访问当前线程的Java栈帧,并且记录下各种调试信息,包括栈帧指向方法的名字,方法所在的类名、文件名,以及在代码中第几行触发该异常。
也正是因为这样,当程序遇到异常时,我们才可以分析调用栈,找到其问题。
JVM是如何捕获异常的
当异常被抛出时,我们可以捕获,而这个捕获JVM是如何实现的呢 其实也很简单,在字节码文件中,每个方法都附带一个异常表;
异常表中每一个条目代表一个异常处理器,并且由from指针、to指针、target指针以及所捕获的异常类型,这些指针的值是字节码索引,用于定位字节码。
其中from和to指针标示了该异常处理器所监控的范围,例如try代码块所覆盖的范围。target指针指向异常处理器的起始位置,即catch代码块的起始位置。
比如下面代码:
public static void main(String[] args) {
try {
mayThrowException();
} catch (Exception e) {
e.printStackTrace();
}
}
该方法对应的字节码:
public static void main(java.lang.String[]);
Code:
0: invokestatic mayThrowException:()V
3: goto 11
6: astore_1
7: aload_1
8: invokevirtual java.lang.Exception.printStackTrace
11: return
Exception table:
from to target type
0 3 6 Class java/lang/Exception // 异常表条目
在字节码中有个异常表,有一个条目,其中0 - 3表示的就是监控索引从0开始到3(不包括3),而target指向6,即发生异常时所进行的代码,最后一列说明的是捕获的异常类型。
当程序触发异常时,JVM会从上到下遍历异常表中的所有条目,当触发异常的字节码索引值在某个异常表条目的监控范围内,JVM会判断所抛出的异常是否和该条目要捕获的异常是否匹配。
如果匹配,JVM会将控制流转移到该条目target指针指向的字节码。如果遍历完当前方法所以异常表条目,都没有匹配到异常处理器,那么会弹出当前方法对应的Java栈帧,并且重复上述操作。
所以在最坏的情况下,JVM会遍历当前线程Java栈上所有方法的异常表。
上面字节码也就说明了一个问题,假如方法A调用方法B,在A代码中捕获异常X,而try代码中调用方法B,在B中也进行捕获异常X,当B抛出X时,则B的异常处理器进行处理即可。
finally代码块
同样,finally代码块的编译比较复杂,为什么比较复杂呢 这里可以想象一下,因为这个finally代码块设计初衷是不论try代码块和catch代码块哪个发生了异常,都需要能执行这个finally代码块,所以java编译器的做法是把finally代码块的字节码复制到正常和异常分支。
示意图如下:
第一次看到这个是不是有种看不太明白的感觉,这里的Java代码,只有一份finally代码块,但是在变种1中,当try代码块正常执行时,需要执行finally代码块,所以复制一份放到try代码块之后;当try代码块发生异常时,会执行catch代码块,所以复制一份放到catch代码块之后;还有一种是当catch代码块发生异常时,这时还要再执行一遍finally代码块。
通过上面说明,我们再来结合字节码来分析一下:
private int tryBlock;
private int catchBlock;
private int finallyBlock;
private int methodExit;
public void test() {
try {
tryBlock = 0;
} catch (Exception e) {
catchBlock = 1;
} finally {
finallyBlock = 2;
}
methodExit = 3;
}
上面test方法中,我们定义了4个成员变量,然后分别赋值,我们来看一下这个test方法对应的字节码:
Code:
stack=2, locals=3, args_size=1
0: aload_0 //从局部变量区加载this指针
1: iconst_0 //加载常量0到操作数栈
2: putfield #2 // Field tryBlock:I 赋值操作,tryBlock
5: aload_0 /加载this指针
6: iconst_2 //加载常量2
7: putfield #3 // Field finallyBlock:I 赋值操作,finallyBlock
10: goto 35 //跳转到exitBlock
13: astore_1 //根据异常表,这是0-5的target异常捕获部分代码,这就是异常实例
14: aload_0 //加载this指针
15: iconst_1 //加载常量1
16: putfield #5 // Field catchBlock:I 赋值操作,catchBlock
19: aload_0 //加载this指针
20: iconst_2 //加载常量2
21: putfield #3 // Field finallyBlock:I 赋值操作,finallyBlock
24: goto 35 //跳转到exitBlock
27: astore_2 //根据异常表,这时0-5的target异常捕获代码(catch捕获非匹配的类型)以及13-19的catch代码块异常捕获的target,保存异常实例
28: aload_0 //加载this指针
29: iconst_2 //加载常量2
30: putfield #3 // Field finallyBlock:I 赋值操作,finallyBlock
33: aload_2 //加载Any异常实例
34: athrow //抛出
35: aload_0 //退出代码块
36: iconst_3
37: putfield #6 // Field methodExit:I
40: return
Exception table:
from to target type
0 5 13 Class java/lang/Exception
0 5 27 any
13 19 27 any
上面字节码每一行我都加了标记,我们可以清楚的看见finallyBlock的赋值操作一共有3处,分别是正常执行、catch捕获到匹配的异常以及catch没有捕获到匹配的异常或者catch代码块自己发生了异常,可以看出这里为了finally代码块能够执行,也是费尽心思。
这里也有个极端情况,也就是catch捕获器捕获到了一个异常,但是在执行catch代码块时发生了异常,那这时抛出的异常是哪一个呢,答案是后者,我们来看一下:
public void test() {
try {
tryBlock = 0;
int a = 10 / 0;
} catch (Exception e) {
catchBlock = 1;
int b = 100 / 0;
} finally {
finallyBlock = 2;
}
methodExit = 3;
}
上面代码明显有2出异常,我们来执行一下:
会发现它只会抛出catch代码块的异常,所以这种情况会对调试bug极为不利。
总结
现在来简单总结一下。
-
Throwable类分为2大类,又可以把异常分检查异常和非检查异常,检查异常必须显示处理;
-
异常捕获的代码实现和流程控制,字节码采用生成异常表来控制;
-
对于finally代码块,在字节码中会复制多份,分别放入不同的流程控制;为了能让finally代码块执行,要额外增加2个异常表,来监控catch匹配失败的异常和catch代码块的异常。
转载自:https://juejin.cn/post/7085152495957180424