本文的主要内容分为 Java 异常的定义、Java 异常的处理、JVM 基础知识(异常表、JVM 指令分类和操作数栈)及深入剖析 try-catch-finally 四部分(图解形式)。在深入剖析 try-catch-finally 部分会以字节码的角度分析为什么 finally 语句一定会执行。第三和第四部分理解起来可能会有些难度,不感兴趣的小伙伴可直接跳过。
阅读更多关于 Angular、TypeScript、Node.js/Java 、Spring 等技术文章,欢迎访问我的个人博客 —— 全栈修仙之路
一、异常定义
异常是指在程序执行期间发生的事件,这些事件中断了正常的指令流(例如,除零,数组越界访问等)。在 Java 中,异常是一个对象,该对象包装了方法内发生的错误事件,并包含以下信息:
与异常有关的信息,如类型
发生异常时程序的状态
其它自定义消息(可选)
此外,异常对象也可以被抛出或捕获。Java 程序在执行过程中发生的异常可分为两大类:Error 和 Exception,它们都继承于 Throwable 类。
1.1 Error
An Error is a subclass of Throwable that indicates serious problems that a reasonable application should not try to catch. Most such errors are abnormal conditions.
Error 是 Throwable 类的子类,它表示合理的应用程序不应该尝试捕获的严重问题。大多数这样的错误都是异常情况。让我们来看一下 Error 类的一些子类,并阅读 JavaDoc 上与它们有关的注释:
AnnotationFormatError:当注解解析器尝试从类文件读取注解并确认注解格式不正确时抛出。
AssertionError:抛出该异常以表明断言失败。
LinkageError:链接错误的子类表示一个类对另一个类有一定的依赖性;然而,后一个类在前一个类编译后发生了不兼容的变化。
VirtualMachineError:抛出表示 Java 虚拟机已损坏或已耗尽继续运行所需的资源。
这些错误是不可查的,因为它们在应用程序的控制和处理能力之外,而且绝大多数是程序运行时不允许出现的状况。
1.2 Exception
The class Exception and its subclasses are a form of Throwable that indicates conditions that a reasonable application might want to catch.
Exception 和它的子类是可抛出异常的一种形式,表示合理的应用程序可能想要捕获的异常。在 Exception 分支中有一个重要的子类 RuntimeException(运行时异常),该类型的异常会自动为你所编写的程序创建ArrayIndexOutOfBoundsException(数组下标越界异常)、NullPointerException(空指针异常)、ArithmeticException(算术异常)、IllegalArgumentException(非法参数异常)等异常,这些异常是不检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免这类异常的发生。
1.3 Error vs Exception
Error 通常是灾难性的致命的错误,是程序无法控制和处理的,当出现这些异常时,Java 虚拟机(JVM)一般会选择终止线程;Exception 通常情况下是可以被程序处理的,并且在程序中应该尽可能的去处理这些异常。
1.4 Unchecked Exception vs Checked Exception
Unchecked Exception(不受检查的异常):可能是经常出现的编程错误,比如 NullPointerException(空指针异常)或 IllegalArgumentException(非法参数异常)。应用程序有时可以处理它或从此 Throwable 类型的异常中恢复。或者至少在 Thread 的 run 方法中捕获它,记录日志并继续运行。
Checked Exception(检查异常):在正确的程序运行过程中,很容易出现的、情理可容的异常状况,在一定程度上这种异常的发生是可以预测的,并且一旦发生该种异常,就必须采取某种方式进行处理。
除了 RuntimeException 及其子类以外,其他的 Exception 类及其子类都属于检查异常,当程序中可能出现这类异常,要么使用 try-catch 语句进行捕获,要么用 throws 子句抛出,否则编译无法通过。
不受检查异常和检查异常的区别是:不受检查异常为编译器不要求强制处理的异常,检查异常则是编译器要求必须处置的异常。
二、异常处理
在 Java 中有 5 个关键字用于异常处理:try,catch,finally,throws 和 throw(注意 throw 和 throws 之间存在一些区别)。
Java 的异常处理包含三部分:声明异常、抛出异常和捕获异常。
2.1 声明异常
一个 Java 方法必须在其签名中声明可能通过 throws 关键字在其方法体中 “抛出” 的已检查异常的类型。
举个例子,假设 methodD() 的定义如下:
public void methodD() throws XxxException, YyyException {
// 方法体抛出XxxException和YyyException异常
}
methodD 的方法签名表示运行 methodD 方法时,可能遇到两种 checked exceptions:XxxException 和 YyyException。换句话说,在 methodD 方法中若出现某些不正常的情况可能会触发 XxxException 或 YyyException 异常。
请注意,我们不需要声明属于 Error,RuntimeException 及其子类的异常。这些异常称为不受检查的异常,因为编译器未检查它们。
2.2 抛出一个异常
当 Java 操作遇到异常情况时,包含错误语句的方法应创建一个适当的 Exception 对象,并通过 throw XxxException 语句将其抛到 Java 运行时。例如:
public void methodD() throws XxxException, YyyException { // 方法签名
// 方法体
...
...
// 出现XxxException异常
if ( ... )
throw new XxxException(...); // 构造一个XxxException对象并抛给JVM
...
// 出现YyyException异常
if ( ... )
throw new YyyException(...); // 构造一个YyyException对象并抛给JVM
...
}
请注意,在方法签名中声明异常的关键字为 throws,在方法体内抛出异常对象的关键字为 throw。
2.3 捕获异常
当方法抛出异常时,JVM 在调用堆栈中向后搜索匹配的异常处理程序。每个异常处理程序都可以处理一类特殊的异常。异常处理程序可以处理特定的类,也可以处理其子类。如果在调用堆栈中未找到异常处理程序,则程序终止。
比如,假设 methodD 方法在方法签名上声明了可能抛出的 XxxException 和 YyyException 异常,具体如下:
public void methodD() throws XxxException, YyyException { ...... }
要在程序中使用 methodD 方法,比如在 methodC 方法中,你可以这样做:
将 methodD 方法的调用包装在 try-catch 或 try-catch-finally 中,如下所示。每个 catch 块可以包含一种类型的异常对应的异常处理程序。
public void methodC() { // 未声明异常
......
try {
......
// 调用声明XxxException和YyyException异常的methodD方法
methodD();
......
} catch (XxxException ex) {
// 处理XxxException异常
......
} catch (YyyException ex} {
// 处理YyyException异常
......
} finally { // 可选
// 这些代码总会执行,用于执行清理操作
......
}
......
}
假设调用 methodD 方法的 methodC 不希望处理异常(通过 try-catch),它可以在方法签名中声明这些异常,如下所示:
public void methodC() throws XxxException, YyyException { // 让更高层级的方法来处理
...
// 调用声明XxxException和YyyException异常的methodD方法
methodD(); // 无需使用try-catch
...
}
在这种情况下,如果 methodD 方法抛出 XxxException 或 YyyException,则 JVM 将终止 methodD 方法和methodC 方法并将异常对象沿调用堆栈传递给 methodC 方法的调用者。
2.4 try-catch-finally
try-catch-finally 的语法如下:
try {
// 主要逻辑,使用了可能抛出异常的方法
......
} catch (Exception1 ex) {
// 处理Exception1异常
......
} catch (Exception2 ex) {
// 处理Exception2异常
......
} finally { // finally是可选的
// 这些代码总会执行,用于执行清理操作
......
}
如果在 try 块运行期间未发生异常,则将跳过所有 catch 块,并在 try 块之后执行 finally 块。如果 try 块中的一条语句引发异常,则 Java 运行时将忽略 try 块中的其余语句,并开始搜索匹配的异常处理程序。它将异常类型与每个 catch 块顺序匹配。
如果 catch 块捕获了该异常类或该异常的超类,则将执行该 catch 块中的语句。然后,在该catch 块之后执行 finally 块中的语句。该程序将在 try-catch-finally 之后继续进入下一个语句,除非它被过早终止。
如果没有任何 catch 块匹配,则异常将沿调用堆栈传递。当前方法执行 finally 子句并从调用堆栈中弹出。调用者遵循相同的过程来处理异常。
三、JVM 基础知识
3.1 异常表
前面我们已经介绍了通过使用 try{}catch(){}finally{} 来对异常进行捕获或者处理。但是对于 JVM 来说,在它内部是如何进行异常处理呢?实际上 Java 编译后,会在代码后附加异常表的形式来实现 Java 的异常处理及 finally 机制(JDK 1.4.2 之前,Java 编译器是使用 jsr 和 ret 指令来实现 finally 语句,JDK1.7 及之后版本,则完全禁止在 Class 文件中使用 jsr 和 ret 指令)。
属性表(attribute_info)可以存在于 Class 文件、字段表、方法表中,用于描述某些场景的专有信息。属性表中有个 Code 属性,该属性在方法表中使用,Java 程序方法体中的代码被编译成的字节码指令存储在 Code 属性中。而异常表(exception_table)则是存储在 Code 属性表中的一个结构,这个结构是可选的。
异常表结构如下表所示。它包含 4 个字段:如果当字节码在第 start_pc 行到 end_pc 行之间(包括 start_pc 行而不包括 end_pc 行)出现了类型为 catch_type 或者其子类的异常(catch_type 为指向一个 CONSTANT_Class_info 型常量的索引),则跳转到第 handler_pc 行执行。如果 catch_type 为 0,表示任意异常情况都需要转到 handler_pc 处进行处理。
异常结构表:
类型
名称
数量
u2
start_pc
1
u2
end_pc
1
u2
handler_pc
1
u2
catch_type
1
下面我们开始来分析一下 一个 catch 语句,多个 catch 语句 和 try-catch-finally 语句 这三种情形所生成的字节码。从而加深对 JVM 内部 try-catch-finally 机制的理解。
为了节省篇幅示例代码就不贴出来了,本人已上传上传至 Gist,需要完整代码的小伙伴请自行获取。
注意:通过 javap -v -p ClassName(编译后所生成 class 文件的名称) 可以查看生成的 class 文件的信息。
3.2 JVM 指令分类
因为使用一字节表示操作码,所以 Java 虚拟机最多只能支持 256(2^8 )条指令。
Java 虚拟机规范已经定义了 205 条指令,操作码分别是 0(0x00)到 202(0xCA)、254(0xFE)和 255(0xFF)。这 205 条指令构成了 Java 虚拟机的指令集(instruction set)。
Java 虚拟机规范把已经定义的 205 条指令按用途分成了 11 类:
常量(constants)指令
加载(loads)指令
存储(stores)指令
操作数栈(stack)指令
数学(math)指令
转换(conversions)指令
比较(comparisons)指令
控制(control)指令
引用(references)指令
扩展(extended)指令
保留(reserved)指令
保留指令共有 3 条。其中一条是留给调试器的,用于实现断点,操作码是 202(0xCA),助记符是breakpoint。另外两条留给 Java 虚拟机实现内使用,操作码分别是 254(0xFE) 和 266(0xFF),助记符是 impdep1 和 impdep2。这三条指令不允许出现在 class 文件中。
3.3 操作数栈
操作数栈也常称为操作栈。它是各种各样的字节码操作如何获得他们的输入,以及他们如何提供他们的输出。
例如,考虑 iadd 操作,它将两个 int 添加在一起。要使用它,你在堆栈上推两个值,然后使用它:
iload_0 # Push the value from local variable 0 onto the stack
iload_1 # Push the value from local variable 1 onto the stack
iadd # Pops those off the stack, adds them, and pushes the result
现在栈上的顶值是这两个局部变量的总和。下一个操作可能需要顶层栈值,并将其存储在某个地方,或者我们可能在堆栈中推送另一个值来执行其他操作。
假设要将三个值添加在一起,堆栈使这很容易:
iload_0 # Push the value from local variable 0 onto the stack
iload_1 # Push the value from local variable 1 onto the stack
iadd # Pops those off the stack, adds them, and pushes the result
iload_2 # Push the value from local variable 2 onto the stack
iadd # Pops those off the stack, adds them, and pushes the result
现在栈上的顶值是将这三个局部变量相加在一起的结果。
让我们更详细地看看第二个例子:
我们假设:
> 堆栈是空的开始
> 局部变量 0 包含 27
> 局部变量 1 包含 10
> 局部变量 2 包含 5
所以最初 stack 的状态:
+-------+
| stack |
+-------+
+-------+
然后我们执行:
iload_0 # Push the value from local variable 0 onto the stack
当前操作数栈的状态:
+-------+
| stack |
+-------+
| 27 |
+-------+
接着继续执行:
iload_1 # Push the value from local variable 1 onto the stack
当前操作数栈的状态:
+-------+
| stack |
+-------+
| 10 |
| 27 |
+-------+
现在我们执行 iadd 指令:
iadd # Pops those off the stack, adds them, and pushes the result
该指令会将 10 和 27 出栈并对它们执行加法运算,完成计算后会把结果继续入栈。此时操作数栈的状态为:
+-------+
| stack |
+-------+
| 37 |
+-------+
继续执行以下指令:
iload_2 # Push the value from local variable 2 onto the stack
该指令执行之后,操作数栈的状态:
+-------+
| stack |
+-------+
| 5 |
| 37 |
+-------+
最后我们执行 iadd 指令:
iadd # Pops those off the stack, adds them, and pushes the result
该指令执行之后,操作数栈的最终状态:
+-------+
| stack |
+-------+
| 42 |
+-------+
四、深入剖析 try-catch-finally
前面我们已经介绍了 Java 中异常和 JVM 虚拟机相关知识,之前刚好看过 字节码角度看面试题 —— try catch finally 为啥 finally 语句一定会执行 这篇文章,下面我们来换个角度,即以 字节码 的角度来分析一下 try-catch-finally 的底层原理。
注意:以下内容需要对 Java 字节码有一定的了解,请小伙伴们选择性阅读。
4.1 一个 catch 语句
红色虚线关联块(1)
tryItOut 方法编译后生成以下代码:
0: aload_0
1: invokespecial #2
上述代码的作用是从局部变量表中加载 this,并调用 tryItOut 方法。
蓝色虚线关联块(2)
catch 语句编译后生成以下代码:
7: astore_1
8: aload_0
9: aload_1
10: invokespecial #4
上述代码的作用是加载 MyException 实例,并调用 handleException 方法。
细心的小伙伴可能会发现生成的 Code 的索引是:0 - 1 - 4 -7 - 8 - 9 - 10 -13,没有看到 2、3 和 11、12。个人猜测是因为 JVM 字节码指令 invokespecial 操作数占用了 2 个索引字节(欢迎知道真相的大佬,慷慨解答)。这里 invokespecial 字节码指令的格式定义如下:
invokespecial
indexbyte1
indexbyte2
Exception table
当字节码在第 0 行到 4 行之间(包括 0 行而不包括 4 行)出现了类型为 MyException 类型或者其子类的异常,则跳转到第 7 行。若 type 的值为 0 时,表示任意异常情况都需要转向到 target 处进行处理。
4.2 多个 catch 语句
从上图可知,若存在多个 catch 语句,则异常表中会生成多条记录。astore_1 字节码指令的作用是把引用(异常对象 e)存入局部变量表。
4.3 try-catch-finally 语句
基于上图我们来详细分析一下生成的字节码:
第 0 - 5 行对应的功能逻辑是调用 tryItOut 方法并最终执行 finally 语句中的 handleFinally 方法;
第 8 行是使用 goto 指令跳转到 31 行即执行 return 指令;
第 11 - 18 行对应的功能逻辑是捕获 MyException 异常进而调用 handleException 方法并最终执行 finally 语句中的 handleFinally 方法;
第 21 行使用 goto 指令跳转到 31 行即执行 return 指令;
24 - 30 行对应的功能逻辑是若出现其他异常时,先保存当时的异常对象然后继续调用 handleFinally 方法,最后再抛出已保存的异常对象。
第 31 行使用 goto 指令跳转到 31 行即执行 return 指令。
根据上述的分析和图中三个虚线框标出的字节码,相信大家已经知道在 Java 的 try-catch-finally 语句中 finally 语句一定会执行的最终原因了。
五、参考资源