阅读 643

Java 的异常处理机制

异常是日常开发中大家都「敬而远之」的一个东西,但实际上几乎每种高级程序设计语言都有自己的异常处理机制,因为无论你是多么厉害的程序员,都不可避免的出错,换句话说:你再牛逼,你也有写出 Bug 的时候。

而所谓的「异常处理机制」就是能够在你出现逻辑错误的时候,尽可能的为你返回出错信息以及出错的代码大致位置,方便你排查错误。

同时,你也不必把异常想的太高深,它只是一段错误的提示信息,只是你的程序在运行过程中的一些逻辑错误被虚拟机检查出来了,它封装了错误信息并向你「报告」而已,而具体你如何处理,取决于你。

异常的继承体系结构

Java 中,类 Throwable 是整个异常处理机制的最高父类,它有两个子类 Error 和 Exception,分别代表着「错误」和「异常」。

而我们平常总是在说的异常其实指的是 Exception,因为错误是我们程序员不可控制的,往往是由于虚拟机内部出现问题导致的,例如:内存不足导致栈空间溢出,虚拟机运行故障等。

一般这种情况,虚拟机将直接线程终止,并通过 Error 及其子类对象回调错误信息。因此,我们只关注能够被我们控制的 Exception 及其子类的异常。

image

我们的 Exception 异常主要分为两类,一类是 IOException(I/O 输入输出异常),另一类是 RuntimeException(运行时异常)。其中 IOException 及其子类异常又被称作「受查异常」,RuntimeException 被称作「非受查异常」。

所谓受查异常就是指,编译器在编译期间要求必须得到处理的那些异常。举个例子:你写一段代码读写文件,而编译器认为读写文件很可能遇到文件不存在的情况,于是强制你写一段代码处理文件不存在的异常情况。

而这里的文件不存在异常就是一个受查异常,你必须在编译期处理了。

而我们的 RuntimeException 之所以叫做运行时异常,就是因为编译器也不知道你的代码会出现哪些问题,于是就不强制你处理异常了,等到运行期间,如果出现异常,虚拟机会回调错误信息的。

当然,如果你预判你的代码会出现某个异常,你也可以自己进行捕获处理,但话又说回来了,如果你知道某个位置可能有问题,你干嘛不直接给它解决了呢。

所以,运行时异常就是不可知的异常,并不强制你处理。

自定义异常类型

Java 的异常机制中所定义的所有异常不可能预见所有可能出现的错误,某些特定的情境下,则需要我们自定义异常类型来向上报告某些错误信息。

而自定义异常类型也是相当简单的,你可以选择继承 Throwable,Exception 或它们的子类,甚至你不需要实现和重写父类的任何方法即可完成一个异常类型的定义。

例如:

public class MyException extends RuntimeException{

}
复制代码
public class MyException extends Exception{

}
复制代码

当然,如果你想要为你的异常提供更多的信息,你也可以重写多个重载构造器,例如:

public class MyException extends RuntimeException{
    public MyException(){}

    public MyException(String mess){
        super(mess);
    }

    public MyException(String mess,Throwable cause){
        super(mess,cause);
    }
}
复制代码

我们知道,任意的一个异常类型,无论是 Java API 中的,或是我们自定义的,它们必然会直接或间接继承 Throwable 类。

而这个 Throwable 类定义了一个 String 类型的 detailMessage 字段存储的由子类传入有关子类异常的详细信息。例如:

public static void main(String[] args) {
    throw new MyException("hello wrold failed");
}
复制代码

输出结果:

Exception in thread "main" test.exception.MyException: hello wrold failed
	at test.exception.Test.main(Test.java:7)
复制代码

每当程序遇到一个异常后,Java 会像创建其他对象一样创建一个异常类型的对象,并存储在堆中,接着异常机制接管程序,首先检索当前方法的异常表是否能匹配到该异常(异常表中保存了当前方法已经处理的所有异常集合)。

如果匹配到一个异常表中的异常,那么将根据异常表中保存的异常处理的相关信息,跳转到处理该异常的字节码位置继续执行。

否则,虚拟机将终止当前方法的调用并弹栈弹出该方法的栈帧,返回该方法的调用处,继续检索调用者的异常表能够匹配到该异常的处理。

如果一直无法匹配,最终整个方法调用链中涉及到的所有方法都会弹栈,不会得到正常运行,并且最后虚拟机将打印这个异常的错误信息。

