Java 异常处理机制与最佳实践

这周小组内的学习是探讨 java 异常处理的最佳实践,今天周末,外面太闷,宅在家里对 java 的异常处理的个人立即做一个总结,如有不对的地方欢迎指正~

一. 谈谈个人对 java 异常处理的看法

维基百科对于异常处理的定义是:

异常处理,是编程语言或计算机硬件里的一种机制,用于处理软件或信息系统中出现的异常状况(即超出程序正常执行流程的某些特殊条件)。

Java 语言从设计之初就提供了对异常处理的支持,并且不同于其它语言,java 对于异常采取了强校验机制,即对于编译期异常需要 API 调用方显式地对异常进行处理,这种强校验机制被一部分人所钟爱,也有一部分人狂吐槽它。持支持观点的人认为这种机制可以极大的提升系统的稳定性,当存在潜在异常的时候强制开发人员去处理异常,而反对的人则认为强制异常处理降低了代码的可读性,一串串的 try-catch 让代码看起来不够简洁,并且不正确的使用不仅达不到提升系统稳定性的目的,反而成为了 bug 的良好栖息之地。

java 的异常处理是一把双刃剑,这是我一向持有的观点。个人认为我们不能对 java 的异常处理片面的下一个好或者不好的定义,黑格尔说“存在即合理”,既然 java 的设计者强制要求我们去处理 java 的异常,那么与其在那里吐槽,还不如去学习如何用好 java 的异常处理,让其为提升程序的稳定性所服务。不过从笔者的亲身感受来看,用好 java 的异常处理门槛并不低!

二. java 的异常继承体系

image

上图展示了 java 的异常继承体系,Throwable 是整个 java 异常处理的最高抽象(但它不是抽象类哦),它实现了 Serializable 接口,然后 java 将异常分为了 Error 和 Exception 两大类。Error 定义了资源不足、约束失败、其它使程序无法继续执行的条件,一般我们在程序中不需要自己去定义额外的 Error,java 设计者提供的 Error 场景几乎覆盖了所有可预见的情况。当程序中存在 Error 时,我们也无须去处理它,因为这种错误一般都是致命的,让程序挂掉是最好的处理方法。

我们一般经常接触到的是 Exception,Error 被翻译为错误,而 Exception 则被翻译成异常,异常可以理解为是轻微的错误,很多时候是不致命的,我们可以 catch 之后,经过处理让程序继续执行。但有时候一些错误虽然是轻微的,但依靠程序本身也无力挽救,即错误和异常之间没有明显的边界,为了解决这个问题,Exception 被设计成了 CheckedException 和 UnCheckedException 两大类,我们可以把 UnCheckedException 看做是介于 Exception 和 Error 的中间产物,有时候它可以被 catch,有时候我们也希望它可以让程序立即挂掉。

  • CheckedException :也称作编译期异常,这类异常强制软件研发人员进行 catch 处理,如果不处理则无法编译通过,这类异常很多时候都是可以恢复的。
  • UnCheckedException :也称作运行时异常,这类异常不强制要求软件研发人员进行 catch 处理,如果不处理则出现该异常的时候程序会挂掉,这个时候有点接近于 Error,虽然不强制,我们也可以主动去 catch 这些异常,处理之后让程序继续执行,这个时候有点接近于一般的 Exception。

三. 最佳实践

建议 1:异常应该使用在程序会发生异常的地方

java 异常处理体系的缺点不光在于降低了程序的可读性,JVM 在对待有 try-catch 代码块的时候的时候往往不能更好的优化,甚至不优化,并且对于一个异常的处理在时间开销上相对是比较昂贵的,所以对于正常的情况,我们不应该使用异常机制去达到自己所揣测的 JVM 对于代码的优化,因为 JVM 在不断的发展和进步中,任何一种优化的教条都不能保证在未来的某个时刻不会被 JVM 用更好的方式替换掉,所以我们在编码的时候更多的应该是去专注代码的逻辑正确和简洁,而不是去琢磨如何编码才能让 JVM 更好地优化,此外我们也不应该用异常去做流程控制,因为前面说过,异常的处理过程开销是昂贵的。总的说来就是我们应该针对潜在异常的程序才使用 java 的异常处理机制,而对于正常的程序流程则不应该试图利用异常去达到某种目的,这样往往会弄巧成拙。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Benchmark
@BenchmarkMode(Mode.SampleTime)
public void badTry() {

int result = 0;
int i = 0;
try {
while (true) {
result += this.a[i++];
}
} catch (ArrayIndexOutOfBoundsException e) {
// do nothing
}

}

