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

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

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

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

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

  Java语言从设计之初就提供了对异常处理的支持,并且不同于其它语言,Java对于异常采取了强校验机制,即对于编译期异常需要API调用方显式地对异常进行处理,这种强校验机制被一部分人所钟爱,也有一部分人狂吐槽它。持支持观点的人认为这种机制可以极大的提升系统的稳定性,当存在潜在异常的时候强制开发人员去处理异常,而反对的人则认为强制异常处理降低了代码的可读性,一串串的try-catch让代码看起来不够简洁,并且不正确的使用不仅达不到提升系统稳定性的目的,反而成为了bug的良好栖息之地。
  Java的异常处理是一把双刃剑,这是我一向持有的观点。个人认为我们不能对Java的异常处理片面的下一个好或者不好的定义,黑格尔说“存在即合理”,既然Java的设计者强制要求我们去处理Java的异常,那么与其在那里吐槽,还不如去学习如何用好Java的异常处理,让其为提升程序的稳定性所服务。不过从笔者的亲身感受来看,用好Java的异常处理门槛并不低!

二. Java的异常继承体系

img
  上图展示了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

三. 最佳实践

3.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
@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
7
8
9
10
11
12
# 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分配时间片等时间,所以统计结果更加具备真实性。_
&emsp;&emsp;从上面JMH经过200次迭代的统计结果来看,`goodTry`执行的时间约为10⁻⁴ms,而`badTry`则耗费了117.413ms(正负偏差3.423ms),所以可以看异常处理开销还是比较昂贵的,对于这种正常的流程,我们利用异常的机制去控制程序的执行往往会适得其反。
&emsp;&emsp;对于数组的遍历,Java在每次访问元素的时候都会去检查一下数组下标是否越界,如果越界则会抛出`IndexOutOfBoundsException`,这样给Java程序猿在编码上带来了便利,但是如果对于一个数组的频繁访问,那么这种边界检查将会是一笔不小的开销,但却是不能省去的步骤,为了提升执行效率,JVM一般会采取如下优化措施:
```text
1. 如果下标是常量的情况,那么可以在编译器通过数据流分析就可以判定运行的时候是否需要检查
2. 如果是在循环中利用变量去访问一个数组,那么JVM也可以在编译器分析循环的范围是否在数组边界之内,从而可以消除边界检查

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

1
2
3
4
/*
* 用户调用
*/
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来结束数组遍历过程的开销是很大的,所以不要用异常去处理正常的执行流程,或者不要用异常去做流程控制。

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

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

3.2.1 什么时候使用Error?

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

3.2.2 什么时候使用CheckedException?

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

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

  在设计API的时候,抛出CheckedException能够暗示调用者对异常手动处理,提升系统稳定性,但是如果调用方也不知道如果更好的处理,那么把异常抛给调用方也没有任何意义,而且API每抛出一个CheckedException,也就意味着API调用方需要多一个catch,则将让程序变的不够简洁。
  有些情况下,我们在设计API时,可以通过一些技巧来避免抛出CheckException。比如下面这段代码中,代码的意图在于设计了一个File类,而整个File类需要对外提供提供一个执行的方法exec,但是在执行的时候需要验证该文件的MD5值是否正确,execShell里面给出了两种方案,第一种是只对外提供一个方法,在该方法中先验证MD5值,然后执行文件,在验证MD5值的时候,会抛出CheckedException,于是exec函数向外抛出了一个CodeException,调用该函数的程序不得不去处理该异常,而方案二则是对外提供了两个函数: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
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
/**
* File的定义
* @author zhenchao.wang 2016-08-09 16:29
* @version 1.0.0
*/
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
}
}
3.2.3 什么时候使用UnCheckedException?

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

3.3 尽量使用jdk提供的异常类型

  “不要重复发明车轮”是软件开发中的一句至理名言,在异常的选择上也同样适用,JDK内置了许多Exception,当我们需要使用的时候我们应该先去检查JDK是否有提供,而不是去实现一个自定义的异常。这样做主要有如下两点好处:
1.这样的API设计更加容易让调用方理解,减少了调用方的学习成本
2.减少了异常类的数目,可以降低程序编译、加载的时间
  在使用JDK提供的Exception类的时候,一定要去阅读一下docs对于该Exception类的说明,不能只是简单的依据类名去揣测类的用途,一旦揣测错误,将会给API的调用方造成困惑。

3.4 使用“异常转译”让语义更清晰

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

1
2
3
4
5
6
7
8
9
10
11
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的调用并没有起到实质性的帮助。

3.5 推荐为方法的checken exception和unchecken exception编写文档

  在方法的声明中,我们应该为每个方法编写可能会抛出的所有异常(CheckedException和UnCheckedException)利用@throws关键字编写文档,包括异常的名称,是CheckedException还是UnCheckedException,异常发生的条件等,从而让API的调用方能够正确的使用。
  这里不得不吐槽一下,到目前为止我所接触的历史项目就没有一个是注释及格的(我的要求并不高~),一行注释都没有的也是大有所在,所以每次去看别人的代码都很痛苦。这里还得感谢一下飞哥(@陈飞),在阿里的时候,飞哥作为我的导师对我的编码风格的纠正起到了非常重要的作用。

3.6 留下罪症,不要偷吃

  当异常发生的时候,我们通常会将其记录到日志文件,事后通过分析日志来查明造成错误的原因,异常在被抛出的时候,我们也可以在栈轨迹中添加一些描述信息,从而让异常更加易于理解,对于描述信息的设置,我们最主要的是要“保留作案现场”,即造成异常的实时条件,比如当造成ArrayIndexOutOfBoundsException的数组的上下界,以及当前访问的下标等数组,这样会为我们在后面排错起到极大的帮助作用,因为有些bug是很难被重现的。
  与“保留作案现场”这一良好习惯背道而驰的是“吃异常”,如果不是真的需要,千万不要把异常给吃了,哪怕你不去处理,用日志记录一下都比吃掉它要强很多。说一个故事背景,有一次在重构一个“订单审核服务”项目的时候,将服务部署启动之后,启动日志一切正常,一切都是那么的美好,但是订单审核的结果始终不正确,但是日志就是没有错误,无奈只能去看源码,然后在历史代码里面发现了下面这样一串:

1
2
3
4
5
6
7
8
9
try{
// 具体算法模型文件加载逻辑
}catch (FileNotFoundException e) {
try{
throw new IOException(e);
} catch (IOException ee) {
// 他把异常给吃了!!!
}
}

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

最后,如果大家对于java异常处理体系有更深的理解或者更好的实践,也欢迎在评论中提出,大家一起探讨,共同进步~


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