Java虚拟机之Class文件(续):探索Java异常处理原理

之前通过《Java虚拟机之Class文件》成功解析了目标Class文件,了解了类文件结构,本篇是对之前的补充,内容涉及CONSTANT_NameAndType表以及Code属性表中的异常表。

特别是本人通过分析Code属性表中的字节码指令,对Java异常处理有了更深入的理解。

关于CONSTANT_NameAndType

先看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ClassFileDemo {
int a;
int b;
public void add01() {
a = a + 1;
}
public static void add02() {
}
public static void main(String[] args) {
add02();
}
}

这个例子提供了两个字段a、b,两个方法add01()、add02()。编译这段程序,然后用javap翻译字节码,输出带NameAndType的内容如下:

1
2
3
4
5
...
#19 = NameAndType #9:#10 // "<init>":()V
#20 = NameAndType #6:#7 // a:I
#21 = NameAndType #14:#10 // add02:()V
...

这段输出表明,b和add01()没有对应的CONSTANT_NameAndType表,仔细观察a与b以及add01()与add02()的区别,发现最大的不同在于,字段或者方法是否被调用过,比如a在add01()中被调用,add02()在main方法中被调用。我想这样做是为了便于解析与分派。

异常表实例分析

《深入》一书在第187页给出了一个演示异常表的例子,这里稍加修改,不吝笔墨的将代码贴出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ExceptionTableDemo01 {
public static void main(String[] args) {
System.out.println(inc(7, 8));
}
public static int inc(int a, int b) {
int y;
int x;
try {
x = 1;
return x;
} catch (Exception e) {
x = 2;
return x;
} finally {
x = 3;
}
}
}

这段代码输出为:

1
2
3
1
Process finished with exit code 0

