Java的异常处理机制

EndlessLethe原创文章,转载请注明: 转载自小楼吹彻玉笙寒

本文链接地址: Java的异常处理机制

前言

本文略去的内容:

  • Java异常简介
    Java异常是Java提供的一种识别及响应错误的机制。
  • 异常处理的基本语法
    try…catch…finally和throw、throws。

这些内容几乎在每一篇参考文献中都有详细的讲解。如果你想先了解关于异常的基础知识,随便挑一篇看就好,它们都是优秀的文章(虽然有的细枝末节存在错误,这也是本文存在的原因之一)。

另外一个原因就是,我想写一个足够全面的总结。

在这里,我先明确一下“处理异常”这个词的意思。它指的是:要么用try-catch捕获处理,要么throws。当然,最好避免直接throws,而是声明throws后依然catch,最后再throw。

Java异常的分类

Java异常的类间关系

Java exception class relationship

  1. Throwable
    Throwable是Java语言中所有错误或异常的超类,它有两个直接子类Error / Exception。
    只有当对象是此类(或其子类之一)的实例时,才能通过 Java虚拟机或者throw语句抛出。类似地,只有此类或其子类之一才可以是catch子句中的参数类型。
    Throwable包含了其线程创建时线程执行堆栈的快照,它提供了printStackTrace()等接口用于获取堆栈跟踪数据等信息。

  2. Error
    Error类,代表了JVM自身内部的错误。Error不能被程序员通过代码处理,也Error很少出现。
    一旦出现,除了通知用户,以及尽量稳妥地终止程序外,几乎什么也无法做(也不应该做什么)。

  3. Exception
    大部分的异常都属于Exception,它们描述的是程序运行过程中和外部环境所引起的错误(比如SQLException)。
    当它们被抛出时,需要程序立即捕捉和处理(do something或向上一层抛出)。

  4. RuntimeException
    RuntimeException是程序设计错误导致的,比如错误的类型转换、数组越界。
    所以,如果代码会产生RuntimeException异常,则需要通过修改代码来避免。

异常的两种类型

  1. Checked Exceptions 必检异常/可查异常
    除了Error 和 RuntimeException的其它异常。这样的异常一般是由程序的运行环境导致的,表示在运行过程中,出现了不能直接控制的无效外界情况(如用户输入IOException,数据库问题SQLException,网络异常,文件丢失ClassNotFoundException等)。

  2. Unchecked Exceptions 免检异常
    Error 和 RuntimeException 以及他们的子类。javac在编译时,不会提示和发现这样的异常,不要求在程序处理这些异常。否则整个Java代码会充斥着try-catch语句。

    对于这些异常,我们应该修正代码,而不是去通过异常处理器处理 。这样的异常发生的原因多半是代码写的有问题。比如,除0错误ArithmeticException,错误的强制类型转换错误ClassCastException,数组索引越界ArrayIndexOutOfBoundsException,使用了空对象NullPointerException等等。

类的来源有两个:一是Java运行时环境自动抛出系统生成的异常,而不管你是否愿意捕获和处理,它总要被抛出。二是程序员自己抛出的异常,这个异常可以是程序员自己定义的,也可以是Java语言中定义的,用throw 关键字抛出异常。

可查异常虽然是异常状况,但在一定程度上它的发生是可以预计的。

javac强制要求程序员为这样的异常做预备处理工作(使用try…catch…finally或者throws)。

注意事项

  • 检查和非检查是对于javac来说的,这样就很好理解和区分了。
  • 免检异常的免检指的是编译器不会去检查你是不是处理了抛出的异常,而不是说你不能处理免检异常。1

异常追踪栈

异常是在执行某个函数时引发的,而函数又是层级调用,形成调用栈的,因为,只要一个函数发生了异常,那么他的所有的caller都会被异常影响。当这些被影响的函数以异常信息输出时,就形成的了异常追踪栈。

