本文的主要内容分为 Java 异常的定义、Java 异常的处理、JVM 基础知识(异常表、JVM 指令分类和操作数栈)及深入剖析 try-catch-finally 四部分(图解形式)。 在深入剖析 try-catch-finally 部分会以字节码的角度分析为什么 finally 语句一定会执行。 第三和第四部分理解起来可能会有些难度,不感兴趣的小伙伴可直接跳过。
异常是指在程序执行期间发生的事件,这些事件中断了正常的指令流(例如,除零,数组越界访问等)。在 Java 中,异常是一个对象,该对象包装了方法内发生的错误事件,并包含以下信息:
此外,异常对象也可以被抛出或捕获。Java 程序在执行过程中发生的异常可分为两大类:Error 和 Exception,它们都继承于 Throwable 类。
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 上与它们有关的注释:
这些错误是不可查的,因为它们在应用程序的控制和处理能力之外,而且绝大多数是程序运行时不允许出现的状况。
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(非法参数异常)等异常, 这些异常是不检查异常,程序中可以选择捕获处理,也可以不处理。 这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免这类异常的发生。
Error 通常是灾难性的致命的错误,是程序无法控制和处理的,当出现这些异常时,Java 虚拟机(JVM)一般会选择终止线程;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 的异常处理包含三部分:声明异常、抛出异常和捕获异常。
一个 Java 方法必须在其签名中声明可能通过 throws 关键字在其方法体中 “抛出” 的已检查异常的类型。
举个例子,假设 methodD()
的定义如下:
public void methodD() throws XxxException, YyyException { // 方法体抛出XxxException和YyyException异常 }
methodD 的方法签名表示运行 methodD 方法时,可能遇到两种 checked exceptions:XxxException 和 YyyException。换句话说,在 methodD 方法中若出现某些不正常的情况可能会触发 XxxException 或 YyyException 异常。
请注意,我们不需要声明属于 Error,RuntimeException 及其子类的异常。这些异常称为不受检查的异常,因为编译器未检查它们。
当 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
。
当方法抛出异常时,JVM 在调用堆栈中向后搜索匹配的异常处理程序。每个异常处理程序都可以处理一类特殊的异常。异常处理程序可以处理特定的类,也可以处理其子类。如果在调用堆栈中未找到异常处理程序,则程序终止。
比如,假设 methodD 方法在方法签名上声明了可能抛出的 XxxException 和 YyyException 异常,具体如下:
public void methodD() throws XxxException, YyyException { ...... }
要在程序中使用 methodD 方法,比如在 methodC 方法中,你可以这样做:
try-catch
或 try-catch-finally
中,如下所示。每个 catch 块可以包含一种类型的异常对应的异常处理程序。 public void methodC() { // 未声明异常 ...... try { ...... // 调用声明XxxException和YyyException异常的methodD方法 methodD(); ...... } catch (XxxException ex) { // 处理XxxException异常 ...... } catch (YyyException ex} { // 处理YyyException异常 ...... } finally { // 可选 // 这些代码总会执行,用于执行清理操作 ...... } ...... }
public void methodC() throws XxxException, YyyException { // 让更高层级的方法来处理 ... // 调用声明XxxException和YyyException异常的methodD方法 methodD(); // 无需使用try-catch ... }
在这种情况下,如果 methodD 方法抛出 XxxException 或 YyyException,则 JVM 将终止 methodD 方法和methodC 方法并将异常对象沿调用堆栈传递给 methodC 方法的调用者。
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 子句并从调用堆栈中弹出。调用者遵循相同的过程来处理异常。
前面我们已经介绍了通过使用 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 文件的信息。
因为使用一字节表示操作码,所以 Java 虚拟机最多只能支持 256(2^8 )条指令。
Java 虚拟机规范已经定义了 205 条指令,操作码分别是 0(0x00)到 202(0xCA)、254(0xFE)和 255(0xFF)。这 205 条指令构成了 Java 虚拟机的指令集(instruction set)。
Java 虚拟机规范把已经定义的 205 条指令按用途分成了 11 类:
保留指令共有 3 条。其中一条是留给调试器的,用于实现断点,操作码是 202(0xCA)
,助记符是 breakpoint
。另外两条留给 Java
虚拟机实现内使用,操作码分别是 254(0xFE)
和 266(0xFF)
,助记符是 impdep1
和 impdep2
。这三条指令不允许出现在 class 文件中。
若想了解完整的 Java 字节码指令列表,可以访问 Wiki - Java_bytecode_instruction_listings 这个页面。
操作数栈也常称为操作栈。它是各种各样的字节码操作如何获得他们的输入,以及他们如何提供他们的输出。
例如,考虑 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 | +-------+
前面我们已经介绍了 Java 中异常和 JVM 虚拟机相关知识,之前刚好看过 字节码角度看面试题 —— try catch finally 为啥 finally 语句一定会执行 这篇文章,下面我们来换个角度,即以 字节码 的角度来分析一下 try-catch-finally 的底层原理。
注意:以下内容需要对 Java 字节码有一定的了解,请小伙伴们选择性阅读。
tryItOut 方法编译后生成以下代码:
0: aload_0 1: invokespecial #2
上述代码的作用是从局部变量表中加载 this,并调用 tryItOut 方法。
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
当字节码在第 0 行到 4 行之间(包括 0 行而不包括 4 行)出现了类型为 MyException 类型或者其子类的异常,则跳转到第 7 行。若 type 的值为 0 时,表示任意异常情况都需要转向到 target 处进行处理。
从上图可知,若存在多个 catch 语句,则异常表中会生成多条记录。astore_1 字节码指令的作用是把引用(异常对象 e)存入局部变量表。
基于上图我们来详细分析一下生成的字节码:
根据上述的分析和图中三个虚线框标出的字节码,相信大家已经知道在 Java 的 try-catch-finally 语句中 finally 语句一定会执行的最终原因了。
全栈修仙之路,及时阅读 Angular、TypeScript、Node.js/Java和Spring技术栈最新文章。