札记:Java异常处理

时间:2023-02-09 21:37:22

异常概述

程序在运行中总会面临一些“意外”情况,良好的代码需要对它们进行预防和处理。大致来说,这些意外情况分三类:

  • 交互输入

    用户以非预期的方式使用程序,比如非法输入,不正当的操作顺序,输入文件错误等。
  • 软件和硬件环境问题

    文件不存在,文件格式错误,网络问题,存储空间不足,需要的预安装库不存在,系统版本不匹配等。
  • 代码错误

    使用的其它代码可能的执行错误,如调用了有关数学计算的方法中执行了除0操作等。

发现异常和处理异常都是困难的,需要非常严谨的代码。实际上,程序总是分层或分模块的,往往发生异常的地方和最终调用的地方“相距”甚远。而且,异常的处理有时需要通知用户,甚至需要用户来决定接下来的动作。又或者,程序运行在“后台”,对错误的只能是记录措施。异常发生后,有的情况是需要从错误的状态中恢复再继续执行,又或者是保存状态然后终止执行等。

有关异常的发现和预防是一个具体问题具体对待的经验之谈。对于异常处理框架,关键包括异常的表示、传递和捕获。接下来我们结合Java提供的异常处理机制来学习下如何在正常的程序逻辑中加入异常处理的代码。

Java中的异常处理机制

异常信息是为了通知更上层的方法调用者有关意外的情况,所以它有一个随方法调用栈向上传递的过程,异常信息会像返回值那样被层层上传,直到有方法处理它。

作为面向对象语言,Java提供给我们的几乎都是类、接口这些编程元素。异常处理也不例外,Java并不选择使用返回值来表示异常信息(因为有时返回值无法表达异常情况,而且会搞乱正常的返回值含意,想象下返回任意int值的方法。你依然可以对返回值做很多约定,使用参数来携带异常信息也是受限的),而是定义了Throwable相关的类层次来表示异常。这样可以保证正常代码执行的简明流程,而“异常发生”后将产生一个Throwable对象并随着调用栈向上传递,对应方法立即退出,没有任何返回值,调用方法的代码收到异常后继续退出并上传到更上层方法调用,或者捕获此异常。

接下来就依次来了解下Java异常框架提供的异常表示、传递和捕获处理相关的实现细节。

异常表示:Throwable继承层次

Java中的“异常”基类是Exception,表示可以被方法调用代码处理的可恢复意外情形的“异常信息”。它又继承自Throwable,Throwable是JVM可以throw(后面会讲到)的所有类的基类,用来随着方法调用栈向上传递表示产生此对象的方法的执行环境的信息,封装了一个字符串描述以及方法的调用栈信息。它的另一个子类是Error,它只能由Java运行时本身错误时被创建,我们的app不要去继承它,也无法处理它。

接下来所谈及的异常都是Exception的子类,不涉及Error。

札记:Java异常处理

Throwable类提供了有关异常的文本描述和调用堆栈:

public String getMessage();
public StackTraceElement[] getStackTrace();

getMessage返回的方法主要是便于调试追踪,如记录日志或者给用户看。而getStackTrace返回一个数组,StackTraceElement表示调用栈中一个调用的有关信息,如类名,方法名和语句的行号等。

Exception的子类有2个分支,RuntimeException是程序自身代码逻辑引起的异常,如NullPointerException、IndexOutOfBoundsException,基本上可避免。其它异常类表示有关运行时不可避免的意外,例如程序输入IOException、运行环境相关的非预期情况等。

从“含义”上去区分RuntimeException和非RuntimeException比较困难,另一个分类是,继承自Error和RuntimeException的类都是未检查(unchecked)异常,其它异常都是已检查(checked)异常。所以对Exception子类而言,可以分为运行时异常已检查异常。它们的使用以及编译器对待它们是不同的,后面会看到。

异常情形的表示尽量使用已有的“系统/框架”异常类,这样很容易获得“共识”。如果没有合适的异常类,就可以设计自己的Exception子类(可以继承某个已有异常类,或者设计自己的异常类层次,不过异常的层次不应该过于“深”,而应该保持扁平——更容易理解)。Exception类的代码很简短,这里直接给出:

/**
* {@code Exception} is the superclass of all classes that represent recoverable
* exceptions. When exceptions are thrown, they may be caught by application
* code.
*
* @see Throwable
* @see Error
* @see RuntimeException
*/
public class Exception extends Throwable {
private static final long serialVersionUID = -3387516993124229948L; /**
* Constructs a new {@code Exception} that includes the current stack trace.
*/
public Exception() {
} /**
* Constructs a new {@code Exception} with the current stack trace and the
* specified detail message.
*
* @param detailMessage
* the detail message for this exception.
*/
public Exception(String detailMessage) {
super(detailMessage);
} /**
* Constructs a new {@code Exception} with the current stack trace, the
* specified detail message and the specified cause.
*
* @param detailMessage
* the detail message for this exception.
* @param throwable
* the cause of this exception.
*/
public Exception(String detailMessage, Throwable throwable) {
super(detailMessage, throwable);
} /**
* Constructs a new {@code Exception} with the current stack trace and the
* specified cause.
*
* @param throwable
* the cause of this exception.
*/
public Exception(Throwable throwable) {
super(throwable);
}
}

根据规范,自己的异常类型需要提供一个无参构造器和一个接收String类型的异常描述信息参数的构造器。

class MyException extends Exception {
public MyException() {} public MyException(String detailMessage) {
super(detailMessage);
}
}

detailMessage提供了有关异常的描述信息,可以调用getMessage()获得它,对于调试非常有用。

上面的示例MyException继承自Exception,这样它就成为一个已检查异常,相反地,如果MyException继承自RuntimeException则它就成为一个未检查异常。在深入探讨异常的传递和捕获之前,可以简单地给出它们的区别:已检查异常是用来表示那些运行中不可避免又不可预期的输入、环境相关的异常,这些异常总是可能发生,因此必须显示地处理它们。一个方法如果会产生已检查异常,那么在通过编译前,就必须在方法声明部分一起使用throws关键字声明将可能抛出这个异常,声明意味着告诉调用方法在执行期间可能会抛出对应的异常对象。之后,调用者必须捕获此异常,或继续声明抛出此异常,因此已检查异常“显式地”完成了异常的上传,而且是编译器的要求。未检查异常则不需要显示地去捕获或声明,只会在运行期间被抛出,然后随调用栈上传。

一般来说,自己的程序应该将代码逻辑错误使用RuntimeException去表示,而涉及到输入、环境等不可控的必然因素使用已检查异常来表示。

异常的传递

知道如何表达异常信息后,接下来就是向上通知异常的发生。通知异常的方式就是使用throw关键字的语法“抛出”一个异常对象,过程是:

异常发生时,根据情况创建一个合适的异常类对象,因为异常类型是最终继承自Throwable的,它创建后就从线程获得了当前方法的调用栈信息。接着,可以为异常对象设置有关错误的描述,还可以增加额外字段携带必要的数据。最后执行throw语句:

MyException myEx = new MyException();
// ...设置异常信息
throw myEx;

抛出异常后,方法后续代码不再执行,方法立即向上传递异常对象,或者说“发生了异常”。

为了分析异常传递的过程,下面制造一个若干方法之间形成链式调用的案例,它是一个控制台程序:

public class ExceptionTest {
public static void main(String[] args) throws IOException {
new ExceptionTest().methodA();
} public void methodA() throws IOException {
methodB();
} public void methodB() throws IOException {
methodC();
} public void methodC() throws IOException {
methodD();
if (System.currentTimeMillis() % 4 == 0) {
throw new IOException();
}
} public void methodD() {
throw new RuntimeException();
}
}

如代码所示,方法methodA的执行最终依次执行方法methodB、methodC、methodD。

运行方法methodA()将获得如下的异常信息:

Exception in thread "main" java.lang.RuntimeException
at com.java.language.ExceptionTest.methodD(ExceptionTest.java:34)
at com.java.language.ExceptionTest.methodC(ExceptionTest.java:27)
at com.java.language.ExceptionTest.methodB(ExceptionTest.java:23)
at com.java.language.ExceptionTest.methodA(ExceptionTest.java:19)
at com.java.language.ExceptionTest.main(ExceptionTest.java:15)

methodD中产生异常,之后异常传递到调用methodA的main方法中,程序终止。

可能类似的打印信息我们见过不少次了,异常发生后方法调用栈的打印信息非常清晰地展示了此刻异常从methodD开始传递到main方法的经过的方法链。实际上任何时候都可以创建一个Throwable对象然后获得当前的调用栈信息:

public static String getStackTraceMsg(Throwable tr) {
if (tr == null) {
return "";
} StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
tr.printStackTrace(pw);
pw.flush();
return sw.toString();
}

更详细的调用栈信息可以通过Throwable类的public StackTraceElement[] getStackTrace()方法得到。

如果方法需要抛出已检查异常,如methodC()中会抛出IOException,那么它必须在方法声明中加入throws IOException语句,如果有多个已检查异常则对于类型使用逗号隔开,类似implements实现多个接口那样。

在了解如何捕获异常之前,可以看到,RuntimeException会随着方法调用栈依次上传,直到到达最终调用者。而已检查异常要求方法调用代码在编译前就声明继续抛出此异常(或者显示地捕获它)。

捕获并处理异常

现在在合适的地方抛出了异常,并且默认地,异常会随着方法调用栈依次向上传递,这样,任何方法都可以在异常发生后获得所调用的其它方法传递上来的异常对象了。

最后也是最重要的一步就是捕获并处理异常了。

try/catch语法正是用来完成异常的捕获的。在try块中执行的语句,如果产生了异常,则catch块会匹配该异常,如果产生的异常和catch捕获的异常类型匹配——异常是catch捕获的异常类型或者它的子类就判定为匹配——该异常就不再继续上传了,catch块中可以获得异常对象的信息,做相应的处理。

对于之前的案例,如果希望在方法methodB()中捕获methodD()抛出的运行时异常可以这么做:

public void methodB() throws IOException {
try {
// 其它代码
methodC();
} catch (RuntimeException e) {
// 这里处理异常.
}
}

再次运行上面的ExceptionTest.main方法,就不再出现异常打印信息了,因为运行时异常传递到 methodB()后被捕获了。

try块中发生异常后,try块中后续代码不再执行,接着会转到匹配的catch块中继续执行,如果没有任何匹配的catch则异常继续向上层方法传递。try块中的代码没有发生异常时,会正常执行所有语句,之后继续执行try/catch块后的其它代码。

一个try块可以对应多个catch,这是应对try中的语句可能产生多种不同类型异常的情况,此时的匹配规则是依次对各个catch块执行匹配,一旦匹配就由该catch块处理此异常。所以,在编写多个catch时,注意它们之间的继承结构所决定的不同的捕获范围:

try {
methodC();
} catch (Exception1 e1) {
// 处理异常1
} catch (Exception2 e2) {
// 处理异常2
} catch (Exception3 e3) {
// 处理异常3
} catch (Exception e) {
// 处理所有异常!!
}

注意catch块的顺序,避免前面的catch块总是捕获掉之后catch块可捕获的异常类型,这本身已经是逻辑错误了。

一个方法可以选择使用try/catch来捕获可能的运行时异常或已检查异常,尤其对于调用了可抛出已检查异常的方法时,必须显示地去捕获此异常,或者选择继续抛出这个已检查异常。可以想象,声明抛出已检查异常,从某种含义上也是一种处理,实际上如果当前方法并没有合适的处理方式时,就继续抛出异常,而不去捕获它。

finally块

如果方法有一些代码在异常发生与否时都需要一定执行到,可以为try/catch块添加finally块。注意finally块需要放在最后,如果没有catch块的话直接就是try/finally的结构:

try {
// 一些语句,有可能抛出异常
} finally {
// 一定会执行到
}

finally块中的代码保证无论是否发生异常也会执行,虽然可以选择在一个特别设计的catch中捕获任何异常来完成同样的目的,但是代码会很丑陋,需要在try和catch中同时包含相应的代码。

finally中的代码是在“最后”执行的,当发生异常后,catch块如果匹配,则对应的处理代码会被执行,最后继续执行完finally中的代码。如果没有异常发生,正常try中的代码执行完毕后,依然继续去执行finally中的代码。

下面的方法有兴趣可以试下,它实验了finally语句中有关return语句的逻辑:

private static String funnyMethod(boolean runCatch, boolean runFinally) {
try {
if (runCatch) {
throw new RuntimeException();
} else {
return "normal return";
}
} catch (RuntimeException e) {
return "catch return";
} finally {
if (runFinally) {
return "finally return";
}
}
}

return关键字实际上做了两件事情,设置方法的返回值,终止后续语句的执行。