异常最先发生的地方,叫做异常抛出点。
Java exception stack trace
从上面的例子可以看出,当devide函数发生除0异常时,devide函数将抛出ArithmeticException异常,因此调用他的CMDCalculate函数也无法正常完成,因此也发送异常,而CMDCalculate的caller——main 因为CMDCalculate抛出异常,也发生了异常,这样一直向调用栈的栈底回溯。这种行为叫做异常的冒泡,异常的冒泡是为了在当前发生异常的函数或者这个函数的caller中找到最近的异常处理程序。由于这个例子中没有使用任何异常处理机制,因此异常最终由main函数抛给JRE,导致程序终止。

异常追踪的具体流程

Java虚拟机用方法调用栈(method invocation stack)来跟踪每个线程中一系列的方法调用过程。该堆栈保存了每个调用方法的本地信息(比如方法的局部变量)。每个线程都有一个独立的方法调用栈。
对于Java应用程序的主线程,堆栈底部是程序的入口方法main()。当一个新方法被调用时,Java虚拟机把描述该方法的栈结构置入栈顶,位于栈顶的方法为正在执行的方法。

当一个方法正常执行完毕,Java虚拟机会从调用栈中弹出该方法的栈结构,然后继续处理前一个方法。如果在执行方法的过程中抛出异常,则Java虚拟机必须找到能捕获该异常的catch代码块。
它首先查看当前方法是否存在这样的catch代码块,如果存在,那么就执行该catch代码块;否则,Java虚拟机会从调用栈中弹出该方法的栈结构,继续到前一个方法中查找合适的catch代码块。

在回溯过程中,如果Java虚拟机在某个方法中找到了处理该异常的代码块,则该方法的栈结构将成为栈顶元素,程序流程将转到该方法的异常处理代码部分继续执行。

当Java虚拟机追溯到调用栈的底部的方法时,如果仍然没有找到处理该异常的代码块,按以下步骤处理。
(1)调用异常对象的printStackTrace()方法,打印来自方法调用栈的异常信息。
(2)如果该线程不是主线程,那么终止这个线程,其他线程继续正常运行。如果该线程是主线程(即方法调用栈的底部为main()方法),那么整个应用程序被终止。

不要随意使用异常

异常处理最根本的优势就是检测错误(由被调用的方法完成)从处理错误(由调用方法完成)中分离出来。这样,可以使程序更易读懂和修改。

但是,应该注意,由于异常处理需要初始化新的异常对象,需要从调用栈中返回,而且还需要沿着方法调用链来传播异常以便找到它的异常处理器,所以,异常处理通常需要更多的时间和资源。

当必须处理不可预料的错误状况时才应该使用它,不要用try-catch处理简单的,可预料的情况。

一定不要把异常处理用做简单的逻辑测试。

异常处理的机制

Java的异常处理模型基于三种操作:声明异常、抛出异常和捕捉异常。

1. try-catch

关键词try后的一对大括号将一块可能发生异常的代码包起来,称为监控区域。

Java方法在运行过程中出现异常,则创建异常对象。立即停止下一条指令的执行,并将异常抛出,由JVM试图寻找匹配的catch子句以捕获异常。
若有匹配的catch子句,则运行其catch中的代码。

一定不要使用一个空的catch段!

2. finally

finally块不管异常是否发生,只要对应的try执行了,则它一定也执行。
要注意,finally块没有处理异常的能力,只做异常出现后的扫尾工作。

在以下4种特殊情况下,finally块不会被完全执行:
1)在finally语句块中抛出了异常。
2)在前面的代码中用了System.exit()退出程序。
3)程序所在的线程死亡。
4)关闭CPU/关机。

3. throw

throw总是出现在函数体中,用来抛出一个Throwable类型的异常。
程序立即开始处理异常。

4. throws

throws声明要抛出的异常,即调用程序需要处理的异常。
它不同于try-catch-finally,throws仅仅是将函数中可能出现的异常向调用者声明,而自己则不具体处理。