@Benchmark
@BenchmarkMode(Mode.SampleTime)
public void goodTry() {

int result = 0;
for (int i = 0; i < ARRAY_SIZE; i++) {
result += this.a[i];
}

}

上面两个函数都实现了对数组的遍历求和过程,但是两种方法使用不同的方式来结束这个过程,badTry 利用异常机制来终止遍历的过程,而 goodTry 则是我们常用的 foreach 迭代。咋看一眼,你可能会对 badTry 的用法感到不屑,谁特么会去这样写程序,但是如果对JVM的内部优化过程有一定了解的话,那么 badTry 的写法也似乎有那么点意思,首先我们来看一下利用 JMH 对这两个方法进行测试的时间开销:

1
2
3
4
5
6
# Run complete. Total time: 00:07:41

Benchmark Mode Cnt Score Error Units
EffectiveException.badTry sample 1819 117.413 ± 3.423 ms/op
EffectiveException.goodTry sample 5290340 ≈ 10⁻⁴ ms/op

我一般推荐大家在统计程序运行时间的时候用 JMH 工具,而不是利用 System.currentTimeMillis() 这种方式去做时间打点。JMH 是 java 的微基准测试框架,相对于时间打点的方式来说,JMH 统计的是程序预热之后 CPU 真正的执行时间,剔除了解释、等待 CPU 分配时间片等时间,所以统计结果更加具备真实性

回到正题,从上面 JMH 经过 200 次迭代的统计结果来看,goodTry 执行的时间约为 10⁻⁴ms,而 badTry 则耗费了117.413ms(正负偏差3.423ms),所以可以看异常处理开销还是比较昂贵的,对于这种正常的流程,我们利用异常的机制去控制程序的执行往往会适得其反。

对于数组的遍历,java 在每次访问元素的时候都会去检查一下数组下标是否越界,如果越界则会抛出 IndexOutOfBoundsException ,这样给 java 程序猿在编码上带来了便利,但是如果对于一个数组的频繁访问,那么这种边界检查将会是一笔不小的开销,但却是不能省去的步骤,为了提升执行效率,JVM 一般会采取如下优化措施:

  1. 如果下标是常量的情况,那么可以在编译器通过数据流分析就可以判定运行的时候是否需要检查
  2. 如果是在循环中利用变量去访问一个数组,那么JVM也可以在编译器分析循环的范围是否在数组边界之内,从而可以消除边界检查

上面例子中的 badTry 写法,编码者应该是希望利用 java 的 隐式异常处理机制 来提升程序的运行效率。

1
2
3
4
5
/*
* 用户调用
*/
obj.toString();

上面的代码为用户的一次普通调用 obj.toString(),对于该调用,java 会去判断是否存在空指针异常,所以JVM会将这部分代码编译成下面这个样子:

1
2
3
4
5
6
7
8
/*
* JVM编译
*/
if(null != obj) {
obj.toString();
} else {
throw new NullPointerException();
}

如果对于 obj.toString() 进行频繁的调用,那么这样的优化势必会造成每一次调用都要去判空,这可是一笔不小的开销,所以 JVM 会利用隐式异常处理机制对上面这部分代码进行再次优化:

1
2
3
4
5
6
7
8
9
10
11
12
/*
* JVM隐式异常优化
*/
try {
obj.toString();
} catch (segment_fault) { // Segment Fault信号异常处理器
/**
* 传入异常处理器中进行恢复并抛出NullPointerException
* 用户态->内核态->用户态
*/
exception_handler();
}

隐式异常处理通过异常机制来减免对于空指针的判定,也就是先执行代码主体,只要不抛异常就继续正常执行,一旦跑了异常说明 obj 为空指针,就转去处理异常,然后抛出 NullPointerException 来告知用户,这样就不用每次调用之前都去判空了。但是隐式异常也存在一个缺陷,如果上面的代码频繁的抛异常,那么每次 JVM 都要转去处理异常,然后再返回,这个过程是需要由用户态转到内核态处理,处理完成之后再返回用户态,最后向用户抛出 NullPointerException 的过程,这可比一次判空的代价要昂贵多了,所以对于频繁异常的情况,我们试图利用异常去控制流程是不合适的,就像我们在最开始的例子给出,当利用 ArrayIndexOutOfBoundsException 来结束数组遍历过程的开销是很大的,所以不要用异常去处理正常的执行流程,或者不要用异常去做流程控制。

建议 2:如果 API 的调用方能够很好的处理异常,就抛 checked exception,否则 unchecked exception 更加合适

对于 java 的 CheckedExceptionUnCheckedException,以及 Error,我们在使用的时候可能经常会去疑惑到底使用哪一种,我始终觉得这没有具体的教条可寻,比如哪种情况一定用哪一种异常,但是我们还是可以总结出一些基本的使用原则。

  1. 什么时候使用 Error?