这就是大致的一个异常出现到最终得到处理的一个过程,足以见得,如果一个异常得到了处理,那么程序将得到恢复并能够继续执行,否则的话所有涉及该异常的方法都将被终止运行。

至于这个异常信息的内容,我们看看 printStackTrace 方法的具体实现:

image

image

总共有三个部分的信息,第一部分由异常的名称及其 detailMessage 构成,第二部分是异常的调用链信息,由上往下的是异常的发生位置到外层方法的调用点,第三部分则是引起该异常的源异常。

异常的处理方式

关于异常的处理方式,想必大家最熟悉的就是 try-catch 了吧,try-catch 的基本语法格式如下:

try{
    //你的程序
}catch(xxxException e){
    //异常处理代码
}catch(xxxException e){
    //异常处理代码
}
复制代码

try 代码块中代码我们又称作「监控区域」,catch 代码块我们称作「异常处理区域」。其中,每一个 catch 代码块对应于一种异常处理,该异常将被保存在方法的异常表中,一旦 try 代码块中产生任何的异常,异常处理机制都会先从异常表检索是否有处理该异常的代码块。

准确来说,异常表保存的已处理异常块只能用于处理我们 try 块中的代码,别处的相同异常不会被匹配处理。

当然,除此之外,我们处理异常还有一种方式,抛出异常。例如:

public static void main(String[] args){
    try{
        calculate(22,0);
    }catch (Exception e){
        System.out.println("捕获一个异常");
        e.printStackTrace();
    }
}

public static void calculate(int x,int y){
    if (y == 0) 
        throw new MyException("除数为 0");
    int z = x/y;
}
复制代码

输出结果:

捕获一个异常
test.exception.MyException: 除数为 0
	at test.exception.Test_throw.calculate(Test_throw.java:14)
	at test.exception.Test_throw.main(Test_throw.java:6)
复制代码

我们可以使用 throw 关键字手动抛出一个异常,这种情况往往是被调用者无力处理某个异常,需要抛给调用者自己处理。

显然,这种抛出异常的方式算细致的了,并且需要程序员有一定的预判,Java 里还有另一种抛出异常的方式,看:

public static void calculate2(int x,int y) throws ArithmeticException{
    int z = x/y;
}
复制代码

这种方式比较「粗暴」,我不管你什么位置会出现异常,只要你遇到 ArithmeticException 类型的异常,你就给我抛出去。

其实第二种本质上和第一种也是一样的,虚拟机在进行 x/y 的时候,当发现 y 等于零,也会 new 一个 ArithmeticException 的对象,然后程序交给异常机制。

但是后者却比前者省事,不用关心你哪个位置会出现异常,也不需要手动做判断,一切都交给虚拟机好了。但是显然的不足点就是有关异常的控制权不在自己手上,某些自定义的异常虚拟机在运行的时候无法判断。

就比如,假如我们这里的 calculate2 方法不允许 y 等于 1,如果等于 1 就要抛一个 MyException 异常。这种情况,后者怎么也无法实现,因为除数为 1 在虚拟机看来根本不存在任何问题,你叫它如何抛出一个异常。而用前者手动抛一个异常是再简单不过的事情了。

但是,你必须明确一点的是,无论是使用 throw 手动向上抛出一个异常,还是使用 throws 让虚拟机为我们动态抛出一个异常,你总是需要在某个位置处理这个异常的,这一点需要明确。

不是说你的垃圾你不想清理,你就扔给你前桌的同学,你前桌也不想清理,就一直往前扔,但最前面那个人总要处理的吧,不然你就等着你们班主任清理完后来收拾你们了。

try-catch-finally 的执行顺序

try-catch-finally 执行顺序的相关问题可以说是各种面试中的「常客」了,尤其是 finally 块中带有 return 语句的情况。我们直接看几道面试题:

面试题一:

public static void main(String[] args){
    int result = test1();
    System.out.println(result);
}

public static int test1(){
    int i = 1;
    try{
        i++;
        System.out.println("try block, i = "+i);
    }catch(Exception e){
        i--;
        System.out.println("catch block i = "+i);
    }finally{
        i = 10;
        System.out.println("finally block i = "+i);
    }
    return i;
}
复制代码

大家不妨算一算程序员最终运行的结果是什么。

输出结果如下:

try block, i = 2
finally block i = 10
10
复制代码

这算一个相当简单的问题了,没有坑,下面我们稍微改动一下:

