zoukankan      html  css  js  c++  java
  • JVM 异常处理原理

    JVM 异常处理原理

    异常相关概念

    Java异常抛出类型有两种:

    1. 显式抛出:抛出异常的主体是应用程序,它指的是程序在代码中使用 throw 关键字进行异常抛出。
    2. 隐式抛出:抛出异常的主体是JVM,它指的是程序在执行过程出现无法执行的异常状态,由JVM自动抛出相关异常。比如数组越界。

    捕获异常相关的三种代码块:

    1. try代码块:用来标记需要进行异常监控的代码。
    2. catch代码块:跟在try代码块之后,用来捕获在try代码块中触发的某种指定类型的异常。除了声明所捕获异常的类型之外,catch代码块还定义了针对该异常类型的异常处理器。在Java中,try代码块后面可以跟着多个catch代码块,来捕获不同类型的异常。JVM会从上至下匹配异常处理器。因此,前面的catch代码块所捕获的异常类型不能覆盖后边的,否则编译器会报错。
    3. finally代码块:跟在try代码块和catch代码块之后,用来声明一段必定运行的代码,无论try块还是catch出现异常,它都会执行。它的设计初衷是为了避免跳过某些关键的清理代码,例如关闭已打开的系统资源。

    在程序正常执行的情况下,这段代码会在try代码块之后运行。否则,也就是try代码块触发异常的情况下,如果该异常没有被捕获,finally代码块会直接运行,并且在运行之后重新抛出该异常。如果该异常被catch代码块捕获,finally代码块则在catch代码块之后运行。在某些不幸的情况下,catch代码块也触发了异常,那么finally代码块同样会运行,并会抛出catch代码块触发的异常。在某些极端不幸的情况下,finally代码块也触发了异常,那么只好中断当前finally代码块的执行,并往外抛异常。

    finally块只有一种情况不会被执行,那么就是执行了System.exit(0);后就不会执行finally块了,还是直接退出了。

    异常体系

    下面是异常的派生图:

    image-20210815155835331

    在Java语言规范中,所有异常都是Throwable类或者其子类的实例。Throwable有两大直接子类。第一个是Error,涵盖程序不应捕获的异常。当程序触发Error时,它的执行状态已经无法恢复,需要中止线程甚至是中止虚拟机。第二子类则是Exception,涵盖程序可能需要捕获并且处理的异常。

    RuntimeException和Error属于Java里的非检查异常(unchecked exception)。其他异常则属于检查异常(checked exception)。在Java语法中,所有的检查异常都需要程序显式地捕获,或者在方法声明中用throws关键字标注。通常情况下,程序中自定义的异常应为检查异常,以便最大化利用Java编译器的编译时检查。

    异常实例的构造十分昂贵。这是由于在构造异常实例时,Java虚拟机便需要生成该异常的栈轨迹(stack trace)。该操作会逐一访问当前线程的Java栈帧,并且记录下各种调试信息,包括栈帧所指向方法的名字,方法所在的类名、文件名,以及在代码中的第几行触发该异常。当然,在生成栈轨迹时,Java虚拟机会忽略掉异常构造器以及填充栈帧的Java方法(Throwable.fillInStackTrace),直接从新建异常位置开始算起。此外,Java虚拟机还会忽略标记为不可见的Java方法栈帧。

    既然异常实例的构造十分昂贵,我们是否可以缓存异常实例,在需要用到的时候直接抛出呢?从语法角度上来看,这是允许的。然而,该异常对应的栈轨迹并非throw语句的位置,而是新建异常的位置。因此,这种做法可能会误导开发人员,使其定位到错误的位置。这也是为什么在实践中,往往选择抛出新建异常实例的原因。

    异常处理的原理

    在编译生成的字节码中,每个方法都附带一个异常表。异常表中的每一个条目代表一个异常处理器,并且由from指针、to指针、target指针以及所捕获的异常类型构成。这些指针的值是字节码索引(bytecode index,bci),用以定位字节码。其中,from指针和to指针标示了该异常处理器所监控的范围,例如try代码块所覆盖的范围。target指针则指向异常处理器的起始位置,例如catch代码块的起始位置。

    为理解上面这段话,举一个例子,看下面一段代码:

    public class Foo {
      public static void main(String []args){
        try {
          int x = 10 / 0;
        } catch (Exception e) {
          e.printStackTrace();
        }
      }
    }
    

    编译并查看字节码:

    javac Foo.java
    javap -c Foo
    

    字节码内容如下:

      public static void main(java.lang.String[]);
        Code:
           0: bipush        10
           2: iconst_0
           3: idiv
           4: istore_1
           5: goto          13
           8: astore_1
           9: aload_1
          10: invokevirtual #3                  // Method java/lang/Exception.printStackTrace:()V
          13: return
        Exception table:
           from    to  target type
               0     5     8   Class java/lang/Exception
    

    编译过后,该方法的异常表拥有一个条目。其from指针和to指针分别为0和5,代表它的监控范围从索引为0的字节码开始,到索引为5的字节码结束(不包括5)。该条目的target指针是8,代表这个异常处理器从索引为8的字节码开始。条目的最后一列,代表该异常处理器所捕获的异常类型正是Exception。可以不用看懂这段字节码的含义,如果想了解常用的字节码请查看 Java 字节码含义

    当程序触发异常时,JVM会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,JVM会判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,JVM会将控制流转移至该条目target指针指向的字节码。如果遍历完所有异常表条目,JVM仍未匹配到异常处理器,那么它会弹出当前方法对应的Java栈帧,并且在调用者(caller)中重复上述操作。在最坏情况下,JVM需要遍历当前线程Java栈上所有方法的异常表。

    finally代码块的编译比较复杂。当前版本Java编译器的做法,是复制finally代码块的内容,分别放在try-catch代码块所有正常执行路径以及异常执行路径的出口中。如下图:

    image

    从上图可以很容易看出来,变种1就是将finally块的代码复制到try块和catch后面,然后再加一个抛出异常的finally块,try块如果出现catch块无法捕获的异常就只能直接执行finally块,同时还有catch块出现异常就只能直接执行finally块。变种2同理,比较容易看懂。

    为了深入理解JVM的如何还执行的,可以从下面的代码进行反编译成字节码进行阅读:

    public class Foo {
      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;
      }
    }
    

    执行以下编译与反编译代码:

    javac Foo.java
    javap -c Foo
    

    字节码如下:

    public class Foo {
      public Foo();
        Code:
           0: aload_0
           1: invokespecial #1                  // Method java/lang/Object."<init>":()V
           4: return
    
      public void test();
        Code:
           0: aload_0
           1: iconst_0
           2: putfield      #2                  // Field tryBlock:I
           5: aload_0
           6: iconst_2
           7: putfield      #3                  // Field finallyBlock:I
          10: goto          35
          13: astore_1
          14: aload_0
          15: iconst_1
          16: putfield      #5                  // Field catchBlock:I
          19: aload_0
          20: iconst_2
          21: putfield      #3                  // Field finallyBlock:I
          24: goto          35
          27: astore_2
          28: aload_0
          29: iconst_2
          30: putfield      #3                  // Field finallyBlock:I
          33: aload_2
          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
    }
    

    只需要看test方法就可以。上面的字节码还是比较好容易理解的。如果对字节码不熟悉的,可以查看 Java 字节码含义。这里只解释几个,不容易看出来的,上面出现了多个aload_0,这个字节码是加载局部变量表的第一个数据到操作数栈,而对于非静态方法来说,局部变量表中第一个数据一定是this指针。因为每个实例需要进行赋值操作时,都有this指针。其他的都字节码都非常好理解,就不解释了,看不懂的可以去看看上面链接。

    需要注意的是,如果catch代码块捕获了异常,并且触发了另一个异常,那么finally捕获并且重抛的异常就是catch块里的异常,这里特别注意,因为try块异常就已经被抛弃了,在排查问题时需要特别注意,同时养成一个好习惯,catch到异常时先打出日志栈。

    Suppressed异常以及语法糖

    Java 7引入了Suppressed异常允许将一个异常附于另一个异常之上。Suppressed异常用来保存被屏蔽的异常,可通过Throwable.getSuppressed() 获得。添加的话用addSuppressed(Throwable exception)。因此,抛出的异常可以附带多个异常的信息。然而,Java层面的finally代码块缺少指向所捕获异常的引用,所以这个新特性使用起来非常繁琐。为此,Java 7专门构造了一个名为try-with-resources的语法糖,在字节码层面自动使用Suppressed异常。当然,该语法糖的主要目的并不是使用Suppressed异常,而是精简资源打开关闭的用法。

    在Java 7之前,对于打开的资源,需要定义一个finally代码块,来确保该资源在正常或者异常执行状况下都能关闭。资源的关闭操作本身容易触发异常。因此,如果同时打开多个资源,那么每一个资源都要对应一个独立的try-finally代码块,以保证每个资源都能够关闭。这样一来,代码将会变得十分繁琐。

    比如如下代码:

    FileInputStream in0 = null;
      FileInputStream in1 = null;
      FileInputStream in2 = null;
      ...
      try {
        in0 = new FileInputStream(new File("in0.txt"));
        ...
        try {
          in1 = new FileInputStream(new File("in1.txt"));
          ...
          try {
            in2 = new FileInputStream(new File("in2.txt"));
            ...
          } finally {
            if (in2 != null) in2.close();
          }
        } finally {
          if (in1 != null) in1.close();
        }
      } finally {
        if (in0 != null) in0.close();
      }
    

    Java 7的try-with-resources语法糖,极大地简化了上述代码。程序可以在try关键字后声明并实例化实现了AutoCloseable接口的类,编译器将自动添加对应的close()操作。在声明多个AutoCloseable实例的情况下,编译生成的字节码类似于上面手工编写代码的编译结果。与手工代码相比,try-with-resources还会使用Suppressed异常的功能,来避免原异常“被消失”。所以如果try语句和释放资源都出现了异常,那么最终抛出的异常是try语句中出现的异常,在释放资源时出现的异常会作为被抑制的异常添加进去,即通过Throwable.addSuppressed方法来实现。

    public class Foo implements AutoCloseable {
      private final String name;
      public Foo(String name) { this.name = name; }
      @Override
      public void close() {
        throw new RuntimeException(name);
      }
      public static void main(String[] args) {
        try (Foo foo0 = new Foo("Foo0"); // try-with-resources
             Foo foo1 = new Foo("Foo1");
             Foo foo2 = new Foo("Foo2")) {
          throw new RuntimeException("Initial");
        }
      }
    }
    // 运行结果:
    Exception in thread "main" java.lang.RuntimeException: Initial
            at Foo.main(Foo.java:18)
            Suppressed: java.lang.RuntimeException: Foo2
                    at Foo.close(Foo.java:13)
                    at Foo.main(Foo.java:19)
            Suppressed: java.lang.RuntimeException: Foo1
                    at Foo.close(Foo.java:13)
                    at Foo.main(Foo.java:19)
            Suppressed: java.lang.RuntimeException: Foo0
                    at Foo.close(Foo.java:13)
                    at Foo.main(Foo.java:19)
    

    除了try-with-resources语法糖之外,Java 7还支持在同一catch代码块中捕获多种异常。实际实现非常简单,生成多个异常表条目即可。

    // 在同一catch代码块中捕获多种异常
    try {
      ...
    } catch (SomeException | OtherException e) {
      ...
    }
    

    异常返回值

    最后一个问题就是关于异常返回值的问题,如果try块、catch块和finally块都有返回值,那么返回结果是什么呢。

    public class Foo {
      public static int test() {
        try {
          return 1;
        } catch(Exception e) {
          return 2;
        } finally {
          return 3;
        }
      }
      public static void main(String[] args) {
        System.out.println(test());
      }
    }
    
    

    先不运行结果,先想想这个结果会是几呢?

    没错答案就是3。因为前面讲过了,finally块会被复制到try块和catch块后面的,所以这个值才是最后的返回结果。

    所以总结一句话,如果finally块中有返回语句,一定是以finally块中的为准。如果finally块中没有返回语句,那么JVM会暂存try块和catch块中的返回值,然后再执行finally块,再返回刚才暂存的返回值。但是需要的注意的是,如果finally块中有返回语句,并不是try块和catch块中的返回值就没有用,因为如果try块和cacth块中的返回值对值进行了修改,那么还是有效果。看下面这个例子。

    public class Foo {
      public static int test() {
        int a = 0;
        try {
          int b = 10 / 0;
          return a = a + 1;
        } catch(Exception e) {
          return a = a + 2;
        } finally {
          return a + 3;
        }
      }
    
      public static void main(String[] args) {
        System.out.println(test());
      }
    }
    
    

    有了上面的解释这个结果应该是显而易见了吧,那就是5。

    因为首先第5行会发生异常,那么会执行catch块的代码,执行第8行,此时a变成了2,然后最后执行finally块代码,再加3,所以最后返回值为5。

  • 相关阅读:
    C# lock
    read appSettings in configuration file by XElement with xmlns
    get all sites under IIS
    Cross-site scripting(XSS)
    Do not throw System.Exception, System.SystemException, System.NullReferenceException, or System.IndexOutOfRangeException intentionally from your own source code
    C++ 算法
    C++ 数据结构概念
    C++ STL 常用算术和生成算法
    C++ STL 常用拷贝和替换算法
    C++ STL 常用排序算法
  • 原文地址:https://www.cnblogs.com/dwtfukgv/p/15144321.html
Copyright © 2011-2022 走看看