6.3 异常处理
6.3 异常处理
在了解了异常的基本概念和异常类之后,我们来看Java语言对异常处理的支持,包括catch、throw、finally、try-with-resources和throws,最后对比受检和未受检异常。
6.3.1 catch匹配
在代码清单6-1中,我们简单演示了使用try/catch捕获异常,其中catch只有一条,其实,catch还可以有多条,每条对应一种异常类型。示例如下面代码所示:
1 | try{ |
异常处理机制将根据抛出的异常类型找第一个匹配的catch块,找到后,执行catch块内的代码,不再执行其他catch块,如果没有找到,会继续到上层方法中查找。需要注意的是,抛出的异常类型是catch中声明异常的子类也算匹配,所以需要将最具体的子类放在前面,如果基类Exception放在前面,则其他更具体的catch代码将得不到执行。
上述示例也演示了对异常信息的利用,e.getMessage()获取异常消息,e.printStackTrace()打印异常栈到标准错误输出流。这些信息有助于理解为什么会出现异常,这是解决编程错误的常用方法。示例是直接将信息输出到标准流上,实际系统中更常用的做法是输出到专门的日志中。
在示例中,每种异常类型都有单独的catch语句,如果多种异常处理的代码是类似的,这种写法比较烦琐。自Java 7开始支持一种新的语法,多个异常之间可以用“|”操作符,形如:
1 | try { |
6.3.2 重新抛出异常
在catch块内处理完后,可以重新抛出异常,异常可以是原来的,也可以是新建的,如下所示:
1 | try{ |
对于Exception,在打印出异常栈后,就通过throw e重新抛出了。
而对于NumberFormatException,重新抛出了一个AppException,当前Exception作为cause传递给了AppException,这样就形成了一个异常链,捕获到AppException的代码可以通过getCause()得到NumberFormatException。
为什么要重新抛出呢?因为当前代码不能够完全处理该异常,需要调用者进一步处理。
为什么要抛出一个新的异常呢?当然是因为当前异常不太合适。不合适可能是信息不够,需要补充一些新信息;还可能是过于细节,不便于调用者理解和使用,如果调用者对细节感兴趣,还可以继续通过getCause()获取到原始异常。
6.3.3 finally
异常机制中还有一个重要的部分,就是finally。catch后面可以跟finally语句,语法如下所示:
1 | try{ |
finally内的代码不管有无异常发生,都会执行,具体来说:
- 如果没有异常发生,在try内的代码执行结束后执行。
- 如果有异常发生且被catch捕获,在catch内的代码执行结束后执行。
- 如果有异常发生但没被捕获,则在异常被抛给上层之前执行。
由于finally的这个特点,它一般用于释放资源,如数据库连接、文件流等。
finally语句有一个执行细节,如果在try或者catch语句内有return语句,则return语句在finally语句执行结束后才执行,但finally并不能改变返回值,我们来看下面的代码:
1 | public static int test(){ |
这个函数的返回值是0,而不是2。实际执行过程是:在执行到try内的return ret;语句前,会先将返回值ret保存在一个临时变量中,然后才执行finally语句,最后try再返回那个临时变量,finally中对ret的修改不会被返回。
如果在finally中也有return语句呢?try和catch内的return会丢失,实际会返回finally中的返回值。finally中有return不仅会覆盖try和catch内的返回值,还会掩盖try和catch内的异常,就像异常没有发生一样,比如:
1 | public static int test(){ |
以上代码中,5/0会触发ArithmeticException,但是finally中有return语句,这个方法就会返回2,而不再向上传递异常了。finally中,如果finally中抛出了异常,则原异常也会被掩盖,看下面的代码:
1 | public static void test(){ |
finally中抛出了RuntimeException,则原异常ArithmeticException就丢失了。所以,一般而言,为避免混淆,应该避免在finally中使用return语句或者抛出异常,如果调用的其他代码可能抛出异常,则应该捕获异常并进行处理。
6.3.4 try-with-resources
对于一些使用资源的场景,比如文件和数据库连接,典型的使用流程是首先打开资源,最后在finally语句中调用资源的关闭方法,针对这种场景,Java 7开始支持一种新的语法,称之为try-with-resources,这种语法针对实现了java.lang.AutoCloseable接口的对象,该接口的定义为:
1 | public interface AutoCloseable { |
没有try-with-resources时,使用形式如下:
1 | public static void useResource() throws Exception { |
使用try-with-resources语法,形式如下:
1 | public static void useResource() throws Exception { |
资源r的声明和初始化放在try语句内,不用再调用finally,在语句执行完try语句后,会自动调用资源的close()方法。
资源可以定义多个,以分号分隔。在Java 9之前,资源必须声明和初始化在try语句块内,Java 9去除了这个限制,资源可以在try语句外被声明和初始化,但必须是final的或者是事实上final的(即虽然没有声明为final但也没有被重新赋值)。
6.3.5 throws
异常机制中,还有一个和throw很像的关键字throws,用于声明一个方法可能抛出的异常,语法如下所示:
1 | public void test() throws AppException, |
throws跟在方法的括号后面,可以声明多个异常,以逗号分隔。这个声明的含义是,这个方法内可能抛出这些异常,且没有对这些异常进行处理,至少没有处理完,调用者必须进行处理。这个声明没有说明具体什么情况会抛出什么异常,作为一个良好的实践,应该将这些信息用注释的方式进行说明,这样调用者才能更好地处理异常。
对于未受检异常,是不要求使用throws进行声明的,但对于受检异常,则必须进行声明,换句话说,如果没有声明,则不能抛出。
对于受检异常,不可以抛出而不声明,但可以声明抛出但实际不抛出。这主要用于在父类方法中声明,父类方法内可能没有抛出,但子类重写方法后可能就抛出了,子类不能抛出父类方法中没有声明的受检异常,所以就将所有可能抛出的异常都写到父类上了。
如果一个方法内调用了另一个声明抛出受检异常的方法,则必须处理这些受检异常,处理的方式既可以是catch,也可以是继续使用throws,如下所示:
1 | public void tester() throws AppException { |
对于test抛出的SQLException,这里使用了catch,而对于AppException,则将其添加到了自己方法的throws语句中,表示当前方法处理不了,继续由上层处理。
6.3.6 对比受检和未受检异常
通过以上介绍可以看出,未受检异常和受检异常的区别如下:受检异常必须出现在throws语句中,调用者必须处理,Java编译器会强制这一点,而未受检异常则没有这个要求。
为什么要有这个区分呢?我们自己定义异常的时候应该使用受检还是未受检异常呢?对于这个问题,业界有各种各样的观点和争论,没有特别一致的结论。
一种普遍的说法是:未受检异常表示编程的逻辑错误,编程时应该检查以避免这些错误,比如空指针异常,如果真的出现了这些异常,程序退出也是正常的,程序员应该检查程序代码的bug而不是想办法处理这种异常。受检异常表示程序本身没问题,但由于I/O、网络、数据库等其他不可预测的错误导致的异常,调用者应该进行适当处理。
但其实编程错误也是应该进行处理的,尤其是Java被广泛应用于服务器程序中,不能因为一个逻辑错误就使程序退出。所以,目前一种更被认同的观点是:Java中对受检异常和未受检异常的区分是没有太大意义的,可以统一使用未受检异常来代替。
这种观点的基本理由是:无论是受检异常还是未受检异常,无论是否出现在throws声明中,都应该在合适的地方以适当的方式进行处理,而不只是为了满足编译器的要求盲目处理异常,既然都要进行处理异常,受检异常的强制声明和处理就显得烦琐,尤其是在调用层次比较深的情况下。
其实观点本身并不太重要,更重要的是一致性,一个项目中,应该对如何使用异常达成一致,并按照约定使用。