相较于书中的原例,这里给方法inc添加了形参a、b和局部变量y,再就是将方法设为static。先用javap对Class文件进行反编译,贴出inc方法的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public static int inc(int, int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=7, args_size=2
0: iconst_1
1: istore_3
2: iload_3
3: istore 4
5: iconst_3
6: istore_3
7: iload 4
9: ireturn
10: astore 4
12: iconst_2
13: istore_3
14: iload_3
15: istore 5
17: iconst_3
18: istore_3
19: iload 5
21: ireturn
22: astore 6
24: iconst_3
25: istore_3
26: aload 6
28: athrow
Exception table:
from to target type
0 5 10 Class java/lang/Exception
0 5 22 any
10 17 22 any
22 24 22 any

首先解释最后出现的异常表:它的逻辑是,从from到to(不包括)执行的过程中,如果发生了type类型的异常,则PC计数器指针跳转到target行。

局部变量表假定用数组localVars表示,因为是静态方法所以没有this,localVars[0]和localVars[1]分别代表传入的参数a、b,localVars[2]代表y,localVars[3]代表x,此时已经确定了四个槽位。这里啰嗦一下:

  • iconst_1:这个命令首先将1推入操作数栈的栈顶,而不会先保存值到局部变量表;
  • istore_3:这个命令将操作数栈栈顶的int型数值存入局部变量表索引为3的位置,此时是一个出栈的过程,操作数栈不会再保留这个数值;
  • iload_3:这个命令将局部变量表索引为3的位置保存的int型数值复制一份到操作数栈的栈顶;

在字节码中第二行,“突然”就执行起iload_3命令,这只可能是因为程序执行到了return语句。这说明:代码中的return和字节码中的ireturn不是一一对应的,return语句会让编译器编译出多条指令。

下面通过本人所作的拙劣图进行说明:

01

从这张图似乎明白了finally语句“一定会执行”的原理:编译器不仅将try中的return编译成多条指令,还将finally编译的指令插在其中。具体来说就是:当执行到try块中的return语句后,会将原本load的值(不妨称之为temp)保存在局部变量表中(《深入》书中说会保存在最后一个本地变量表的slot,经本机测试并非如此,本机中保存在原位置后移的一位),等finally指令执行完后,载入temp到栈顶,最后ireturn。

写到这里,很容易产生两个疑问:

  1. 如果finally语句中存在return,会出现什么情况?
  2. 如果没有执行到try中的return语句就抛出了异常,会出现什么情况?

首先来分析第一个问题

在前文代码finally括号内添加一行return x;并运行代码,结果为3,返回的是finally里的值。前面已经分析过,finally语句块被编译后得到的指令会插入在try块里return被编译后得到的指令中,可是现在的情况是,finally语句块本身就会被编译出ireturn指令,而ireturn使用汇编编写,执行jmp跳转命令。也就是说程序在执行到这一步就直接跳转了。

所以,如果finally语句中存在return,执行该return,方法返回。

再分析第二个问题

将前文代码针对问题进行修改,修改后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ExceptionTableDemo01 {
public static void main(String[] args) {
System.out.println(inc(7, 8));
}
public static int inc(int a, int b) {
int y;
int x;
try {
x = 1;
if (x == 1) {
throw new RuntimeException();
}
return x;
} catch (NullPointerException e) {
x = 2;
return x;
} finally {
x = 3;
return x;
}
}
}

这段代码在try里抛出一个运行时异常,然后在catch尝试捕获空指针异常,在finally里将x赋值为3并返回之。

运行代码:

1
2
3
3
Process finished with exit code 0

也许此时又会产生疑问:运行时异常不是catch设定的能捕获的异常,异常应该继续抛出啊,为什么结果为3呢?

反编译Class文件,相关字节码行如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public static int inc(int, int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=7, args_size=2
0: iconst_1
1: istore_3
2: iload_3
3: iconst_1
4: if_icmpne 15
7: new #5 // class java/lang/RuntimeException
10: dup
11: invokespecial #6 // Method java/lang/RuntimeException."<init>":()V
14: athrow
15: iload_3
16: istore 4
18: iconst_3
19: istore_3
20: iload_3
21: ireturn
22: astore 4
24: iconst_2
25: istore_3
26: iload_3
27: istore 5
29: iconst_3
30: istore_3
31: iload_3
32: ireturn
33: astore 6
35: iconst_3
36: istore_3
37: iload_3
38: ireturn
Exception table:
from to target type
0 18 22 Class java/lang/NullPointerException
0 18 33 any
22 29 33 any
33 35 33 any

为了进行比较,这里还需要一个finally不含return的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ExceptionTableDemo01 {
public static void main(String[] args) {
System.out.println(inc(7, 8));
}
public static int inc(int a, int b) {
int y;
int x;
try {
throw new RuntimeException();
} finally {
x = 3;
}
}
}

运行程序输出为:

1
2
3
4
5
Exception in thread "main" java.lang.RuntimeException
at com.demo.ExceptionTableDemo01.inc(ExceptionTableDemo01.java:17)
at com.demo.ExceptionTableDemo01.main(ExceptionTableDemo01.java:7)
Process finished with exit code 1

反编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static int inc(int, int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=5, args_size=2
0: new #5 // class java/lang/RuntimeException
3: dup
4: invokespecial #6 // Method java/lang/RuntimeException."<init>":()V
7: athrow
8: astore 4
10: iconst_3
11: istore_3
12: aload 4
14: athrow
Exception table:
from to target type
0 10 8 any

要想明白执行流程必须弄懂字节码指令athrow。Oracle官网某章有这样一段描述:

The objectref must be of type reference and must refer to an object that is an instance of class Throwable or of a subclass of Throwable. It is popped from the operand stack. The objectref is then thrown by searching the current method (§2.6) for the first exception handler that matches the class of objectref, as given by the algorithm in §2.10.

所以当执行到这一指令时RuntimeException对象从操作数栈顶pop出,然后会被抛给通过§2.10算法在当前方法中查找到的第一个匹配该异常的异常处理的地方;如果当前方法找不到处理该异常的地方,那么该方法的调用就会突然完成并弹出当前的栈帧,继续向调用该栈帧的栈帧抛异常,如此向上传递。

匹配该异常的异常处理的地方就需要借助前文分析过的异常表了,这里以finally有返回值的ExceptionTableDemo01程序为例,它的异常表如下:

1
2
3
4
5
6
Exception table:
from to target type
0 18 22 Class java/lang/NullPointerException
0 18 33 any
22 29 33 any
33 35 33 any

第一个匹配的是0 18 33 any这一行,所以当虚拟机执行到athrow的时候,会跳转到target为33的地方执行,之后异常对象会astore,先行完成finally中的内容。

小结

对CONSTANT_NameAndType的困惑源自本人编写玩具JVM的实践,通过本篇小文解决了困惑;对Java的异常处理,通过本次实例分析,也有了更深理解。

不同的语言,对异常的处理,体现了不同的语言特性。虽然在本文中,本人不断追溯源码以求能够彻底明白相关原理,但目前仍然处在探索之中,因为在Java里,该语言特性是通过虚拟机和编译器共同完成的。“编译器如何做到将finally编译出来的指令正确插入到return编译出的指令中的”之类的问题,还需等待后续的研究。

参考

Jclasslib Bytecode Viewer:a tool that visualizes all aspects of compiled Java class files and the contained bytecode. In addition, it contains a library that enables developers to read and write Java class files and bytecode.

The Java Virtual Machine Instruction Set

Java Virtual Machine
Online Instruction Reference