您的位置 首页 java

Java 进阶之异常处理

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

一、异常定义

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

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

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

1.1 Error

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-catch 或 try-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 处进行处理。

    异常结构表:

    下面我们开始来分析一下 一个 catch 语句,多个 catch 语句 和 try-catch-finally 语句 这三种情形所生成的字节码。从而加深对 JVM 内部 try-catch-finally 机制的理解。

    为了节省篇幅示例代码就不贴出来了…….

    注意:通过 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),助记符是 impdep1 和 impdep2。这三条指令不允许出现在 class 文件中。

    若想了解完整的 Java 字节码指令列表,可以访问en.wikipedia.org/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 字节码有一定的了解,请小伙伴们选择性阅读。

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

    Java 进阶之异常处理

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

    4.3 try-catch-finally 语句

    Java 进阶之异常处理

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

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

    五、参考资源


  • 转发+关注后私信我【架构资料】领取

    这套技术体系是由6名10余年行业工作经验的架构师结合实战经验、一线互联网企业主流技术,归纳整理而成,并针对每一个技术点配有相应的视频详解、源码注释和笔记文档。

    转发+关注后私信我【架构资料】领取

    文章来源:智云一二三科技

    文章标题:Java 进阶之异常处理

    文章地址:https://www.zhihuclub.com/174553.shtml

    关于作者: 智云科技

    热门文章

    网站地图