Java 进阶之路:异常处理的内在原理及优雅的处理方式

永远不要期待程序在完全理想的状态下运行,异常往往不期而遇,如果没有完善的异常处理机制,后果可能是灾难性的。对于 Java 工程师而言,合理地处理异常是一种基本而重要的能力,然而,在近来的面试中,笔者发现很多应聘者对异常处理的内在原理几无了解,现场手写的异常处理代码也极为“原始”。

鉴于此,笔者试图通过本场 Chat 为读者呈现 Java 异常处理的内在原理、处理原则及优雅的处理方式。主要内容如下:

  1. Java 异常的层次结构和处理机制;
  2. Java 异常表与异常处理的内在原理;
  3. Java 异常处理的基本原则;
  4. 典型案例:优雅地处理 Java 异常。

 

1.Java 异常简介

对于Java工程师而言,异常应该并不陌生,作为本场 Chat 的引入部分,对 Java 异常的基础知识仅作简要回顾,本文主体将聚焦于深入解读 Java 异常的底层原理和异常处理实践。

1.1 Java 异常类层次结构

在 Java 中,所有的异常都是由 Throwable 继承而来,换言之,Throwable 是所有异常类共同的“祖先”,层次结构图如下所示(注:Error、Exception 的子类及其孙子类只列出了部分): 
enter image description here

1.2 Java 异常类相关的基本概念

Throwable:

作为所有异常类共同的 “祖先”,Throwable 在 “下一代” 即分化为两个分支:Exception(异常)和 Error(错误),二者是 Java 异常处理的重要子类。

Error:

Error 类层次结构用于描述 Java 运行时系统的内部错误和资源耗尽错误,这类错误是程序无法处理的严重问题,一旦出现,除了通告给用户并尽可能安全终止程序外,别无他法。

常见的错误,如:JVM 内存资源耗尽时出现的 OutOfMemoryError;栈溢出时出现的 StackOverFlowError;类定义错误 NoClassDefFoundError。这些错误表示故障发生于虚拟机自身、或者发生在虚拟机试图执行应用时,它们在应用程序的控制和处理能力之外,一旦发生,Java 虚拟机一般会选择线程终止。

Exception:

相较于 Error,Exception 类层次结构所描述的异常更需要 Java 程序设计者关注,因为它是程序本身可以处理的。Exception 类的 “下一代” 分化为两个分支:RuntimeException + 其它异常。划分两个分支的原则为:由程序错误导致的异常属于 RuntimeException;而程序本身没有问题,但由于 I/O 错误之类问题导致的异常属于其它异常。

关于异常和错误的区别:通俗地讲,异常是程序本身可以处理的,而错误则是无法处理的。

可检查异常

可检查异常也称为已检查异常(checked exception),这类异常是编译器要求必须处置的异常。在工程实践中,程序难免出现异常,其中一些异常是可以预计和容忍的,比如:

读取文件的时候可能出现文件不存在的情况(FileNotFoundException),但是,并不希望因此就导致程序结束,那怎么办呢?通常采用捕获异常(try-catch)或者抛出异常(throws 抛出,由调用方处理)的方式来处理。

可检查异常虽然也是异常,但它具备一些重要特征:可预计、可容忍、可检查、可处理。因此,一旦发生这类异常,就必须采取某种方式进行处理。

Java 语言规范将派生于 Error 类或 RuntimeException 类之外的所有异常都归类为可检查异常,Java 编译器会检查它,如果不做处理,无法通过编译。

不可检查异常:

与可检查异常相反,不可检查异常(unchecked exception)是 Java 编译器不强制要求处置的异常。Java 语言规范将 Error 类和 RuntimeException 及其子类归类为不可检查异常。

为什么编译器不强制要求处置呢?不是因为这类异常简单,危害性小,而是因为这类异常是应该尽力避免出现的,而不是出现后再去补救。以 RuntimeException 类及其子类为例:

1.NullPointerException(空指针异常);2.IndexOutOfBoundsException(下标越界异常);
3.IllegalArgumentException(非法参数异常);

这些异常通常是由不合理的程序设计和不规范的编码引起的,工程师在设计、编写程序时应尽可能避免这类异常的发生,这是可以做到的。在 IT 圈内有个不成文的原则:如果出现 RuntimeException 及其子类异常,那么可认为是程序员的错误。

1.3 异常处理机制

在 Java 应用程序中,异常处理机制有:抛出异常,捕捉异常。

抛出异常:

这里的 “抛出异常” 是指主动抛出异常。在设计、编写程序时,我们可以预料到一些可能出现的异常,如 FileNotFoundException,有时候我们并不希望在当前方法中对其进行捕获处理,怎么办呢?抛出去,让调用方去处理,通过 throw 关键字即可完成,如:

throw new FileNotFoundException()

