4.6虚拟机如何处理异常
目录介绍
- 00.该文总纲的阅览
- 01.JVM处理异常
- 02.捕获异常涉及三块
- 03.虚拟机如何捕获异常
- 04.看一个实践案例
- 05.Suppressed异常以及语法糖
- 06.异常栈轨迹说明
00.该文总纲的阅览
- 异常实例的构造十分昂贵
- 由于在构造异常实例时,JVM 需要生成该异常的栈轨迹,该操作逐一访问当前线程的 Java 栈桢,并且记录下各种调试信息,包括栈桢所指向方法的名字、方法所在的类名以及方法在源代码中的位置等信息。
- JVM 捕获异常需要异常表
- 每个方法都有一个异常表,异常表中的每一个条目都代表一个异常处理器,并且由 from、to、target 指针及其异常类型所构成。form-to 其实就是 try 块,而 target 就是 catch 的起始位置。
- 当程序触发异常时,JVM 会检测触发异常的字节码的索引值落到哪个异常表的 from-to 范围内,然后再判断异常类型是否匹配,匹配就开始执行 target 处字节码处理该异常。
- 最后是finally代码块的编译
- finally 代码块一定会运行的(除非虚拟机退出了)。那么它是如何实现的呢?其实是一个比较笨的办法,当前 JVM 的做法是,复制 finally 代码块的内容,分别放在所有可能的执行路径的出口中。
01.JVM处理异常
1.1 JVM默认是如何处理异常的
- main函数收到这个问题时,有两种处理方式:
- a:自己将该问题处理,然后继续运行
- b:自己没有针对的处理方式,只有交给调用main的jvm来处理
- jvm有一个默认的异常处理机制,就将该异常进行处理.
- 并将该异常的名称,异常的信息.异常出现的位置打印在了控制台上,同时将程序停止运行
1.2 异常分类
- RuntimeException和Error属于Java里的非检查异常(unchecked exception)。其他异常则属于检查异常(checked exception)。
- 在Java语法中,所有的检查异常都需要程序员先显式地捕获,或者在方法声明中用throws关键字标注。通常情况下,程序中自定义的异常因为检查异常,以便最大化利用Java编译器的编译时检查。
1.3 异常构造昂贵
- 这是由于在构造异常实例时,Java虚拟机便需要生成该异常的栈轨迹(stack trace)。
- 该操作会逐一访问当前线程的Java栈帧,并且记录下各种调试信息,包括栈帧所指向的方法的名字,方法所在的类名、文件名,以及在代码中的第几行触发该异常。
- 当然,在生成栈轨迹时,Java虚拟机会忽略掉异常构造器以及填充栈帧的Java方法(Throwable.fillInStackTrace),直接从新建异常位置开始算起。此外,Java虚拟机还会忽略标记为不可见的Java方法栈帧。
- 既然异常实例的构造十分昂贵,我们是否可以缓存异常实例,在需要用到的时候直接抛出呢?
- 从语法角度上来看,这是允许的。然而,该异常对应的栈轨迹并非throw语句的位置,而是新建异常的位置。因此,这种做法可能会误导开发人员,使其定位到错误的位置。这也是为什么在实践中,我们往往选择抛出新建异常实例的原因。
02.捕获异常涉及三块
2.1 捕获异常三块
- 捕获异常这涉及了如下三种代码块:
- try代码块:用来标记需要进行异常监控的代码。
- catch代码块:跟在try代码块之后,用来捕获在try代码块中触发的某种指定类型的异常。除了声明所捕获异常的类型之外,catch代码块还定义了针对该异常类型的异常处理器。在Java中,try代码块后面可以跟着多个catch代码块,来捕获不同类型的异常。Java虚拟机会从上至下匹配异常处理器。因此,前面的catch代码块所捕获的异常类型不能覆盖后边的,否则编译器会报错。
- finally代码块:跟在try代码块和catch代码块之后,用来声明一段必定运行的代码。它的设计初衷是为了避免跳过某些关键的清理代码,例如关闭已打开的系统资源。
2.2 代码块异常分析
- 在程序正常执行的情况下,这段代码会在try代码块之后运行。否则,也就是try代码块触发异常的情况下,如果该异常没有被捕获,finally代码块会直接执行,并且在运行之后重新抛出该异常。
- 如果该异常被catch代码块捕获,finally代码块则在catch代码块之后运行。在某些不幸的情况下,catch代码同样也触发了异常,那么finally代码块同样会运行,并会抛出catch代码块触发的异常。在某些极端不幸的情况下,finally代码块也触发了异常,那么只好中断当前finally代码块的执行,并往外抛异常。
03.虚拟机如何捕获异常
- 在编译生成的字节码中,每个方法都附带一个异常表。
- 异常表中的每一个条目都代表一个异常处理器,并且由from指令、to指令、target指令以及所捕获的异常类型构成。这些指针的值是字节码索引(bytecode index, bci),用以定位字节码。
- 其中,from指针和to指针标示了该异常处理器所监控的范围,例如try代码所覆盖的范围。target指针则指向异常处理器的起始位置。
- 看一下案例代码如下所示
public static void main(String[] args){ try{ mayThrowException(); } catch(Exception e){ e.printStackTrace(); } } //对应的Java字节码 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 Class java/lang/Exception //异常表条目
- 编译过后,该方法的异常表拥有一个条目。其from指针和to指针分别为0和3,代表它的监控范围从索引为0的字节码开始,到索引为3的字节码结束(不包括3)。该条目的target指针是6,代表这个异常处理器从索引6的字节码开始。条目的最后一列,代表该异常处理器所捕获的异常类型正是Exception。
- 当程序触发异常是,Java虚拟机会从上至下遍历异常表中的所有条目。
- 当触发异常的字节码的索引值在某个异常表监控范围内,Java虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,Java虚拟机会将控制流转移至该条目target指针指向的字节码。
- 如果遍历完所有异常表条目,Java虚拟机仍未匹配到异常处理器,那么它会弹出当前方法对应的Java栈帧,并且在调用者(caller)中重复上述操作。在最坏情况下,Java虚拟机需要遍历当前线程Java栈上所有方法的异常表。
- finally代码块的编译比较复杂。
- 当前版本Java编译器的做法,是复制finally代码块的内容,分别放在try-catch代码块所有正常执行路径以及异常执行路径的出口中。
04.看一个实践案例
- 针对异常执行路径,Java编译器会生成一个或多个异常表条目,监控整个try-catch代码块,并且捕获所有种类的异常(在javap中以any指代)。
- 这些异常表条目的target指针将指向另一份复制的finally代码块。并且,在这个finally代码块的最后,Java编译器会重新抛出所捕获的异常。
image
- 我们可以使用javap工具来查看下面这段包含了try-catch-finally代码块的编译结果。
- 为了更好地区分每个代码块,我们定义了四个实例字段:tryBlock、catchBlock、finallyBlock以及methodExit,并且仅在对应的代码块中访问这些字段。
- 测试案例代码
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 Compiled from "Foo.java" 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 }
- 可以看到,便以结果包含三份finally代码块。
- 其中,前两份分别位于try代码块和catch代码块的正常执行路径出口。最后一份则作为异常处理器,监控try代码块以及catch代码块。它将捕获try代码块触发的、未被catch代码块捕获的异常,以及catch代码块触发的异常。
- 如果catch代码块捕获了异常,并且触发了另一个异常,那么finally捕获并且重拋的异常是哪个呢?答案是后者。也就是说原本的异常便会被忽略掉,这对于代码调试来说十分不利。
05.Supressed异常以及语法糖
- Java 7 引入了Supressed异常来解决这个问题。这个新特性允许开发人员将一个异常附于另一个异常之上。因此,抛出的异常可以附带多个异常信息。
- 然而,Java层面的finally代码块缺少指向所捕获异常的引用,所以这个新特性使用起来十分繁琐。
- 为此,Java 7 专门构造了一个名为try-with-resources的语法糖,在字节码层面自动使用Supressed异常。当然,该语法糖的主要目的并不是使用Supressed异常,而是精简资源打开关闭的用法。
- 在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还会使用Supressed异常的功能,来避免原异常“被消失”。
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"); } } }
- 程序可以在try关键字后声明并实例化实现了AutoCloseable接口的类,编译器将自动添加对应的close()操作。在声明多个AutoCloseable 实例的情况下,编译生成的字节码类似于上面手工编写代码的编译结果。与手工代码相比,try-with-resources还会使用Supressed异常的功能,来避免原异常“被消失”。
- 运行结果如下:
javac Foo.java java Foo Exception in thread "main" java.lang.RuntimeException: Initial at Foo.main(Foo.java:16) Suppressed: java.lang.RuntimeException: Foo2 at Foo.close(Foo.java:9) at Foo.main(Foo.java:17) Suppressed: java.lang.RuntimeException: Foo1 at Foo.close(Foo.java:9) at Foo.main(Foo.java:17) Suppressed: java.lang.RuntimeException: Foo0 at Foo.close(Foo.java:9) at Foo.main(Foo.java:17)
- 除了 try-with-resources语法糖之外,Java 7 还支持在同一catch代码块中捕获多种异常。实际实现非常简单,生成多个异常表条目即可。
//在同一 catch 代码块中捕获多种异常 try { ... } catch (SomeException | OtherException e) { ... }
- 编写Java源代码
public class Foo{ private int tryBlock; private int catchBlock; private int finallyBlock; private int methodExit; public void test(){ for (int i = 0; i < 100; i++){ try{ tryBlock = 0; if(i < 50){ continue; }else if(i < 80){ break; }else { return; } } catch(Exception e){ catchBlock = 1; } finally{ finallyBlock = 2; } } methodExit = 3; } }
- 编译并查看其字节码
javac Foo.java javap -c Foo Compiled from "Foo.java" 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: iconst_0 1: istore_1 2: iload_1 3: bipush 100 5: if_icmpge 75 8: aload_0 9: iconst_0 10: putfield #2 // Field tryBlock:I 13: iload_1 14: bipush 50 16: if_icmpge 27 19: aload_0 20: iconst_2 21: putfield #3 // Field finallyBlock:I 24: goto 69 27: iload_1 28: bipush 80 30: if_icmpge 41 33: aload_0 34: iconst_2 35: putfield #3 // Field finallyBlock:I 38: goto 75 41: aload_0 42: iconst_2 43: putfield #3 // Field finallyBlock:I 46: return 47: astore_2 48: aload_0 49: iconst_1 50: putfield #5 // Field catchBlock:I 53: aload_0 54: iconst_2 55: putfield #3 // Field finallyBlock:I 58: goto 69 61: astore_3 62: aload_0 63: iconst_2 64: putfield #3 // Field finallyBlock:I 67: aload_3 68: athrow 69: iinc 1, 1 72: goto 2 75: aload_0 76: iconst_3 77: putfield #6 // Field methodExit:I 80: return Exception table: from to target type 8 19 47 Class java/lang/Exception 27 33 47 Class java/lang/Exception 8 19 61 any 27 33 61 any 47 53 61 any }
- 得出结论
- 由此可见,finally代码块被拷贝到了if语句的每个分支之后(如果分支中有return语句,则在该语句之前)
06.异常栈轨迹说明
6.1 看崩溃信息
- 首先看一个异常崩溃信息
java.lang.RuntimeException: Unable to destroy activity {com.didi.global.rider/com.didi.rider.business.main.RiderMainActivity}: java.lang.NullPointerException: Attempt to invoke virtual method 'com.google.android.libraries.navigation.internal.eg.f com.google.android.libraries.navigation.internal.eg.h.n()' on a null object reference at android.app.ActivityThread.performDestroyActivity(ActivityThread.java:4491) at android.app.ActivityThread.handleDestroyActivity(ActivityThread.java:4509) at android.app.servertransaction.DestroyActivityItem.execute(DestroyActivityItem.java:39) at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:145) at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:70) at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1836) at android.os.Handler.dispatchMessage(Handler.java:106) at android.os.Looper.loop(Looper.java:193) at android.app.ActivityThread.main(ActivityThread.java:6704) at java.lang.reflect.Method.invoke(Native Method) at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:493) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:916)
6.2 栈轨迹如何形成
- 构造异常实例时,Java虚拟机便需要生成该异常的栈轨迹(stack trace)。
- 该操作会逐一访问当前线程的Java栈帧,并且记录下各种调试信息,包括栈帧所指向的方法的名字,方法所在的类名、文件名,以及在代码中的第几行触发该异常。