Java基础——如何优雅地处理异常

Java
331
0
0
2023-06-11
标签   Java基础

说起优雅,不知道为什么,我脑补的场景是这样的:

说起优雅的反面,不优雅,我首先想到的是这位:

希望 费天王 还能回归赛场,伤病快点好,说回正题

1 基本概括

2 主要介绍

2.1 异常处理机制

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

抛出异常

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

  throw new FileNotFoundException()  

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

声明异常

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

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

暗含了两方面的意思:

第一,该方法要抛出 FileNotFoundException 类型的异常;

第二,除了 FileNotFoundException 外不能(根据规范)抛出其它的异常。

那么,如何保证没有除 File NotFoundException 之外的任何异常被抛出呢?很显然,方式有:

通过合理的设计和编码避免出现其它异常;

如果其它异常不可完全避免(如方法内调用的其它方法明确可能出现异常),就需要 try-catch 其它的异常。

简而言之,一般情况下,方法不抛出哪些异常就要在方法内部 try-catch 这些异常。

捕获异常

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

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

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

2.2 处理异常的三大原则

Java中异常提供了一种识别及响应错误情况的一致性机制,有效地异常处理能使程序更加健壮、易于调试。异常之所以是一种强大的调试手段,在于其回答了以下三个问题:

1、什么出了错?

2、在哪出的错?

3、为什么出错?

在有效使用异常的情况下,异常类型回答了“什么”被抛出,异常堆栈跟踪回答了“在哪“抛出,异常信息回答了“为什么“会抛出,如果你的异常没有回答以上全部问题,那么可能你没有很好地使用它们。有三个原则可以帮助你在调试过程中最大限度地使用好异常,这三个原则是:

1、具体明确

2、提早抛出

3、延迟捕获

2.2.1 具体明确

Java定义了一个异常类的层次结构,其以Throwable开始,扩展出Error和Exception,而Exception又扩展出RuntimeException.如图1所示.

图1.Java异常层次结构

这四个类是泛化的,并不提供多少出错信息,虽然实例化这几个类是语法上合法的(如:new Throwable()),但是最好还是把它们当虚基类看,使用它们更加特化的子类。Java已经提供了大量异常子类,如需更加具体,你也可以定义自己的异常类。

例 如:java.io package包中定义了Exception类的子类IOException,更加特化确的是 FileNotFoundException, EOF Exception和ObjectStreamException这些IOException的子 类。每一种都描述了一类特定的I/O错误:分别是文件丢失,异常文件结尾和错误的序列化对象流.异常越具体,我们的程序就能更好地回答”什么出了错”这个 问题。

捕获异常时尽量明确也很重要。例如:JCheckbook可以通过重新询问用户文件名来处理FileNotFoundException,对于 EOFException,它可以根据异常抛出前读取的信息继续运行。如果抛出的是ObjectStreamException,则程序应该提示用户文件 已损坏,应当使用备份文件或者其他文件。

Java让明确捕获异常变得容易,因为我们可以对同一try块定义多个catch块,从而对每种异常分别进行恰当的处理。

 File prefsFile = new File(prefsFilename);
try{
    readPreferences(prefsFile);
}
catch (FileNotFoundException e){
    // alert the user that the specified file
    // does not exist
}
catch (EOFException e){
    // alert the user that the end of the file
    // was reached
}
catch (ObjectStreamException e){
     // alert the user that the file is corrupted
}
catch (IOException e){
    // alert the user that some other I/O
    // error occurred
}  

JCheckbook 通过使用多个catch块来给用户提供捕获到异常的明确信息。举例来说:如果捕获了FileNotFoundException,它可以提示用户指定另一 个文件,某些情况下多个catch块带来的额外编码工作量可能是非必要的负担,但在这个例子中,额外的代码的确帮助程序提供了对用户更友好的响应。

除前三个catch块处理的异常之外,最后一个catch块在IOException抛出时给用户提供了更泛化的错误信息.这样一来,程序就可以尽可能提供具体的信息,但也有能力处理未预料到的其他异常。

