大家熟悉方法表的Code属性后,知道了Code属性里把字节码指令描述完后,是这个方法的显示异常处理表(下文简称异常表)集合(exception_table[]
),异常表对于Code属性来说并不是必须存在的,在Java字节码结构剖析四:属性表里的举例代码里就没有异常表生成,其中Code属性里的exception_table_length
为0。
认识异常表
异常表的结构如下:它包含4个字段,这些字段的含义为:如果当字节码在第start_pc
行到第end_pc
行之间(不包含end_pc
行)出现了类型为catch_type
或者其子类的异常(catch_type
为指向一个CONSTANT_Class_info
型常量的索引),则转到第handler_pc
行继续处理。当catch_type
的值为0时,代表任意异常情况都需要转向到handler_pc
处进行处理。
exception_table {
u2 start_pc;
u2 end_pc;
u2 handler_pc;
u2 catch_type;
}
我们来用一段代码来解释,如下所示,一个try-catch-finally
的代码,我们从字节码当中去看看jvm是如何使用异常表的。在阅读字节码之前,大家不妨先看看下面的源码,想一下这段代码的返回值在出现异常和不出现异常的情况分别是多少?
public int inc() {
int x;
try {
x = 1;
return x;
} catch(Exception e) {
x = 2;
return x;
} finally {
x = 3;
}
}
给出上面Java源码编译后的字节码及异常表信息:(大家通过jclasslib bytecode viewer,可以很轻松查看其字节码信息)
Code:
Stack=1, Locals=5, Args_size=1
0 iconst_1
1 istore_1
2 iload_1
3 istore_2
4 iconst_3
5 istore_1
6 iload_2
7 ireturn
8 astore_2
9 iconst_2
10 istore_1
11 iload_1
12 istore_3
13 iconst_3
14 istore_1
15 iload_3
16 ireturn
17 astore 4
19 iconst_3
20 istore_1
21 aload 4
23 athrow
Exception table:
Nr. | Start Pc | End Pc | Handler Pc | Catch Type |
---|---|---|---|---|
0 | 0 | 4 | 8 | cp info #2 <java/lang/Exception> |
1 | 0 | 4 | 17 | cp info #0 < any> |
2 | 8 | 13 | 17 | cp info #0 < any> |
3 | 17 | 19 | 17 | cp info #0 < any> |
通过异常表,我们知道,编译器为这段Java代码生成了4条异常表记录,对应4条可能出现的代码执行路径。我们看前3条记录,这3条是能和我们的代码对应上的,分别对应下面的3种情况为:
- 如果
try
语句块中出现属于Exception
或其子类的异常,则转到catch
语句块处理。 - 如果
try
语句块中出现不属于Exception
或其子类的异常,则转到finally
语句块处理。 - 如果
catch
语句块中出现任何异常,则转到finally
语句块处理。
第4条记录的意思是,从17行指令到19行指令(不包括19行指令)之间出任何异常,会重新跳到17行指令开始执行。这好像进入了一个死循环。其实,第4行记录,我觉得是不会发生的特殊情况,我不太懂为什么生成Class文件时会给我们在异常表中记录这一情况!所以,就先暂时抛开它,我们去看前3条记录。
从字节码层面分析异常处理
返回到我们上面的问题,这段代码的返回值应该是多少?熟悉Java的读者应该很容易说出答案:如果没有异常,返回值是1;如果出现Exception
异常,返回值是2;如果出现了Exception
以外的异常,方法非正常退出,没有返回值。我们一起来分析一下字节码的执行过程(结合异常表),从字节码的层面上看看为何会有这样的返回结果。
字节码中0~ 3行所做的操作就是将整数1赋值给变量x,并且将此时x的值赋值一份副本到本地变量表索引为2的位置处。如果这过程没有出现异常,则继续往后执行4~ 5行(即finally语句块的代码),将变量x重新赋值为3。最后执行6~ 7行,把之前存在局部变量表2位置处的int型常量1又推送到栈顶,然后ireturn
指令把栈顶元素弹出,即方法返回int型常量1,方法结束。这是正常流程。
如果0~ 3行出现异常,且属于Exception
或其子类的异常,那么根据异常表第1条记录,JVM就知道如何处理。此时,PC寄存器指针转到第8行(即catch
语句块的代码),8~ 16行所做的事情,先将2赋值给变量x,再把常量2保存在局部变量表中,接着重新把常量3赋值给变量x,最后从局部变量中从新获取常量2推送至栈顶,最后ireturn
指令把栈顶元素弹出,即方法返回int型常量2,方法结束。
如果0~ 3行出现异常,且不属于Exception
或其子类的异常,那么根据异常表第2条记录,就会转到17行(即直接进入到finally
语句块),就会执行17~ 23行的指令。
如果8~ 12行出现异常,出现任何异常,那么根据异常表第3条记录,也会转到17行(即直接进入到finally
语句块),就会执行17~ 23行的指令。
于是,我重新给上面的字节码做了注释,方便大家阅读理解。
0 iconst_1 // 将一个int型常量1,推送栈顶
1 istore_1 // 将1个int类型数据保存到局部变量表的索引为1的位置中
2 iload_1 // 将存放在局部变量索引为 1 的位置的int型数据推送至栈顶
3 istore_2 // 将1个int类型数据保存到局部变量表的索引为 2 的位置中
// 4-5行的指令,其实就是对应finall语句块的Java代码
4 iconst_3 // 将一个int型常量3,推送栈顶
5 istore_1 // 将1个int类型数据保存到局部变量表的索引为 1 的位置中
6 iload_2 // 将存放在局部变量索引为 2 的位置的int型数据推送至栈顶
7 ireturn // 结束方法,并返回一个int类型数据
8 astore_2 // 将一个reference类型数据保存到局部变量表的索引为 2 的位置中
9 iconst_2 // 将一个int型常量2,推送栈顶
10 istore_1 // 将1个int类型数据保存到局部变量表的索引为 1 的位置中
11 iload_1 // 将存放在局部变量索引为 1 的位置的int型数据推送至栈顶
12 istore_3 // 将1个int类型数据保存到局部变量表的索引为 3 的位置中
// 13-14行的指令,其实就是对应finall语句块的Java代码
13 iconst_3 // 将一个int型常量3,推送栈顶
14 istore_1 // 将1个int类型数据保存到局部变量表的索引为 1 的位置中
15 iload_3 // 将存放在局部变量索引为 3 的位置的int型数据推送至栈顶
16 ireturn // 结束方法,并返回一个int类型数据
17 astore 4 // 将一个reference类型数据保存到局部变量表的索引为 4 的位置中
// 19-20行的指令,其实就是对应finall语句块的Java代码
19 iconst_3 // 将一个int型常量3,推送栈顶
20 istore_1 // 将1个int类型数据保存到局部变量表的索引为 1 的位置中
21 aload 4 // 将存放在局部变量索引为 4 的位置的reference型数据推送至栈顶
23 athrow // 抛出一个异常实例(exception 或者 error)
小结一下
从字节码层面分析了虚拟机在处理异常流程的过程,我们可以看出以下几点内容:
- 异常表实际上是Java代码的一部分,编译器使用异常表而不是简单的跳转命令来实现Java异常及
finally
处理机制。(注:在JDK1.4.2之前的Javac编译器采用了jsr和ret指令实现finally
语句。在JDK1.7中,已经完全禁止Class文件中出现jsr和ret指令,如果遇到这两条指令,虚拟机会在类加载的字节码校验阶段抛出异常) - 当异常处理存在
finally
语句块时,编译器会自动在每一段可能的分支路径之后都将finally
语句块的内容冗余生成一遍来实现finally
语义。 - 在我们Java代码中,
finally
语句块是在最后的,但编译器在生成字节码时候,其实将finally
语句块的执行指令移到了ireturn
指令之前,指令重排序了。所以,从字节码层面,我们解释了,为什么finally
语句总会执行!初学Java的时候,我们有感到困惑,为什么方法已经return
了,finally
语句块里的代码还会执行呢?这是因为,在字节码中,它就是先执行了finally
语句块,再执行return
的,而这个变化是Java编译器帮我们做的,程序员一脸闷逼。