27

Java 进阶之异常处理

 4 years ago
source link: https://semlinker.com/java-in-advance-exception/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

一、异常定义

异常是指在程序执行期间发生的事件,这些事件中断了正常的指令流(例如,除零,数组越界访问等)。在 Java 中,异常是一个对象,该对象包装了方法内发生的错误事件,并包含以下信息:

  • 与异常有关的信息,如类型
  • 发生异常时程序的状态
  • 其它自定义消息(可选)

此外,异常对象也可以被抛出或捕获。Java 程序在执行过程中发生的异常可分为两大类:Error 和 Exception,它们都继承于 Throwable 类。

Mzm2iyQ.jpg!web

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 语句

VNBjyyF.jpg!web

红色虚线关联块(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 语句

qe2eyyb.jpg!web

从上图可知,若存在多个 catch 语句,则异常表中会生成多条记录。astore_1 字节码指令的作用是把引用(异常对象 e)存入局部变量表。

4.3 try-catch-finally 语句

yYn2Qrm.jpg!web

基于上图我们来详细分析一下生成的字节码:

  • 第 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技术栈最新文章。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK