likes
comments
collection
share

JVM是如何处理异常的

作者站长头像
站长
· 阅读数 27

前言

说起异常,作为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,是运行时异常,表示程序虽然出错了,但是还可以抢救,比如数组越界了,我们可以给捕获住,再继续执行。

JVM是如何处理异常的

检查异常和非检查异常

在对异常进行分类时,其中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代码块的字节码复制到正常和异常分支

示意图如下:

JVM是如何处理异常的

第一次看到这个是不是有种看不太明白的感觉,这里的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出异常,我们来执行一下:

JVM是如何处理异常的

会发现它只会抛出catch代码块的异常,所以这种情况会对调试bug极为不利。

总结

现在来简单总结一下。

  • Throwable类分为2大类,又可以把异常分检查异常和非检查异常,检查异常必须显示处理;

  • 异常捕获的代码实现和流程控制,字节码采用生成异常表来控制;

  • 对于finally代码块,在字节码中会复制多份,分别放入不同的流程控制;为了能让finally代码块执行,要额外增加2个异常表,来监控catch匹配失败的异常和catch代码块的异常。

转载自:https://juejin.cn/post/7085152495957180424
评论
请登录