有时开发人员会捕获范化异常,并显示异常类名称或者打印堆栈信息以求"具体"。千万别这么干!用户看到java.io.EOFException或者堆栈信息 只会头疼而不是获得帮助。应当捕获具体的异常并且用"人话"给用户提示确切的信息。不过,异常堆栈倒是可以在你的日志文件里打印。记住,异常和堆栈信息是用来帮助开发人 员而不是用户的。

最后,应该注意到JCheckbook并没有在readPreferences()中捕获异常,而是将捕获和处理异常留到用户界面层来做,这样就能用对话框或其他方式来通知用户。这被称为"延迟捕获",下文就会谈到。

2.2.2 提早抛出

异常堆栈信息提供了导致异常出现的方法调用链的精确顺序,包括每个方法调用的类名,方法名,代码文件名甚至行数,以此来精确定位异常出现的现场。

 java.lang.NullPointerException
at java.io.FileInputStream.open(Native Method)
at java.io.FileInputStream.<init>(FileInputStream.java:)
at jcheckbook.JCheckbook.readPreferences(JCheckbook.java:)
at jcheckbook.JCheckbook.startup(JCheckbook.java:)
at jcheckbook.JCheckbook.<init>(JCheckbook.java:)
at jcheckbook.JCheckbook.main(JCheckbook.java:)  

以上展示了 FileInputStream 类的open()方法抛出NullPointerException的情况。不过注意 FileInputStream.close()是标准Java类库的一部分,很可能导致这个异常的问题原因在于我们的代码本身而不是Java API。所以问题很可能出现在前面的其中一个方法,幸好它也在堆栈信息中打印出来了。

不幸的是,NullPointerException是Java中信息量最少的(却也是最常遭遇且让人崩溃的)异常。它压根不提我们最关心的事情:到底哪里是null。所以我们不得不回退几步去找哪里出了错。

通过逐步回退跟踪堆栈信息并检查代码,我们可以确定错误原因是向readPreferences()传入了一个空文件名参数。既然readPreferences()知道它不能处理空文件名,所以马上检查该条件:

 public void readPreferences(String filename)
throws IllegalArgumentException{
    if (filename == null){
         throw new IllegalArgumentException("filename is null");
    }  //if
   //...perform other operations...
    InputStream  in = new FileInputStream(filename);
   //...read the preferences file...
}  

通过提早抛出异常(又称"迅速失败"),异常得以清晰又准确。堆栈信息立即反映出什么出了错(提供了非法参数值),为什么出错(文件名不能为空值),以及哪里出的错(readPreferences()的前部分)。这样我们的堆栈信息就能如实提供:

 java.lang.IllegalArgumentException: filename is null
at jcheckbook.JCheckbook.readPreferences(JCheckbook.java:)
at jcheckbook.JCheckbook.startup(JCheckbook.java:)
at jcheckbook.JCheckbook.<init>(JCheckbook.java:)
at jcheckbook.JCheckbook.main(JCheckbook.java:)  

另外,其中包含的异常信息("文件名为空")通过明确回答什么为空这一问题使得异常提供的信息更加丰富,而这一答案是我们之前代码中抛出的NullPointerException所无法提供的。

通过在检测到错误时立刻抛出异常来实现迅速失败,可以有效避免不必要的对象构造或资源占用,比如文件或网络连接。同样,打开这些资源所带来的清理操作也可以省却。

2.2.3 延迟捕获

菜鸟和高手都可能犯的一个错是,在程序有能力处理异常之前就捕获它。 Java编译器 通过要求检查出的异常必须被捕获或抛出而间接助长了这种行为。自然而然的做法就是立即将代码用try块包装起来,并使用catch捕获异常,以免编译器报错。

问题在于,捕获之后该拿异常怎么办?最不该做的就是什么都不做。空的catch块等于把整个异常丢进黑洞,能够说明何时何处为何出错的所有信息都会永远丢失。把异常写到日志中还稍微好点,至少还有记录可查。但我们总不能指望用户去阅读或者理解日志文件和异常信息。让readPreferences()显示错误信息对话框也不合适,因为虽然JCheckbook目前是桌面应用程序,但我们还计划将它变成基于HTML的Web应用。那样的话,显示错误对话框显然不是个选择。同时,不管HTML还是C/S版本,配置信息都是在服务器上读取的,而错误信息需要显示给 Web浏览器 或者客户端程序。 readPreferences()应当在设计时将这些未来需求也考虑在内。适当分离用户界面代码和程序逻辑可以提高我们代码的可重用性。