采取这种异常处理的原因可能是:方法本身不知道如何处理这样的异常,或者说让调用者处理更好。

在我看来,决定采取try-catch还是throws,要根据解耦的原则来决定。而且,尽量对于throws的每一个异常都使用catch-throw语句。

异常的时序

  1. 当try没有捕获到异常时,try语句块中的语句逐一被执行,程序将跳过catch语句块,执行finally语句块和其后的语句。

  2. 当try捕获到异常,catch语句块里没有处理此异常的情况(也没有在throws里声明):此异常将会抛给JVM处理,finally语句块里的语句还是会被执行,但finally语句块后的语句不会被执行;

  3. 当try捕获到异常,catch语句块里有处理此异常的情况:程序将跳到catch语句块,并与catch语句块逐一匹配,找到与之对应的处理程序,其他的catch语句块将不会被执行。

    而try语句块中,出现异常之后的语句也不会被执行,catch语句块执行完后,执行finally语句块里的语句,最后执行finally语句块后的语句

  4. 在同一try…catch…finally块中,如果try中抛出异常,且有匹配的catch块,则先执行catch块,再执行finally块。如果没有catch块匹配,则先执行finally,然后去外面的调用者中寻找合适的catch块。

    在同一try…catch…finally块中,try发生异常,且匹配的catch块中处理异常时也抛出异常,那么后面的finally也会执行:首先执行finally块,然后去外围调用者中寻找合适的catch块。

  5. 如果没有抛出异常或结束函数调用,在finally块执行完毕后,程序将继续执行try-catch-finally块后的指令。

  6. 在try块中即便有return,break,continue等改变执行流的语句,finally也会执行。但对于return来说,在finally执行完毕后立即执行。

异常的链化

在一些大型的,模块化的软件开发中,一旦一个地方发生异常,则如骨牌效应一样,将导致一连串的异常。假设B模块完成自己的逻辑需要调用A模块的方法,如果A模块发生异常,则B也将不能完成而发生异常,但是B在抛出异常时,会将A的异常信息掩盖掉,这将使得异常的根源信息丢失。异常的链化可以将多个模块的异常串联起来,使得异常信息不会丢失。

从面向对象的角度来理解,异常转译使得异常类型与抛出异常的对象的类型位于相同的抽象层。例如:车子运行时会出现故障异常,而职工开车上班会出现迟到异常,车子的故障异常是导致职工的迟到异常的原因,如果员工直接抛出车子的故障异常,意味着车子故障是发生在职工身上的,这显然是不合理的,正确的做法是,将在职工类里发生的车子异常转译为迟到异常。

异常链化:以一个异常对象为参数构造新的异常对象。新的异对象将包含先前异常的信息。这项技术主要是异常类的一个带Throwable参数的函数来实现的。这个当做参数的异常,我们叫他根源异常(cause)。

查看Throwable类源码,可以发现里面有一个Throwable字段cause,就是它保存了构造时传递的根源异常参数。这种设计和链表的结点类设计如出一辙,因此形成链也是自然的了。

public class Throwable implements Serializable {
    private Throwable cause = this;

    public Throwable(String message, Throwable cause) {
        fillInStackTrace();
        detailMessage = message;
        this.cause = cause;
    }
     public Throwable(Throwable cause) {
        fillInStackTrace();
        detailMessage = (cause==null ? null : cause.toString());
        this.cause = cause;
    }

    //........
}

通过代码可以看出,我们可以简单通过下面语句来串联异常:

catch(IOException ioe) {
    throw new Exception("文件不存在", ioe);
}

自定义异常

如果要自定义异常类,则扩展Exception类即可,因此这样的自定义异常都属于检查异常(checked exception)。如果要自定义非检查异常,则扩展自RuntimeException。

