本文结合《Effective Java》第九章《异常》和自己的理解及实践,讲解了正确使用Java异常的优秀指导原则,文章发布于专栏Effective Java,欢迎读者订阅。
充分发挥异常的优点,可以提高程序的可读性、可靠性和可维护性,如果使用不当,则会带来负面影响,下面几条清单,希望能帮助您正确使用Java异常。
清单1: 只针对异常的情况才使用异常
曾经有个哥们把异常当做流程控制的工具,写了这么一段代码:
try {
int[] arr = {1,2,3,4,5};
int i = 0;
while(true){
System.out.println(arr[i++]);
}
} catch (ArrayIndexOutOfBoundsException e) {
}
而程序员都知道,标准的循环模式应该是这样的:
for(int j : arr){
System.out.println(j);
}
写出第一种写法的程序员,往往秉持着这样子的观点:标准的循环模式每次都会去检查角标是否越界,效率更慢。
对此,我们可以做一个小实验,测试一下两种方式的速度:
private static void test1() {
int size = 100000;
int[] arr = new int[size];
for(int i=0;i<size;i++){
arr[i] = i;
}
long beginTime = System.currentTimeMillis();
// use try-catch cycle
try {
int i = 0;
while(true){
System.out.println(arr[i++]);
}
} catch (ArrayIndexOutOfBoundsException e) {
}
// use for-each cycle
// for(int j : arr){
// System.out.println(j);
// }
long endTime = System.currentTimeMillis();
System.out.println(endTime-beginTime);
}
在我的机器上,十万个元素的数组,for循环每次都比try-catch要快几十毫秒。
对此,《Effective Java》给出的解释是:
异常机制设计初衷是用于不正常的情形,很少有JVM会对异常进行优化,也就是说,把代码放在try-catch块中,会阻止JVM对代码的优化;
标准模式下,并不会导致冗余的检查,很多JVM都会对这种代码进行优化。
而且基于异常的循环,还会掩盖掉代码中的bug,假如代码中确实访问了越界的元素,那么正常流程会抛出异常,打印堆栈,提供定位的线索,而基于异常的循环,则会将这个bug悄悄的掩盖掉。
因此,异常只应该用于异常的情况,不应该用于控制流。
这个例子也启发我们,设计良好的API,不应该强迫调用者使用异常来进行正常的控制流程。比如Iterator接口,提供了一个hasNext方法,来让我们在循环之前判断能不能继续遍历,而不是等到遍历时抛异常再来进行循环的终止。
清单2: 对可恢复的情况使用受检异常,对编程错误使用运行时异常
所有的异常都是throwable的,throwable下面有三种结构:受检异常、运行时异常和错误。
如果期望调用者在遇到异常后可以进行恢复的操作,那么使用受检异常,也就是 xxx extends Exception的异常。例子:数据库断连,抛出受检异常,让调用者再去尝试连接。
如果不希望调用者进行恢复,想直接中断程序的运行,则抛出运行时异常,也就是 xxx extends RuntimeException的异常。例子:数组越界异常,属于代码中没有做好充足的校验,访问了越界的元素。
对于错误,我们不需要自己去实现Error的子类,Jdk里面已经定义了所有的错误,当我们需要自定义未受检异常时,应该去继承RuntimeException,而不是Error.
清单3: 避免不必要的使用受检异常
如果一个API抛出了受检异常,那么就要求调用者在它的代码中使用try-catch捕获或者使用throws向上抛,这都会给调用者带来麻烦。
有一个办法可以避免不必要的受检异常,那就是清单1提到过的状态检查方法,比如对于下面这段try-catch代码:
try {
obj.action(args);
} catch(CheckedException e) {
....
}
我们可以在调用action方法前,提供一个辅助的校验方法:
if(obj.actionPermmited(args)) {
obj.action(args);
} else {
}
当然,如果是以下两种情况,那么这种方法就不适用:
- 对象在缺陷外部同步的情况下被并发访问,那么在actionPermmited和action之间,对象状态可能被改变,不再满足校验,那么action方法同样会执行失败。
- actionPermmited方法必须重复action方法的操作,那么从性能上考虑,这种重构就不值得。
清单4: 优先使用标准异常
Java平台提供了一组基本的未受检异常,它们满足了绝大多数API的异常抛出需要,所以在创建自己的异常时,先考虑一下现有的异常是否满足需要。
下面是这些常用的异常列表:
- IllegalArgumentException 非null的参数值不满足API需要
- IllegalStateException 对象状态不合适
- NullPointerException 在禁止使用null的情况下使用了null的参数
- IndexOutOfBoundsException 下标越界
- ConcurrentModificationException 在禁止并发修改的情况下,检测到对象的并发修改
- UnsupportedOperationException 不支持的操作方法
比如,我们开发了一个纸牌游戏,客户端传入一个值为99的纸牌,不在我们1~15的范围(姑且认为 14小王 15大王),我们可以抛出IllegalArgumentException;再比如,客户端传来一只K和一只大王的组合牌,那么我们可以抛出UnsupportedOperationException。
清单5: 抛出与抽象相对应的异常
如果方法抛出的异常与它所执行的任务没有明显的联系,那么这种异常会让调用者不知所措,这时候就需要进行异常转译,以JDK的AbstractSequentialList类为例:
/**
* Returns the element at the specified position in this list.
*
* <p>This implementation first gets a list iterator pointing to the
* indexed element (with <tt>listIterator(index)</tt>). Then, it gets
* the element using <tt>ListIterator.next</tt> and returns it.
*
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
public E get(int index) {
try {
return listIterator(index).next();
} catch (NoSuchElementException exc) {
throw new IndexOutOfBoundsException("Index: "+index);
}
}
get方法是通过一个角标去获取对象,如果不进行异常转译,直接抛出NoSuchElementException,显然不合适,因此在捕获了NoSuchElementException之后,进行了异常的转译,抛出IndexOutOfBoundsException。
清单6: 方法抛出的每个异常都要有文档说明
Java语言要求程序员为每个方法声明它可能抛出的受检异常,但是对于未受检的异常,却没有这个要求。
然而,同受检异常一样,给每个可能的未受检异常创建Javadoc文档说明,也是很有必要的,可以帮助调用者避免犯错误。
异常的文档格式很简单,在专栏之前的一篇文章中提到过:Java 设计方法的五条优秀实践清单 —— 为每个方法编写Javadoc文档注释
大致的格式就是 @throws + 异常类型 + 什么情况下会抛出此异常
/**
* Returns the element at the specified position in this list.
*
This method is thread-safe, and it will start a thread.
* @param index index of the element to return
* @return the element at the specified position in this list
* @throws IndexOutOfBoundsException if the index is out of range
* (<tt>index < 0 || index >= size()</tt>)
*/
清单7: 在异常的消息中包含能定位失败原因的信息
这一点,在项目后期维护中尤为重要,很多问题并非每次都能重现,也许运行一百万次才能重现一次,这时候,如果异常的信息不能充分到能够帮助程序员分析出问题的原因,那就麻烦大了。
为了能够定位出失败的原因,异常的消息中应该包括所有对造成该异常有关的参数和域的值。比如对于IndexOutOfBoundsException,必要的信息就包括:下界、上界和导致异常的下标值。
清单8: 努力使失败保持原子性
对于受检异常,我们都希望对象保持在调用前的状态,具有这种属性的方法,我们称之为失败原子性。
实现失败原子性的方法有以下几种:
- 设计一个不可变对象(参考 Java 设计类和接口的八条优秀实践清单 —— 清单3 使类的可变性最小化),如果对象是不可变的,那么失败原子性就是必然的了。
- 执行状态修改操作前进行有效性检查,如果检查失败,就不进行状态的修改。
- 调整处理过程的顺序,把有可能失败的操作放在前面,把会修改对象状态的操作放在后面。
- 写一段恢复代码,在失败后将对象回滚到之前的状态。
- 在对象的临时拷贝上进行操作,操作成功再把对象指向拷贝对象。
清单9: 不要忽略异常
我们经常会见到这样的代码,在catch代码块里没有做任何操作:
try {
} catch (SomeException e) {
//do nothing
}
我的建议是,即使不需要做恢复动作,那么也应该将错误打印的日志中。
以上,希望能对您正确的使用Java异常有所帮助。