在有条件处理异常之前过早捕获它,通常会导致更严重的错误和其他异常。例如,如果上文的readPreferences()方法在调用FileInputStream构造方法时立即捕获和记录可能抛出的FileNotFoundException,代码会变成下面这样:

 public void readPreferences(String filename){
   //...
   InputStream in = null;
   // DO NOT DO THIS!!!
try{
    in = new FileInputStream(filename);
}
catch (FileNotFoundException e){
    logger.log(e);
}
in.read(...);
//...
}  

上面的代码在完全没有能力从FileNotFoundException中恢复过来的情况下就捕获了它。如果文件无法找到,下面的方法显然无法读取它。如果 readPreferences()被要求读取不存在的文件时会发生什么情况?当然,FileNotFoundException会被记录下来,如果我们 当时去看日志文件的话,就会知道。然而当程序尝试从文件中读取数据时会发生什么?既然文件不存在,变量in就是空的,一个 NullPointerException就会被抛出。

调试程序时,本能告诉我们要看日志最后面的信息。那将会是NullPointerException,非常让人讨厌的是这个异常非常不具体。错误信息不仅误导我们什么出了错(真正的错误是FileNotFoundException而不是NullPointerException),还误导了错误的出处。真正 的问题出在抛出NullPointerException处的数行之外,这之间有可能存在好几次方法的调用和类的销毁。我们的注意力被这条小鱼从真正的错误处吸引了过来,一直到我们往回看日志才能发现问题的源头。

既然readPreferences() 真正应该做的事情不是捕获这些异常,那应该是什么?看起来有点有悖常理,通常最合适的做法其实是什么都不做,不要马上捕获异常。把责任交给 readPreferences()的调用者,让它来研究处理配置文件缺失的恰当方法,它有可能会提示用户指定其他文件,或者使用默认值,实在不行的话也 许警告用户并退出程序。

把异常处理的责任往调用链的上游传递的办法,就是在方法的throws子句声明异常。在声明可能抛出的异常时,注意越具体越好。这用于标识出调用方法的程序需要知晓并且准备处理的异常类型。例如,“延迟捕获”版本的readPreferences()可能是这样的:

 public void readPreferences(String filename)
throws IllegalArgumentException,
FileNotFoundException, IOException{
    if (filename == null){
           throw new IllegalArgumentException("filename is null");
     }  //if
     //...
     InputStream in = new FileInputStream(filename);
//...
}  

2.3 异常处理的建议

2.4.1 只针对不正常的情况才使用异常

建议:异常只应该被用于不正常的条件,它们永远不应该被用于正常的控制流。通过比较下面的两份代码进行说明。

2.4.2 对于可恢复的条件使用被检查的异常,对于程序错误使用运行时异常

运行时异常 — RuntimeException类及其子类都被称为运行时异常。

被检查的异常 — Exception 类本身,以及Exception的子类中除了”运行时异常”之外的其它子类都属于被检查异常。

区别:

Java编译器会对”被检查的异常”进行检查,而对”运行时异常”不会检查 也就是说,对于被检查的异常,要么通过throws进行声明抛出,要么通过try-catch进行捕获处理,否则不能通过编译。而对于运行时异常,倘若既”没有通过throws声明抛出它”,也”没有用try-catch语句捕获它”,还是会编译通过。当然,虽说Java编译器不会检查运行时异常,但是,我们同样可以通过throws对该异常进行说明,或通过try-catch进行捕获。 ArithmeticException (例如,除数为0),IndexOutOfBoundsException(例如,数组越界)等都属于运行时异常。对于这种异常,我们应该通过修改代码进行避免它的产生。而对于被检查的异常,则可以通过处理让程序恢复运行。例如,假设因为一个用户没有存储足够数量的前,所以他在企图在一个收费电话上进行呼叫就会失败;于是就将一个被检查异常抛出。