在遇到一些资源必须被释放这样的情况时,就可以在finally中执行资源关闭这样的操作。注意这些操作如果继续产生异常的话,就try/catch执行它们。

更多要点

有关Java异常处理机制,还有很多细节上值得关注,下面是一个不完整的列表。

重写方法时声明已检查异常

当一个子类重写父类的方法时,它可以声明的已检查异常不能超出父类方法所声明的那些。这样,子类方法就需要显式地捕获语句中不可以抛出的已检查异常。声明的已检查异常必须比父类方法中声明的类型更具体化。

catch中再次抛出异常

catch块中的代码有可能再次抛出异常,所以有时需要在catch块内部使用try/catch结构。另一些情况下,我们需要主动在catch块在抛出异常。这有很多原因,例如当前方法的catch只是为了记录下日志,之后希望原始的异常继续传递。又或者自己的系统是分层或分模块的,这时需要对调用者抛出更有描述意义的异常,可以重新在catch中抛出自己定义了的异常类型的对象。

Throwable提供了initCause方法用来对异常设置相应的原始异常,之后捕获异常后调用getCause获得原始异常:

/**
* Initializes the cause of this {@code Throwable}. The cause can only be
* initialized once.
*
* @param throwable
* the cause of this {@code Throwable}.
* @return this {@code Throwable} instance.
* @throws IllegalArgumentException
* if {@code Throwable} is this object.
* @throws IllegalStateException
* if the cause has already been initialized.
*/
public Throwable initCause(Throwable throwable); /**
* Returns the cause of this {@code Throwable}, or {@code null} if there is
* no cause.
*/
public Throwable getCause();

所以,假设数据访问层方法收到了SQLException,那么可以重新创建一个抽象的数据访问的异常,把实际的SQLException设置为新异常的cause。

catch块的异常参数

当出现多个catch块时,catch(Exception ex)中的参数ex隐含为final变量,不可以对它赋值。

catch和finally中发生异常

catch和finally块中都有可能继续发生异常或主动抛出异常,这时如果try中已经有异常了,就会被覆盖掉。一般的做法是继续抛出try块中本身的异常,然后使用Throwable.addSuppressed(Throwable throwable)方法把后面的异常添加到“原”异常的“压制了的异常列表”中,调用者可以调用Throwable[] getSuppressed()方法获得这些“伴随”异常,如果需要的话。

避免不必要的异常

如果方法可以从约定上清晰的表达自己和调用者的各种使用规范,就不要去抛出异常。如果方法可以增加判断来避免异常发生,就增加这些判断。因为异常的产生会带来性能问题,尤其是已检查异常。

如果可以通过引用判断来执行不同的流程,那么要比发生NullPointerException后传递给调用者具有更好的执行效率。只在真正的异常情形下去抛出异常。

不要盲目的压制异常

很多人喜欢这样的代码:

try {
// 一些可能抛出各种各样异常的语句
} catch (Exception ex) {
// 什么也不做,吃掉所有异常,好像什么也没发生
}

过度捕获异常很可能使得最终代码的错误查找十分困难!数据和业务bug远比语法层面的逻辑错误隐晦得多。

压制不可能的异常

Java反射库中的很多方法声明了各种已检查异常,在实际使用时也许基本上是100%肯定不会发生这些异常的,那么就大胆的压制它们。否则随着方法调用的传递,其它更多方法被动的声明了那些完全不可能发生的异常。

早抛出,晚捕获

早抛出:异常抛出的地方应该足够及时,距离异常情形的原因最近的地方。所以方法一旦检测到异常操作,就立即抛出异常来通知调用者,否则在更上层方法中发生其它异常可能更难理解。可以参考下Stack.pop、peek等方法的设计。

晚捕获:异常的处理往往需要更上层的调用者才可以做出正确的决策,这时候框架中的方法就应该将异常传递出去,不要自己做任何不恰当的假设处理。尤其是那些和UI相关的操作。

异常类型的设计

尽量使用系统/框架已有的异常类型,减少没必要的代码沟通成本。

例外的情况是,自己的框架需要一套专有的异常继承结构,主要是区分开其它框架的异常。自己的异常类型中可以增加额外的信息,如对异常来源的统一描述等,但框架内部方法没必要舍弃合适的系统类型去增加重复概念。

参考资料

  • Java核心技术 卷1 基础知识 原书第9版

(本文使用Atom编写)