Error一般被用于定义资源不足、约束失败、其它使程序无法继续执行的条件的场景,我们经常会在JVM中看到Error的情况,比如OOM,所以对于Error而言,一般不推荐在程序中去主动使用,也不推荐去实现自己的Error,对于有这种需求的情况我们完全可以利用UncheckedException代替。

  1. 什么时候使用 CheckedException?

对于 CheckedException 的使用,如果同时满足如下两条原则,则推荐使用,如果不能满足则使用 UnCheckedException 让程序早点挂掉应该是一种更好的选择:

  • API 的调用方正确的使用 API 不能阻止异常的发生
  • 一旦异常发生,调用方可以采取有效的应对措施

在设计 API 的时候,抛出 CheckedException 能够暗示调用者对异常手动处理,提升系统稳定性,但是如果调用方也不知道如果更好的处理,那么把异常抛给调用方也没有任何意义,而且 API 每抛出一个 CheckedException,也就意味着 API 调用方需要多一个 catch,则将让程序变的不够简洁。

有些情况下,我们在设计 API 时,可以通过一些技巧来避免抛出 CheckException。比如下面这段代码中,代码的意图在于设计了一个 File 类,而整个 File 类需要对外提供提供一个 exec 方法,但是在执行的时候需要验证该文件的 MD5 值是否正确。

方法 execShell 里面给出了两种方案:

  1. 只对外提供一个方法,在该方法中先验证 MD5 值,然后执行文件。在验证 MD5 值的时候会抛出 CheckedException,于是 exec 方法向外抛出了一个 CodeException,调用该函数的程序不得不去处理该异常。
  2. 对外提供了两个函数:isCheckSumRightexec2,前者执行 MD5 值验证逻辑,当验证通过则返回 true,否则返回 false,方法 exec2 则是执行主体。这样设计 API,调用时先调用 isCheckSumRight 方法,然后再调用 exec2 方法。

方案二可以免去 CheckedException,让程序更加美观,同时 API 也可以更加灵活。但是这样去重构有两个不太适用的场景,一个是当并发调用时,如果没有做线程安全控制会存在线程安全问题,因为在方法 isCheckSumRightexec2 之间的瞬间可能会发生状态的改变;另外一个就是如果拆分成两个函数之后,这两个函数之间有重复的逻辑,那么为了性能考虑,这样的拆分也不值得。