2.4.3 避免不必要的使用被检查的异常

”被检查的异常”是Java语言的一个很好的特性。与返回代码不同,”被检查的异常”会强迫程序员处理例外的条件,大大提高了程序的可靠性。 但是,过分使用被检查异常会使API用起来非常不方便。如果一个方法抛出一个或多个被检查的异常,那么调用该方法的代码则必须在一个或多个catch语句块中处理这些异常,或者必须通过throws声明抛出这些异常。 无论是通过catch处理,还是通过throws声明抛出,都给程序员添加了不可忽略的负担。

适用于”被检查的异常”必须同时满足两个条件:

第一,即使正确使用API并不能阻止异常条件的发生。

第二,一旦产生了异常,使用API的程序员可以采取有用的动作对程序进行处理。

2.4.4 尽量使用标准的异常

代码重用是值得提倡的,这是一条通用规则,异常也不例外。重用现有的异常有几个好处:

它使得你的API更加易于学习和使用,因为它与程序员原来已经熟悉的习惯用法是一致的。

对于用到这些API的程序而言,它们的可读性更好,因为它们不会充斥着程序员不熟悉的异常。

异常类越少,意味着内存占用越小,并且转载这些类的时间开销也越小。

Java标准异常中有几个是经常被使用的异常。如下表格:

异常

使用场合

IllegalArgumentException

参数的值不合适

IllegalStateException

参数的状态不合适

NullPointerException

在null被禁止的情况下参数值为null

IndexOutOfBoundsException

下标越界

ConcurrentModificationException

在禁止并发修改的情况下,对象检测到并发修改

UnsupportedOperationException

对象不支持客户请求的方法

虽然它们是Java平台库迄今为止最常被重用的异常,但是,在许可的条件下,其它的异常也可以被重用。例如,如果你要实现诸如复数或者矩阵之类的算术对象,那么重用ArithmeticException和NumberFormatException将是非常合适的。如果一个异常满足你的需要,则不要犹豫,使用就可以,不过你一定要确保抛出异常的条件与该异常的文档中描述的条件一致。这种重用必须建立在语义的基础上,而不是名字的基础上!

最后,一定要清楚,选择重用哪一种异常并没有必须遵循的规则。例如,考虑 纸牌 对象的情形,假设有一个用于发牌操作的方法,它的参数(handSize)是发一手牌的纸牌张数。假设调用者在这个参数中传递的值大于整副牌的剩余张数。那么这种情形既可以被解释为IllegalArgumentException(handSize的值太大),也可以被解释为IllegalStateException(相对客户的请求而言,纸牌对象的纸牌太少)。

2.4.5 抛出的异常要适合于相应的抽象

如果一个方法抛出的异常与它执行的任务没有明显的关联关系,这种情形会让人不知所措。当一个方法传递一个由低层抽象抛出的异常时,往往会发生这种情况。这种情况发生时,不仅让人困惑,而且也”污染”了高层API。 为了避免这个问题,高层实现应该捕获低层的异常,同时抛出一个可以按照高层抽象进行介绍的异常。这种做法被称为”异常转译(exception translation)”。

例如,在Java的集合框架AbstractSequentialList的get()方法如下(基于JDK1.7.0_40):

 public E get(int index) {
    try {
        return listIterator(index).next();
} catch (NoSuchElementException exc) {
       throw new IndexOutOfBoundsException("Index: "+index);
   }
 }  


listIterator(index)会返回ListIterator对象,调用该对象的next()方法可能会抛出NoSuchElementException异常。而在get()方法中,抛出NoSuchElementException异常会让人感到困惑。所以,get()对NoSuchElementException进行了捕获,并抛出了IndexOutOfBoundsException异常。即,相当于将NoSuchElementException转译成了IndexOutOfBoundsException异常。

2.4.6 每个方法抛出的异常都要有文档

要单独的声明被检查的异常,并且利用 Javadoc 的@throws标记,准确地记录下每个异常被抛出的条件。

如果一个类中的许多方法处于同样的原因而抛出同一个异常,那么在该类的文档注释中对这个异常做文档,而不是为每个方法单独做文档,这是可以接受的。