关于抛出异常,还有一个点需要补充,那就是声明可检查异常。在设计程序的时候,如果一个方法明确可能发生某些可检查异常,那么,可以在方法的定义中带上这些异常,如此,这个方法的调用方就必须对这些可检查异常进行处理。

声明异常:

根据 Java 规范,如果一个 Java 方法要抛出异常,那么需要在这个方法后面用 throws 关键字明确定义可以抛出的异常类型。倘若没有定义,就默认该方法不抛出任何异常。这样的规范决定了 Java 语法必须强行对异常进行 try-catch。如下的方法签名:

public void foo() throws FileNotFoundException { ... }

暗含了两方面的意思:第一,该方法要抛出 FileNotFoundException 类型的异常;第二,除了 FileNotFoundException 外不能(根据规范)抛出其它的异常。

那么,如何保证没有除 FileNotFoundException 之外的任何异常被抛出呢? 很显然,方式有:1.通过合理的设计和编码避免出现其它异常;2.如果其它异常不可完全避免(如方法内调用的其它方法明确可能出现异常),就需要 try-catch 其它的异常。简而言之,一般情况下,方法不抛出哪些异常就要在方法内部 try-catch 这些异常。

捕获异常:

抛出异常十分容易,抛出去便不用再理睬,但是,在一些场景下,必须捕获异常并进行相应的处理。如果某个异常发生后没有在任何地方被捕获,那么,程序将会终止。

在 Java 中,捕获异常涉及三个关键字:try、catch 和 finally。如下举例:

try {
    可能发生异常的代码块
} catch (某种类型的异常 e) {
    对于这种异常的处理代码块
} finally {
    处理未尽事宜的代码块:如资源回收等
}

2.Java 异常表与异常处理的内在原理

在上一节中,笔者简要介绍了 Java 异常,本节将从字节码的层面切入,剖析 Java 异常处理的内在原理。

2.1 Java 类文件结构简要回顾

众所周知,Java 是一种 “与平台无关” 的编程语言,其实现 “平台无关性” 的基石在于虚拟机和字节码的存储格式。事实上,Java 虚拟机并不绑定任何编程语言(包括 Java 语言),而是与 “Class 文件” 这种特定的二进制格式文件强关联,这种 Class 文件包含了 Java 虚拟机指令集、符号表等信息。

Java 编译器可以将 Java 代码编译成存储字节码的 Class 文件,其它语言,如 JRuby 也可以通过相应的编译器编译为 Class 文件。对于虚拟机而言,并不关心 Class 文件源自何种语言,毕竟,Class 文件才是 Java 虚拟机最终要执行的计算机指令的来源。

Class 文件的格式

Class 文件是一组以 8 位字节为基础单位的二进制流,程序编译后的数据按照严格的顺序紧密排列,其间没有任何分隔符。从数据结构来看,Class 文件采用了一种类似 C 语言结构体的伪结构来存储数据,这种伪结构只有两种数据类型:无符号数。其中,表主要有方法表、字段表和属性表,为便于读者理解后文的内容,在此着重介绍一下属性表。

属性表(attribute_info)

属性表可以存在于 Class 文件、字段表、方法表中(数据结构是可以嵌套的),用于描述某些场景的专有信息。属性表中有个 Code 属性,该属性在方法表中使用,Java 程序方法体中的代码被编译成的字节码指令存储在 Code 属性中。

异常表(exception_table)

异常表是存储在 Code 属性表中的一个结构,但是,这个结构并不是必须存在的,很好理解,如果方法中根本就没有异常相关的代码,编译结果中自然也不会有异常表。

2.2 异常表解读

异常表结构

异常表的结构如下表所示。它包含 4 个字段,含义为:如果当字节码在第 start_pc 行到 end_pc 行之间(不包含第 end_pc 行)出现了类型为 catch_type 或者其子类的异常(catch_type 为指向一个 CONSTANT_Class_info 型常量的索引),则跳转到第 handler_pc 行执行。如果 catch_type 的值为 0,则表示任意异常情况都需要转到 handler_pc 处进行处理。 
enter image description here
​ 
注:u2 是一种数据类型,表示 2 个字节的无符号数。

异常表是 Java 代码的一部分,编译器使用异常表而不是简单的跳转指令来实现 Java 异常及 finally 处理机制。

处理异常的基本原理

根据前面的介绍,不难理解,具备处理异常能力的 Java 类编译后,都会跟随一个异常表,如果发生异常,首先在异常表中查找对应的行(即代码中相应的 try{}catch(){} 代码块),如果找到,则跳转到异常处理代码执行,如果没有找到,则返回(如果有 finally,须在执行 finally 之后),并复制异常给父调用者,接着查询父调用的异常表,以此类推,直至异常被处理或者因没有处理而导致程序终止。

