Java 进阶之异常处理

本文的主要内容分为 Java 异常的定义、Java 异常的处理、JVM 基础知识(异常表、JVM 指令分类和操作数栈)及深入剖析 try-catch-finally 四部分(图解形式)。 在深入剖析 try-catch-finally 部分会以字节码的角度分析为什么 finally 语句一定会执行。 第三和第四部分理解起来可能会有些难度,不感兴趣的小伙伴可直接跳过。

一、异常定义

异常是指在程序执行期间发生的事件,这些事件中断了正常的指令流(例如,除零,数组越界访问等)。在 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 方法中,你可以这样做:

  1. 将 methodD 方法的调用包装在 try-catchtry-catch-finally 中,如下所示。每个 catch 块可以包含一种类型的异常对应的异常处理程序。
public void methodC() {  // 未声明异常
   ......
   try {
      ......
      // 调用声明XxxException和YyyException异常的methodD方法
      methodD();
      ......
   } catch (XxxException ex) {
      // 处理XxxException异常
      ......
   } catch (YyyException ex} {
      // 处理YyyException异常
      ......
   } finally {   // 可选
      // 这些代码总会执行,用于执行清理操作
      ......
   }
   ......
}
  1. 假设调用 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 类:

  1. 常量(constants)指令
  2. 加载(loads)指令
  3. 存储(stores)指令
  4. 操作数栈(stack)指令
  5. 数学(math)指令
  6. 转换(conversions)指令
  7. 比较(comparisons)指令
  8. 控制(control)指令
  9. 引用(references)指令
  10. 扩展(extended)指令
  11. 保留(reserved)指令

保留指令共有 3 条。其中一条是留给调试器的,用于实现断点,操作码是 202(0xCA) ,助记符是 breakpoint 。另外两条留给 Java 虚拟机实现内使用,操作码分别是 254(0xFE)266(0xFF) ,助记符是 impdep1impdep2 。这三条指令不允许出现在 class 文件中。

若想了解完整的 Java 字节码指令列表,可以访问 Wiki - Java_bytecode_instruction_listings 这个页面。

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 语句一定会执行的最终原因了。

全栈修仙之路,及时阅读 Angular、TypeScript、Node.js/Java和Spring技术栈最新文章。

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章