2.4.7 在细节消息中包含失败 — 捕获消息

简而言之,当我们自定义异常或者抛出异常时,应该包含失败相关的信息。

当一个程序由于一个未被捕获的异常而失败的时候,系统会自动打印出该异常的栈轨迹。在栈轨迹中包含该异常的字符串表示。典型情况下它包含该异常类的类名,以及紧随其后的细节消息。

2.4.8 努力使失败保持原子性

当一个对象抛出一个异常之后,我们总期望这个对象仍然保持在一种定义良好的可用状态之中。对于被检查的异常而言,这尤为重要,因为调用者通常期望从被检查的异常中恢复过来。

一般而言,一个失败的方法调用应该使对象保持在”它在被调用之前的状态”。具有这种属性的方法被称为具有”失败原子性(failure atomic)”。可以理解为,失败了还保持着原子性。对象保持”失败原子性”的方式有几种:

设计一个非可变对象。

对于在可变对象上执行操作的方法,获得”失败原子性”的最常见方法是,在执行操作之前检查参数的有效性。如下(Stack.java中的pop方法):

 public Object pop() {
     if (size==)
         throw new EmptyStackException();
     Object result = elements[--size];
     elements[size] = null;
     return result;
 }  

与上一种方法类似,可以对计算处理过程调整顺序,使得任何可能会失败的计算部分都发生在对象状态被修改之前。

编写一段恢复代码,由它来解释操作过程中发生的失败,以及使对象回滚到操作开始之前的状态上。

在对象的一份临时拷贝上执行操作,当操作完成之后再把临时拷贝中的结果复制给原来的对象。

虽然”保持对象的失败原子性”是期望目标,但它并不总是可以做得到。例如,如果多个线程企图在没有适当的同步机制的情况下,并发的访问一个对象,那么该对象就有可能被留在不一致的状态中。

即使在可以实现”失败原子性”的场合,它也不是总被期望的。对于某些操作,它会显著的增加开销或者复杂性。

总的规则是:作为方法规范的一部分,任何一个异常都不应该改变对象调用该方法之前的状态,如果这条规则被违反,则API文档中应该清楚的指明对象将会处于什么样的状态。

2.4.9 不要忽略异常

当一个API的设计者声明一个方法会抛出某个异常的时候,他们正在试图说明某些事情。所以,请不要忽略它!忽略异常的代码如下:

  try {
    ...
} catch (SomeException e) {
}  

空的catch块会使异常达不到应有的目的,异常的目的是强迫你处理不正常的条件。忽略一个异常,就如同忽略一个火警信号一样 — 若把火警信号器关闭了,那么当真正的火灾发生时,就没有人看到火警信号了。所以,至少catch块应该包含一条说明,用来解释为什么忽略这个异常是合适的。

2.4.10 切忌使用空catch块

在捕获了异常之后什么都不做,相当于忽略了这个异常。千万不要使用空的catch块,空的catch块意味着你在程序中隐藏了错误和异常,并且很可能导致程序出现不可控的执行结果。如果你非常肯定捕获到的异常不会以任何方式对程序造成影响,最好用Log日志将该异常进行记录,以便日后方便更新和维护。

2.4.11 注意catch块的顺序

不要把上层类的异常放在最前面的catch块。比如下面这段代码:

2.4.12 不要将提供给用户看的信息放在异常信息里

2.4.13 避免多次在日志信息中记录同一个异常

只在异常最开始发生的地方进行日志信息记录。很多情况下异常都是层层向上跑出的,如果在每次向上抛出的时候,都Log到日志系统中,则会导致无从查找异常发生的根源。

2.4.14 异常处理尽量放在高层进行

尽量将异常统一抛给上层调用者,由上层调用者统一之时如何进行处理。如果在每个出现异常的地方都直接进行处理,会导致程序异常处理流程混乱,不利于后期维护和异常错误排查。由上层统一进行处理会使得整个程序的流程清晰易懂。

2.4.15 在finally中释放资源

如果有使用文件读取、网络操作以及数据库操作等,记得在finally中释放资源。这样不仅会使得程序占用更少的资源,也会避免不必要的由于资源未释放而发生的异常情况。