Java异常处理二
本篇文章来自 Effective Java 中异常处理的一些注意点
只针对异常的情况才使用异常
异常只应该被用于不正常的条件,它们永远不应该被用于正常的控制流。
- 异常机制的设计初衷是用于不正常的情况,所以很少会会JVM实现试图对它们的性能进行优化。所以,创建、抛出和捕获异常的开销是很昂贵的。
- 把代码放在try-catch中返回阻止了JVM实现本来可能要执行的某些特定的优化。
- 对数组进行遍历的标准模式并不会导致冗余的检查,有些现代的JVM实现会将它们优化掉。
举个例子
1 | try { |
1 | for (int i=0; i<arr.length; i++) { |
显然我们应该避免第一种代码的使用方式;这对API设计也有启发,设计良好的API不应该强迫客户端为了正常的控制流而使用异常。
如果类有“状态相关”的方法,也应该提供“状态测试”方法。例如,Iterator接口有一个“状态相关”方法next()
,和相应的”状态测试“方法hasNext()
。
对可恢复的情况使用受检异常,对编程错误使用运行时异常
- 运行时异常
RuntimeException类及其子类都被称为运行时异常。 - 被检查的异常
Exception的子类中除了”运行时异常”之外的其它子类都属于被检查异常。
它们的区别是:Java编译器会对”被检查的异常”进行检查,而对”运行时异常”不会检查。也就是说,对于被检查的异常,要么通过throws进行声明抛出,要么通过try-catch进行捕获处理,否则不能通过编译。而对于运行时异常,倘若既”没有通过throws声明抛出它”,也”没有用try-catch语句捕获它”,还是会编译通过。当然,虽说Java编译器不会检查运行时异常,但是,我们同样可以通过throws对该异常进行说明,或通过try-catch进行捕获。
ArithmeticException(例如,除数为0),IndexOutOfBoundsException(例如,数组越界)等都属于运行时异常。对于这种异常,我们应该通过修改代码进行避免它的产生。而对于被检查的异常,则可以通过处理让程序恢复运行。
避免不必要地使用受检的异常
“被检查的异常”是Java语言的一个很好的特性。与返回代码不同,”被检查的异常”会强迫程序员处理例外的条件,大大提高了程序的可靠性。
但是,过分使用被检查异常会使API用起来非常不方便。如果一个方法抛出一个或多个被检查的异常,那么调用该方法的代码则必须在一个或多个catch语句块中处理这些异常,或者必须通过throws声明抛出这些异常。无论是通过catch处理,还是通过throws声明抛出,都给程序员添加了不可忽略的负担。
适用于”被检查的异常”必须同时满足两个条件:
- 第一,即使正确使用API并不能阻止异常条件的发生。
- 第二,一旦产生了异常,使用API的程序员可以采取有用的动作对程序进行处理。
把受检的异常变成未受检的异常
“把受检的异常变成未受检的异常”的一种方法是,把抛出异常的方法分为两个方法,其中第一个方法返回一个boolean,表明是否抛出异常。例子如下:
1 | // 受检异常 |
注意,如果对象将在缺少外部同步的情况下并发访问,或可被外界改变状态,这种重构就是不恰当的。
优先使用标准的异常
代码重用是值得提倡的,异常也不例外。重用现有的异常有几个好处:
- 它使得你的API更加易于学习和使用,因为它与程序员已经熟悉的习惯用法是一致的。
- 对于用到这些API的程序而言,它们的可读性更好,因为它们不会充斥着程序员不熟悉的异常。
- 异常类越少,意味着内存占用越小,并且转载这些类的时间开销也越小。
Java标准异常中有几个是经常被使用的异常。如下表格:
异常 | 使用场合 |
---|---|
IllegalArgumentException | 参数的值不合适 |
IllegalStateException | 参数的状态不合适 |
NullPointerException | 在null被禁止的情况下参数值为null |
IndexOutOfBoundsException | 下标越界 |
ConcurrentModificationException | 在禁止并发修改的情况下,对象检测到并发修改 |
UnsupportedOperationException | 对象不支持客户请求的方法 |
在许可的条件下,其它的异常也可以被重用。例如,如果你要实现诸如复数或者矩阵之类的算术对象,那么重用ArithmeticException和NumberFormatException将是非常合适的。如果一个异常满足你的需要,则不要犹豫,使用就可以,不过你一定要确保抛出异常的条件与该异常的文档中描述的条件一致。这种重用必须建立在语义的基础上,而不是名字的基础上!
抛出与抽象相对应的异常
如果一个方法抛出的异常与它执行的任务没有明显的关联关系,这种情形会让人不知所措。当一个方法传递一个由低层抽象抛出的异常时,往往会发生这种情况。这种情况发生时,不仅让人困惑,而且也”污染”了高层API。
为了避免这个问题,高层实现应该捕获低层的异常,同时抛出一个可以按照高层抽象进行介绍的异常。这种做法被称为”异常转译(exception translation)”。
例如,在Java的集合框架AbstractSequentialList的get()方法如下:
1 | public E get(int index) { |
listIterator(index)会返回ListIterator对象,调用该对象的next()方法可能会抛出NoSuchElementException异常。而在get()方法中,抛出NoSuchElementException异常会让人感到困惑。所以,get()\对NoSuchElementException进行了捕获,并抛出了IndexOutOfBoundsException异常。即,相当于将NoSuchElementException转译成了IndexOutOfBoundsException异常。
一种特殊的异常转译形式称为异常链,如果底层的异常对于调试高层异常有帮助,异常链就很合适。低层的异常被传递到高层异常,高层异常提供访问方法Throwable.getCause()
。
1 | // 异常链 |
处理低层异常的最好做法是,在调用底层方法之前检查参数的有效性,保证会成功执行,从而避免抛出异常。
如果无法避免低层异常,还可以让更高层将高层调用和低层问题隔离开来,例如使用logging记录异常。
每个方法抛出的异常都要有文档
- 要单独的声明被检查的异常,并且利用Javadoc的
@throws
标记,准确地记录下每个异常被抛出的条件。这些异常将是方法成功执行的”前提条件”。 - 使用Javadoc的
@throws
标签记录下一个方法可能抛出的未受检异常,但是不要使用throws关键字将未受检的异常包含在方法的声明中。
如果一个类中的许多方法处于同样的原因而抛出同一个异常,那么在该类的文档注释中对这个异常做文档,而不是为每个方法单独做文档,这是可以接受的。
在细节消息中包含能捕获失败的信息
简而言之,当我们自定义异常或者抛出异常时,应该包含失败相关的信息。当一个程序由于一个未被捕获的异常而失败的时候,系统会自动打印出该异常的栈轨迹。在栈轨迹中包含该异常的字符串表示。典型情况下它包含该异常类的类名,以及紧随其后的细节消息。
努力使失败保持原子性
当一个对象抛出一个异常之后,我们总期望这个对象仍然保持在一种定义良好的可用状态之中。对于被检查的异常而言,这尤为重要,因为调用者通常期望从被检查的异常中恢复过来。
一般而言,一个失败的方法调用应该保持使对象保持在”它在被调用之前的状态”。具有这种属性的方法被称为具有”失败原子性(failure atomic)“。可以理解为,失败了还保持着原子性。对象保持”失败原子性”的方式有几种:
- 设计一个非可变对象。
- 对于在可变对象上执行操作的方法,获得”失败原子性”的最常见方法是,在执行操作之前检查参数的有效性。如Stack.java中的pop方法:
1 | public Object pop() { |
- 与上一种方法类似,可以对计算处理过程调整顺序,使得任何可能会失败的计算部分都发生在对象状态被修改之前。
- 编写一段恢复代码,由它来解释操作过程中发生的失败,以及使对象回滚到操作开始之前的状态上。
- 在对象的一份临时拷贝上执行操作,当操作完成之后再把临时拷贝中的结果复制给原来的对象。
虽然”保持对象的失败原子性”是期望目标,但它并不总是可以做得到。例如,如果多个线程企图在没有适当的同步机制的情况下,并发的访问一个对象,那么该对象就有可能被留在不一致的状态中。即使在可以实现”失败原子性”的场合,它也不是总被期望的。对于某些操作,它会显著的增加开销或者复杂性。
总的规则是:作为方法规范的一部分,任何一个异常都不应该改变对象调用该方法之前的状态,如果这条规则被违反,则API文档中应该清楚的指明对象将会处于什么样的状态。
不要忽略异常
当一个API的设计者声明一个方法会抛出某个异常的时候,他们正在试图说明某些事情。所以,请不要忽略它! 忽略异常的代码如下:
1 | try { |
空的catch块会使异常达不到应有的目的,异常的目的是强迫你处理不正常的条件。忽略一个异常,就如同忽略一个火警信号一样 – 若把火警信号器关闭了,那么当真正的火灾发生时,就没有人看到火警信号了。所以,至少catch块应该包含一条说明,用来解释为什么忽略这个异常是合适的。
Exception Puzzles in 《Java Puzzles》
极端不可思议
下面的三个程序每一个都会打印些什么? 不要假设它们都可以通过编译。
- 第一个程序
1 | import java.io.IOException; |
- 第二个程序
1 | public class Arcane2 { |
- 第三个程序
1 | interface Type1 { |
运行结果
- 第一个程序编译出错!
1 | Arcane1.java:9: exception java.io.IOException is never thrown in body of corresponding try statement |
- 第二个程序能正常编译和运行。
- 第三个程序能正常编译和运行。输出结果是: Hello world
结果说明
Arcane1展示了被检查异常的一个基本原则。它看起来应该是可以编译的:
try
子句执行I/O,并且catch
子句捕获IOException
异常。但是这个程序不能编译,因为println
方法没有声明会抛出任何被检查异常,而IOException
却正是一个被检查异常。语言规范中描述道:如果一个catch
子句要捕获一个类型为E的被检查异常,而其相对应的try
子句不能抛出E 的某种子类型的异常,那么这就是一个编译期错误。基于同样的理由,第二个程序,Arcane2,看起来应该是不可以编译的,但是它却可以。它之所以可以编译,是因为它唯一的
catch
子句检查了Exception
。尽管在这一点上十分含混不清,但是捕获Exception
或Throwble
的catch
子句是合法的,不管与其相对应的try
子句的内容为何。尽管 Arcane2是一个合法的程序,但是catch
子句的内容永远的不会被执行,这个程序什么都不会打印。第三个程序Arcane3,看起来它也不能编译。方法f在Type1接口中声明要抛出被检查异常
CloneNotSupportedException
,并且在Type2接口中声明要抛出被检查异常InterruptedException
。Type3接口继承了Type1和Type2,因此,看起来在静态类型为Type3的对象上调用方法 f 时, 有潜在可能会抛出这些异常。一个方法必须要么捕获其方法体可以抛出的所有被检查异常,要么声明它将抛出这些异常。Arcane3的main方法在静态类型为Type3的对象上调用了方法 f,但它对CloneNotSupportedException
和InterruptedExceptioin
并没有作这些处理。那么,为什么这个程序可以编译呢?上述分析的缺陷在于对“Type3.f 可以抛出在Type1.f 上声明的异常和在Type2.f上声明的异常”所做的假设。这并不正确,因为每一个接口都限制了方法f可以抛出的被检查异常集合。一个方法可以抛出的被检查异常集合是它所适用的所有类型声明要抛出的被检查异常集合的交集,而不是合集。 因此,静态类型为 Type3 的对象上的f方法根本就不能抛出任何被检查异常。因此,Arcane3可以毫无错误地通过编译,并且打印 Hello world。
不受欢迎的宾客
1 | public class UnwelcomeGuest { |
上面的代码执行结果是什么?
运行结果
1 | UnwelcomeGuest.java:10: variable USER_ID might already have been assigned |
结果说明
问题出在哪里了?USER_ID
域是一个空final(blank final)
,它是一个在声明中没有进行初始化操作的final域。很明显,只有在对USER_ID
赋值失败时,才会在try
语句块中抛出异常,因此,在catch
语句块中赋值是相当安全的。不管怎样执行静态初始化操作语句块,只会对USER_ID
赋值一次,这正是空final所要求的。为什么编译器不知道这些呢?
要确定一个程序是否可以不止一次地对一个空 final 进行赋值是一个很困难的问题。为了能够编写出一个编译器,语言规范在这一点上采用了保守的方式。在程序中,一个空 final 域只有在它是明确未赋过值的地方才可以被赋值。规范长篇大论,对此术语提供了一个准确的但保守的定义。 因为它是保守的,所以编译器必须拒绝某些可以证明是安全的程序。这个谜题就展示了这样的一个程序。幸运的是, 你不必为了编写 Java 程序而去学习那些骇人的用于明确赋值的细节。通常明确赋值规则不会有任何妨碍。如果碰巧你编写了一个真的可能会对一个空final赋值超过一次的程序,编译器会帮你指出的。只有在极少的情况下,就像本谜题一样, 你才会编写出一个安全的程序, 但是它并不满足规范的形式化要求。编译器的抱怨就好像是你编写了一个不安全的程序一样,而且你必须修改你的程序以满足它。
solution
解决这类问题的最好方式就是将这个烦人的域从空 final 类型改变为普通的final 类型,用一个静态域的初始化操作替换掉静态的初始化语句块。
1 | - private static final long USER_ID; |
您好,再见
1 | public class HelloGoodbye { |
运行结果
1 | Hello World |
结果说明
这个程序包含两个println
语句: 一个在try
语句块中, 另一个在相应的finally
语句块中。try
语句块执行它的println
语句,并且通过调用System.exit
来提前结束执行。在此时,你可能希望控制权会转交给finally
语句块。然而,如果你运行该程序,就会发现它永远不会说再见:它只打印了”Hello world”。这是否违背了”Indecisive示例”中所解释的原则呢? 不论try
语句块的执行是正常地还是意外地结束,finally
语句块确实都会执行。然而在这个程序中,try
语句块根本就没有结束其执行过程。System.exit
方法将停止当前线程和所有其他当场死亡的线程。finally
子句的出现并不能给予线程继续去执行的特殊权限。
当System.exit
被调用时,虚拟机在关闭前要执行两项清理工作。首先,它执行所有的关闭挂钩操作,这些挂钩已经注册到了Runtime.addShutdownHook
上。这对于释放VM之外的资源将很有帮助。务必要为那些必须在VM退出之前发生的行为关闭挂钩。下面的程序版本示范了这种技术,它可以如我们所期望地打印出”Hello world”和”Goodbye world”:
1 | public class HelloGoodbye1 { |
System.exit
将立即停止所有的程序线程,它并不会使finally
语句块得到调用,但是它在停止VM之前会执行关闭挂钩操作。当VM被关闭时,请使用关闭挂钩来终止外部资源。
不情愿的构造器
1 | public class Reluctant { |
运行结果
1 | Exception in thread "main" java.lang.StackOverflowError |
结果说明
main方法调用了Reluctant
构造器,它将抛出一个异常。你可能期望catch
子句能够捕获这个异常,并且打印”I told you so”。凑近仔细看看这个程序就会发现,Reluctant实例还包含第二个内部实例,它的构造器也会抛出一个异常。无论抛出哪一个异常,看起来main中的catch
子句都应该捕获它,因此预测该程序将打印”I told you”应该是一个安全的赌注。但是当你尝试着去运行它时,就会发现它压根没有去做这类的事情:它抛出了StackOverflowError
异常,为什么呢?
与大多数抛出StackOverflowError
异常的程序一样,本程序也包含了一个无限递归。当你调用一个构造器时,实例变量的初始化操作将先于构造器的程序体而运行。在本谜题中,internalInstance变量的初始化操作递归调用了构造器,而该构造器通过再次调用Reluctant构造器而初始化该变量自己的internalInstance域,如此无限递归下去。这些递归调用在构造器程序体获得执行机会之前就会抛出StackOverflowError
异常,因为StackOverflowError
是Error
的子类型而不是Exception
的子类型,所以catch
子句无法捕获它。对于一个对象包含与它自己类型相同的实例的情况,并不少见。例如,链接列表节点、树节点和图节点都属于这种情况。你必须非常小心地初始化这样的包含实例,以避免StackOverflowError
异常。