前面介绍了方法的调用过程,简单的方法可以在我们自己写的 JVM 中运行起来了,但是方法的执行过程并不完整,因为方法可能会抛出异常,我们暂时还无法处理异常。本节依然通过示例来讲解异常的处理机制。进一步完善我们的 JVM。核心是 athrow 指令的实现
本节异常处理机制所用的示例
本节所讲解的异常处理机制是基于以下面的例子做说明的,有两个方法,(1)func 方法直接抛出了一个异常;(2)test 方法调用了 func 方法,同时试图用 try-catch 来捕获异常,但是其所捕获的异常并非 func 抛出的异常,也就是说 test 方法处理不了,最终交由 JVM 来处理,而 JVM 默认的处理方式则是打印虚拟机堆栈信息。
1 | public static void test() { |
test 方法的字节码:
1 | 0 bipush 100 |
直观的感受 athrow 命令
由上面的 func 方法,其方法体直接通过 throw 关键字抛出了一个异常,那么查看 func 方法对应的字节码:
1 | 0 new #62 <java/lang/IndexOutOfBoundsException> |
前面的三个指令是在创建 IndexOutOfBoundsException 异常的一个实例,并执行其 init 方法。
接下来就是一个 athrow 命令,将上面创建的异常抛出。这就是 athrow 指令出现的场景。现在只是只管的感受一下 athrow 指令,暂时先不去实现它。
异常处理表
func 方法内部抛出了异常,那么接下来需要另一个方法 test 来调用 func 方法。在 test 方法中,我们 catch 了一个异常 NumberFormatException,但是该异常的类型并不是 func 方法抛出的异常,因此是不会执行对应的 catch 块的。我们通过定义一个变量 i 来标示,如果执行了 catch 块,那么该变量 i 会变为 200,否则还是初值 100;
如果在方法中使用了 try-catch 语句,那么在该方法的 code 属性中就会有一个异常处理表。这里之所以叫表,是因为我们可能会 catch 多个异常,每 catch 一个异常,就会产生一条表项。
在上面的 test 方法中就有异常表,因为我们这里只有一个 catch,所以异常表只有一条表项
1 | { |
JVM 中定义的异常处理表项有四个字段:均为整数
- startPc
- endPc
- handlerPc
- catchType
其含义如下:
[startPc,endPc) 一个区间:锁定一个指令区间,该区间可能抛出某种异常。test 的异常表表示在字节码 3 到字节码 6(不包含 6)之间,可能会抛出异常,而指令 3 正是调用了抛出异常的 func 方法。
handlerPc:负责处理异常的 catch 块的其实位置,一旦发现某个 catch 可以处理该异常,那么就将 nextPc 改为 handlerPc。
catchType:指向运行时常量池的一个索引,通过该索引可以得到一个异常的类 xxxException 的对象,也就是我们在代码中显式 catch 的异常的类型。
解析异常处理表
和之前的常量池类似,异常处理表也是保存 class 文件中,我们在读取 class 文件的时候,一样要对其进行转换。
首先看一下其在 ClassFile 中的表现形式:
1 | public static class ExceptionTableEntry { |
其在 Zclass 中的表现:
1 | class ExceptionHandler { |
对比发现,具体的转换是将 catchType 运行时常量池的常量直接转换为对应的异常类的引用,其转换过程是在 ClassFile->Zclass 的转换过程中,具体对应到方法的转换过程。
1 | public Zclass(ClassFile classFile) { |
athorw 命令的实现(异常处理流程)
再次贴出 test 方法的代码
1 | public static void test() { |
test 对应的栈帧为 frame1,当其内部执行 func 方法时,会为其创建栈帧 frame2,在 frame2 中执行 func 方法,在 func 方法中,首先创建了一个异常——IndexOutOfBoundsException 的对象,该对象放在 frame2 的操作数栈顶。然后遇到了 athrow 命令,该指令将 frame2 操作数栈顶的异常对象 ex pop 出来,然后从当前 frame 开始遍历虚拟机栈。我们知道当前 JVM 的栈为 frame1 -> frame2。那么先从 frame2 开始,查找当前方法的异常处理表,当然我们一般在写代码的时候不会在手动写了 throw 异常之后,立刻在当前方法就去 catch 它,我们一般的做法都是在 func 方法的声明处显示的 throws 对应的异常,等待调用 func 的上层方法来出来,但是语法也是允许自己来 catch 的,所以先从 frame2 查找,查找不到,那么将 frame2 从虚拟机栈弹出,然后再从当前栈顶的 frame1 中查找是否可以找到异常处理项,就这样一直遍历下去。
这样的遍历会产生两个结果:
- 在某个 frame 中找到了可以处理该异常的项,那么首先到该 frame 的操作数栈清空,然后把异常对象压入当前操作数栈,接着执行该 frame 对应的 catch 块语句。
- 遍历了整个虚拟机栈都没有找到可以处理的异常项,因此交由 JVM 来处理,而 JVM 的处理方式就是打印虚拟机栈信息(从产生异常的方法 func 对应的 frame,依次到 main 方法对应的 frame)
athrow 指令的处理流程:
1 | Zobject ex = frame.getOperandStack().popRef(); |
上面多次提到了在当前 frame 寻找对应的异常处理项,如何寻找,这就用到了 findAndGotoExceptionHandler 方法,具体流程如下:
1 | //从当前帧开始,遍历 Java 虚拟机栈,查找方法的异常处理表 |
超找异常处理项,最终是在 Method 的 ExceptionTable 对象中
1 | //返回能解决当前 Exception 的 handler=>多个 catch 块,决定用哪个 |
上面能找到对应的异常处理项,那么整个程序还可以正常运行,但如果没有找到的话,就要交给 JVM 来处理的,此时也意味着当前的虚拟机栈已经被清空了,解释器也停止执行了,JVM 将整个调用链打印出来,程序结束。
Java 虚拟机栈信息
1 | public class NStackTraceElement { |
NStackTraceElement 代表每个 frame 中调用抛出异常方法的具体消息。
上面实现了 ATHROW 命令,但是目前程序还是无法运行,因为异常的父类 Exception 的构造方法中会调用一个 native 的方法——fillInStackTrace,因此还要在 native 方法中注册 fillInStackTrace 方法,同时在该方法中进行虚拟机调用栈的收集工作。
具体的代码请参考Nthrowable.java,注释已经写的很清楚,相信不会有太大的阅读困难。
异常的测试用例
同样,这里提供了一个单独的测试用例来测试异常,我们的 test 方法并没有不会 func 方法抛出的异常,这一点从最终打印 test 的本地变量表就可以发现,i 的值为 100,没有走到 catch 语句中。同时程序也打印出了虚拟机堆栈信息,具体打印信息如下:
1 | the same as testInterpreter06! |