public static int test2(){
    int i = 1;
    try{
        i++;
        throw new Exception();
    }catch(Exception e){
        i--;
        System.out.println("catch block i = "+i);
    }finally{
        i = 10;
        System.out.println("finally block i = "+i);
    }
    return i;
}
复制代码

输出结果如下:

catch block i = 1
finally block i = 10
10
复制代码

运行结果想必也是意料之中吧,程序抛出一个异常,然后被本方法的 catch 块捕获并进行了处理。

面试题二:

public static void main(String[] args){
    int result = test3();
    System.out.println(result);
}

public static int test3(){
    //try 语句块中有 return 语句时的整体执行顺序
    int i = 1;
    try{
        i++;
        System.out.println("try block, i = "+i);
        return i;
    }catch(Exception e){
        i ++;
        System.out.println("catch block i = "+i);
        return i;
    }finally{
        i = 10;
        System.out.println("finally block i = "+i);
    }
}
复制代码

输出结果如下:

try block, i = 2
finally block i = 10
2
复制代码

是不是有点疑惑?明明我 try 语句块中有 return 语句,可为什么最终还是执行了 finally 块中的代码?

我们反编译这个类,看看这个 test3 方法编译后的字节码的实现:

0: iconst_1         //将 1 加载进操作数栈
1: istore_0         //将操作数栈 0 位置的元素存进局部变量表
2: iinc          0, 1   //将局部变量表 0 位置的元素直接加一(i=2)
5: getstatic     #3     // 5-27 行执行的 println 方法                
8: new           #5                  
11: dup
12: invokespecial #6                                                     
15: ldc           #7 
17: invokevirtual #8                                                     
20: iload_0         
21: invokevirtual #9                                                     24: invokevirtual #10                
27: invokevirtual #11                 
30: iload_0         //将局部变量表 0 位置的元素加载进操作栈(2)
31: istore_1        //把操作栈顶的元素存入局部变量表位置 1 处
32: bipush        10 //加载一个常量到操作栈(10)
34: istore_0        //将 10 存入局部变量表 0 处
35: getstatic     #3  //35-57 行执行 finally中的println方法             
38: new           #5                  
41: dup
42: invokespecial #6                  
45: ldc           #12                 
47: invokevirtual #8                  
50: iload_0
51: invokevirtual #9                
54: invokevirtual #10                 
57: invokevirtual #11                 
60: iload_1         //将局部变量表 1 位置的元素加载进操作栈(2)
61: ireturn         //将操作栈顶元素返回(2)
-------------------try + finally 结束 ------------
------------------下面是 catch + finally,类似的 ------------
62: astore_1
63: iinc          0, 1
.......
.......
复制代码

从我们的分析中可以看出来,finally 代码块中的内容始终会被执行,无论程序是否出现异常的原因就是,编译器会将 finally 块中的代码复制两份并分别添加在 try 和 catch 的后面。

可能有人会所疑惑,原本我们的 i 就被存储在局部变量表 0 位置,而最后 finally 中的代码也的确将 slot 0 位置填充了数值 10,可为什么最后程序依然返回的数值 2 呢?

仔细看字节码,你会发现在 return 语句返回之前,虚拟机会将待返回的值压入操作数栈,等待返回,即使 finally 语句块对 i 进行了修改,但是待返回的值已经确实的存在于操作数栈中了,所以不会影响程序返回结果。

面试题三:

public static int test4(){
    //finally 语句块中有 return 语句
    int i = 1;
    try{
        i++;
        System.out.println("try block, i = "+i);
        return i;
    }catch(Exception e){
        i++;
        System.out.println("catch block i = "+i);
        return i;
    }finally{
        i++;
        System.out.println("finally block i = "+i);
        return i;
    }
}
复制代码

运行结果:

try block, i = 2
finally block i = 3
3
复制代码

其实你从它的字节码指令去看整个过程,而不要单单四记它的执行过程。

image

你会发现程序最终会采用 finally 代码块中的 return 语句进行返回,而直接忽略 try 语句块中的 return 指令。

最后,对于异常的使用有一个不成文的约定:尽量在某个集中的位置进行统一处理,不要到处的使用 try-catch,否则会使得代码结构混乱不堪


文章中的所有代码、图片、文件都云存储在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

欢迎关注微信公众号:扑在代码上的高尔基,所有文章都将同步在公众号上。

image

关注下面的标签,发现更多相似文章
评论