比如状态检查函数里面是检查一个文件是否可以被打开,如果可以被打开就在主体函数里面去执行读取文件操作,但是在主体文件中读取文件时我们仍然需要将文件打开一次,于是这个时候就存在了重复,降低了性能,为了代码的美观,去做这样的设计不是好的设计。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void execShell(File file) {

/*
* 方案一
*/
try {
file.exec();
} catch (CodecException e) {
// TODO: handle this exception
}

/*
* 方案二
* 不适用的情景:
* 1.没有同步措施的并发访问
* 2.状态检查的执行逻辑与正文函数重复
*/
if (file.isCheckSumRight()) {
file.exec2();
} else {
// TODO: do something
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class File<T> {

private T content;

private String md5Checksum;

public File(T content, String md5Checksum, boolean isCheckSum) {
this.content = content;
this.md5Checksum = md5Checksum;
}

/**
* 执行文件
* v1.0
*
* @throws CodecException
*/
public void exec() throws CodecException {

String md5Value;
try {
md5Value = CodecUtil.MD5(String.valueOf(this.content));
} catch (NoSuchAlgorithmException e) {
throw new CodecException("no such no such algorithm", e);
} catch (UnsupportedEncodingException e) {
throw new CodecException("unsupported encoding", e);
}

if (StringUtils.isNotEmpty(md5Value) && StringUtils.equals(this.md5Checksum, md5Value)) {
// TODO: do something
} else {
// TODO: do something
}
}

/**
* 文件内容校验
*
* @return
*/
public boolean isCheckSumRight() {

boolean checkResult = false;
String md5Value;
try {
md5Value = CodecUtil.MD5(String.valueOf(this.content));
} catch (NoSuchAlgorithmException e) {
return checkResult;
} catch (UnsupportedEncodingException e) {
return checkResult;
}

if (StringUtils.isNotEmpty(md5Value) && StringUtils.equals(this.md5Checksum, md5Value)) {
checkResult = true;
}

return checkResult;

}

/**
* 执行文件
* v2.0
*
*/
public void exec2() {
// TODO: do something
}

}
  1. 什么时候使用 UnCheckedException?

Error 和 UnCheckedException 的共同点都是 UnChecked,但是之前有说过一般我们不应该主动使用 Error,所以当需要抛出 Unchecked 异常的时候,UnCheckedException 是我们最好的选择。我们也可以自己去继承 RuntimeException 类来定义自己的 UncheckedException。简单的说,当我们发现 CheckedException 不适用的时候,我们应该去使用 UncheckedException,而不是 Error。

  • 建议 3:尽量使用 JDK 提供的异常类型

“不要重复发明车轮”是软件开发中的一句至理名言,在异常的选择上也同样适用,JDK 内置了许多 Exception 类型,当我们需要使用的时候我们应该先去检查 JDK 是否有提供,而不是去实现一个自定义的异常。这样做主要有如下两点好处:

  1. 这样的API设计更加容易让调用方理解,减少了调用方的学习成本
  2. 减少了异常类的数目,可以降低程序编译、加载的时间

在使用 JDK 提供的 Exception 类的时候,一定要去阅读一下 docs 对于该 Exception 类的说明,不能只是简单的依据类名去揣测类的用途,一旦揣测错误,将会给 API 的调用方造成困惑。

  • 建议 4:使用“异常转译”让语义更清晰

在软件设计的时候,我们通常会进行分层处理,典型的就是“三层软件设计架构”,即 web 层、业务逻辑层(service),以及持久化层(orm),三层之间相互隔离,不能跨层调用。虽然很多开发者在开发的时候会去注重分层,但是在异常处理方面还是会出现“跨层传播”的现象。比如我们在 orm 层抛出的 DAOException 异常,因为在 service 里面没有经过处理就直接抛给了 web 层,这样就出现了“跨层传播”。这样没有任何好处,web 开发人员在调用服务的时候,需要去捕获一个 DAOException,除了不知道如何去处理,也会让开发人员对于底层的设计疑惑。所以在这种时候我们可以通过“异常转译”,在 service 层对 orm 层抛出的异常进行处理之后,封装成 service 层的异常再抛给web层,如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public User getUserInfo(long userId) throws ServiceException {

User user = new User();

try {
user = userDao.findUserById(userId);
} catch (SQLException e) {
log.error("DB operate exception!", e);
// 异常转译
throw new ServiceException("DB operate exception!", e);
}

return user;

}

异常转译值得提倡,但是不能滥用,我们还是要坚持对 CheckedException 处理的原则,不能仅仅捕获后就转译抛出,这样只是让异常在语义上更加易于理解,但是对API的调用并没有起到实质性的帮助。

  • 建议 5:推荐为方法的 checken exception 和 unchecken exception 编写文档

在方法的声明中,我们应该为每个方法编写可能会抛出的所有异常(CheckedException 和 UnCheckedException)利用 @throws 关键字编写文档,包括异常的名称,是 CheckedException 还是 UnCheckedException,异常发生的条件等,从而让 API 的调用方能够正确的使用.

  • 建议 6:留下罪症,不要偷吃

当异常发生的时候,我们通常会将其记录到日志文件,事后通过分析日志来查明造成错误的原因,异常在被抛出的时候,我们也可以在栈轨迹中添加一些描述信息,从而让异常更加易于理解,对于描述信息的设置,我们最主要的是要“保留作案现场”,即造成异常的实时条件,比如当造成 ArrayIndexOutOfBoundsException 的数组的上下界,以及当前访问的下标等数组,这样会为我们在后面排错起到极大的帮助作用,因为有些 bug 是很难被重现的。

与“保留作案现场”这一良好习惯背道而驰的是“吃异常”,如果不是真的需要,千万不要把异常给吃了,哪怕你不去处理,用日志记录一下都比吃掉它要强很多。说一个故事背景,有一次在重构一个“订单审核服务”项目的时候,将服务部署启动之后,启动日志一切正常,一切都是那么的美好,但是订单审核的结果始终不正确,但是日志就是没有错误,无奈只能去看源码,然后在历史代码里面发现了下面这样一串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try{

// 具体算法模型文件加载逻辑

}catch (FileNotFoundException e) {

try{

throw new IOException(e);

} catch (IOException ee) {
// 他把异常给吃了!!!
}

}

先不去讨论这段代码写的是有多奇葩,造成上面现象的主要原因是当算法模型文件找不到,发生异常的时候,这个异常吃掉了,catch 之后不做任何处理,连用日志记录一下都没有,它就这样无声无息地从这个宇宙中消失了,给我造成了 10000 点伤害。所以如果不是必须,我们在代码里面千万不要去把异常吃掉,这样会让原本提升系统稳定性的 java 异常处理机制成为 bug 的良好栖息之地!

参考
  1. Effective Java 2nd Edition
  2. 透过 JVM 看 Exception 的本质