按照国际惯例,自定义的异常应该总是包含如下的构造函数:
– 一个无参构造函数
– 一个带有String参数的构造函数,并传递给父类的构造函数。
– 一个带有String参数和Throwable参数,并都传递给父类构造函数
– 一个带有Throwable 参数的构造函数,并传递给父类的构造函数。

务必实现这四种构造函数!

获取异常信息

Throwable类中提供了一些方法来获取异常对象的有价值的信息,如下:

public String getMessage() //返回这个对象的消息
public String toString() //返回异常类的命名+":"+getMessage()
public void printStackTrace() //在控制台打印Throwable对象以及它的调用栈的跟踪信息
public StackTraceElement[] getStackTrace() //返回栈跟踪构成的数组来表示这个可抛出的栈跟踪信息

异常的注意事项

  1. 当子类重写父类的带有 throws声明的函数时,其throws声明的异常必须是父类异常的子集。

  2. try代码块后面可以只跟finally代码块。

  3. 有的编程语言当异常被处理后,控制流会恢复到异常抛出点接着执行,这种策略叫做:resumption model of exception handling(恢复式异常处理模式 )
    而Java则是让执行流恢复到处理了异常的catch块后接着执行,这种策略叫做:termination model of exception handling(终结式异常处理模式)

  4. try块中的局部变量和catch块中的局部变量(包括异常变量),以及finally中的局部变量,他们之间不可共享使用。

  5. 在catch条件匹配时,不仅支持精确匹配,也支持父类匹配。因此,如果同一个try块下的多个catch异常类型有父子关系,应该将子类异常放在前面,父类异常放在后面 。

  6. 当一个方法抛出一个异常时,Java虚拟机是用一段“异常处理器”的代码,沿着函数调用栈,逐个检查catch块,判断如何处理这个异常。

  7. Java程序可以是多线程的。每一个线程都是一个独立的执行流,独立的函数调用栈。如果程序只有一个线程,那么没有被任何代码处理的异常会导致程序终止。如果是多线程的,那么没有被任何代码处理的异常仅仅会导致异常所在的线程结束。

    也就是说,Java中的异常是线程独立的,不会直接影响到其它线程的执行。有的人说要在线程内解决,我觉得也看情况。比如在安卓开发中,SQL的函数调用通常会用一个ASyncTask来创建一个单独的线程,异常处理,一方面要在线程内关闭连接,另一方面主界面也要对异常有所反应。

  8. Java语言规范中描述道:如果一个catch子句要捕获一个类型为 E 的被检查异常, 而其相对应的try子句不能抛出 E 的某种子类型的异常,那么这就是一个编译期错误。

  9. 尽管在这一点上十分含混不清,但是捕获Exception或Throwble的catch子句是合法的,不管与其相对应的try子句的内容为何。

finally块和return

本来不是很想单独来说finally和return,因为finally块中本来就不应该有返回值。
Java引入异常的原因,就是要优化return“返回错误码”这个简陋的错误处理方式,并通过异常来完善错误处理的机制。
return语句出现时说明程序正常进行,不应该出现在finally里面。

下面是几个规则:
1. finally中的return 会覆盖 try 或者catch中的返回值。
2. finally中的return会覆盖(消灭)前面try或者catch块中的异常。
3. finally中的异常会覆盖(消灭)前面try或者catch中的异常。

具体例子见参考文献2

关于异常处理的几条建议

嗯因为不是本文的重点,关心的读者可以戳:Java异常(二) 《Effective Java》中关于异常处理的几条建议

参考文献

  1. 《Java语言程序设计》 10th Edition 第十二章
  2. Java中的异常和处理详解
  3. 深入理解java异常处理机制
  4. Unchecked Exception 和 Checked Exception 比较
  5. Java异常(一) Java异常简介及其架构
  6. java中的异常处理
  7. java学习笔记《面向对象编程》——异常处理
  8. Java异常(三) 《Java Puzzles》中关于异常的几个谜题

1 评论

发表评论

电子邮件地址不会被公开。 必填项已用*标注