2.3 异常处理实例

为了便于读者更好的理解 Java 异常的处理,在此,结合一个简单的实例来看一下异常表如何运作。 Java 源码如下(本例参考了《深入理解 Java 虚拟机》一书):

public class Test {
    public int inc() {
        int x;
        try {
            x = 1;
            return x;
        } catch (Exception e) {
            x = 2;
            return x;
        } finally {
            x = 3;
        }
    }
}

从 Java 语义来看,上述代码的执行路径有以下 3 种:

  1. 如果 try 语句块中出现了属于 Exception 及其子类的异常,则跳转到 catch 处理;
  2. 如果 try 语句块中出现了不属于 Exception 及其子类的异常,则跳转到 finally 处理;
  3. 如果 catch 语句块中出现了任何异常,则跳转到 finally 处理。

由此可以分析上述代码可能的返回结果:如果没有出现异常,返回 1;如果出现 Exception 异常,返回 2;如果出现了 Exception 以外的异常,则非正常退出,没有返回。

将上面的源码编译为 ByteCode 字节码(采用的 JDK 版本为 1.8):

public int inc();
    Code:
       0: iconst_1          #try中x=1入栈
       1: istore_1          #x=1存入第二个int变量
       2: iload_1           #将第二个int变量推到栈顶
       3: istore_2          #将栈顶元素存入第三个变量,即保存try中的返回值
       4: iconst_3          #finally中的x=3入栈
       5: istore_1          #栈顶元素放入第二个int变量,即finally中的x=3
       6: iload_2           #将第三个int变量推到栈顶,即try中的返回值
       7: ireturn           #当前方法返回int,即x=1
       8: astore_2          #栈顶数值放入当前frame的局部变量数组中第三个
       9: iconst_2          #catch中的x=2入栈
      10: istore_1          #x=2放入第二个int变量
      11: iload_1           #将第二个int变量推到栈顶
      12: istore_3          #将栈顶元素存入第四个变量,即保存catch中的返回值
      13: iconst_3          #finally中的x=3入栈
      14: istore_1          #finally中的x=3放入第一个int变量
      15: iload_3           #将第四个int变量推到栈顶,即保存的catch中的返回值
      16: ireturn           #当前方法返回int,即x=2
      17: astore  4         #栈顶数值放入当前frame的局部变量数组中第五个
      18: iconst_3          #final中的x=3入栈
      19: istore_1          #final中的x=3放入第一个int变量
      20: aload   4         #当前frame的局部变量数组中第五个放入栈顶
      21: athrow            #将栈顶的数值作为异常或错误抛出
    Exception table:
       from    to  target type
           0     4     8   Class java/lang/Exception
           0     4    17   any
           8    13    17   any
          17    19    17   any

异常表符号解释:

从上述字节码中可见,对于 finally 代码块,编译器为每个可能出现的分支后都放置了冗余。并且编译器生成了 3 个异常表记录(在 Exception table 中),它们分别对应 3 条可能出现的代码执行路径。Exception Table 中包含了很多信息:异常处理开始的偏移量、结束偏移量、异常捕捉的类型等等。

  • Exception table:异常处理信息表;
  • from:异常处理开始的位置;
  • to:异常处理结束的位置;
  • target:异常处理器的起始位置,即 catch 开始处理的位置;
  • type:异常类型,any 表示所有类型;

字节码分析:

首先,0-3 行,就是把整数 1 赋值给 x,并且将此时 x 的值复制一个副本到本地变量表的 Slot 中暂存,这个 Slot 里面的值在 ireturn 指令执行前会被重新读到栈顶,作为返回值。这时如果没有异常,则执行 4-5 行,把 x 赋值为 3,然后返回前面保存的 1,方法结束。如果出现异常,读取异常表发现应该执行第 8行,pc 寄存器指针转向 8 行,8-16 行就是把 2 赋值给 x,然后把 x 暂存起来,再将 x 赋值为 3,然后将暂存的 2 读到操作栈顶返回。第 17-19 行是把 x 赋值为 3,第 20-21 行是将异常放置于栈顶并抛出,方法结束。

3.Java 异常处理的基本原则

在异常处理的整个过程中,需要初始化新的异常对象,从调用栈返回,而且还需要沿着方法的调用链来传播异常以便找到它的异常处理器,因此,相较于普通代码异常处理通常需要消耗更多的时间和资源。为了保证代码的质量,有一些原则需要遵守。

1. 细化异常的类型,避免过度泛化

因版权问题,查看全文请移步GitChat,原文链接地址:Java 进阶之路:异常处理的内在原理及优雅的处理方式》,https://gitbook.cn/gitchat/activity/5c867a1d0bda8f2bb35634b0,或者微信扫码参与

 

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jin_Kwok

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值