12.5 异常
Java 的异常处理一直有代价高昂的坏名声。其代价确实比处理正常的控制流高一些,不过在大多数情况下,这种代价并不值得浪费精力去绕过。另一方面,因为异常处理是有成本的,所以不应将其用作一种通用机制。这里的指导方针是,根据良好程序设计的一般原则来使用异常:基本上,代码仅应该通过抛出异常来说明发生了意料之外的情况。遵循良好的代码设计原则,意味着 Java 代码不会因异常处理而变慢。
有两个因素会影响异常处理的一般性能。一个是代码块本身:创建一个 try-catch 块代价高吗?尽管很久以前可能是这样,但是近几年来,情况已非如此。不过在互联网上有些信息会留存很久,所以偶尔还会看到有人建议避免使用异常,因为 try-catch 块代价较高。这些建议都是老黄历了,因为现代 JVM 生成的代码可以非常高效地处理异常。
第二个方面是,(大部分)异常会涉及获取该异常发生时的栈轨迹信息。这一操作代价可能会很高,特别是在栈的轨迹很深时。
下面看一个例子。假如现在有一个特定方法的 3 种实现:
public ArrayList<String> testSystemException() {ArrayList<String> al = new ArrayList<>();for (int i = 0; i < numTestLoops; i++) {Object o = null;if ((i % exceptionFactor) != 0) {o = new Object();}try {al.add(o.toString());} catch (NullPointerException npe) {// 继续获取下一个字符串}}return al;}public ArrayList<String> testCodeException() {ArrayList<String> al = new ArrayList<>();for (int i = 0; i < numTestLoops; i++) {try {if ((i % exceptionFactor) == 0) {throw new NullPointerException("Force Exception");}Object o = new Object();al.add(o.toString());} catch (NullPointerException npe) {// 继续获取下一个字符串}}return al;}public ArrayList<String> testDefensiveProgramming() {ArrayList<String> al = new ArrayList<>();for (int i = 0; i < numTestLoops; i++) {Object o = null;if ((i % exceptionFactor) != 0) {o = new Object();}if (o != null) {al.add(o.toString());}}return al;}
每个方法都返回一个字符串数组,其元素是从新创建的对象得到的。数组的大小会变化,跟抛出异常的次数有关。
表 12-5 列出了在最坏情况下(即 exceptionFactor 为 1,每次迭代都会生成异常,得到的结果是一个空列表)为 100 000 次迭代执行每个方法的时间。示例代码中,有的方法栈轨迹很浅(当调用这个方法时,栈上只有 3 个类),有的栈轨迹很深(当调用这个方法时,栈上有 100 个类)。
表12-5:100%产生异常时的处理时间
| 方法 | 浅时间(毫秒) | 深时间(毫秒) |
|---|---|---|
| 代码异常 | 381 | 10 673 |
| 系统异常 | 15 | 15 |
| 防御性编程 | 2 | 2 |
这里有 3 点有趣的差别。首先,在每次迭代显式地构建异常的代码中,栈较浅和栈较深两种情况下时间差别很大。构建栈轨迹需要时间,这个时间和栈的深度有关。
第二个有趣的差别在这两种情况之间:代码显式地创建异常,或者是当 JVM 解析到空指针时创建异常(见表中的前两行)。目前的情况是,在某一个时刻,编译器会优化掉系统生成的异常;JVM 开始重用同一个异常对象,而不是每次需要时创建一个新的。不管调用栈是什么样的,相关的代码每次执行时都会重用这个对象;而且这个异常实际上没有包含调用栈(也就是说,printStackTrace() 没有输出)。这种优化在完整的栈异常信息抛出很长一段时间之后才会出现,所以如果测试用例中没有包含足够长的热身周期,是不会看到这种效果的。
最后,在访问对象之前先判断一下是否为 null,这种防御性编程性能最好。在这个例子中,这一点并不意外,因为整个循环变成了空操作。所以对这个数字要持保留态度。
尽管这些实现存在一些差别,但是请注意,大部分情况下,所用的时间都很少,是毫秒级的。平均到 100 000 次调用,每次调用的执行时间几乎看不到什么差别(别忘了,这还是最坏的情况)。
如果异常使用得当,这些循环中的异常数目就会非常小。表 12-6 列出了执行 100 000 次循环时,产生 1000 次异常(1% 的几率)需要的时间。
表12-6:有1%的几率产生异常时的处理时间
| 方法 | 浅时间(毫秒) | 深时间(毫秒) |
|---|---|---|
| 代码异常 | 56 | 157 |
| 系统异常 | 51 | 52 |
| 防御性编程 | 50 | 50 |
现在 toString() 方法的处理时间成了计算的大头。在栈较深的情况下,创建异常仍然有性能损失,不过提前测试 null 值的收益都被抵消了。
所以异常使用不当所带来的性能损失并没有想象的那么大。有些情况下,我们仍然会遇到创建太多异常的代码。因为性能损失主要来自填充栈轨迹信息,因此可以使用 -XX:-StackTraceInThrowable 标志(默认为 false)来禁止生成栈轨迹信息。
这并不是个好主意:栈轨迹的存在就是为帮我们分析哪里出问题的。如果使用了 -XX:-StackTraceInThrowable 标志,也就丢失了这种能力。而且有些代码实际上会检查栈轨迹,并以此确定如何从异常恢复。(CORBA 的参考实现就是这么工作的。)这种方式本身就有问题,但关键还在于禁止栈跟踪信息会使代码出现莫名其妙的问题。
JDK 中有些 API 的异常处理会导致性能问题。当集合中并不存在要检索的元素时,很多集合类就会抛出异常。比如 Stack 类,如果栈是空的,当调用 pop() 时,就会抛出 EmptyStackException。这种情况下,先通过防御性编程方式检查一下栈的长度会好一些。(另一方面,和很多集合类不同的是,Stack 类支持保存为 null 的对象,所以不能用 pop() 方法返回 null 来说明栈是空的。 )
关于异常的不当使用,JDK 中最臭名昭著的例子是类加载:当使用 ClassLoader 类的 loadClass() 方法加载某个找不到的类时,就会抛出 ClassNotFoundException。这实际并不是一个异常条件。不要期望一个类加载器能知道如何加载应用中的每个类,这也是之所以会有类加载器的层次结构的原因了。
在一个存在大量类加载器的环境中,这意味着,在层次化的类加载器中搜索知道如何加载给定类的那个类加载器时,会有大量的异常。比如在本章前面类加载的例子中,如果关闭栈轨迹信息,运行速度会提升 3%。
不过,类加载只是个例外。那个例子是使用很长的 classpath 做的微基准测试,而且即便是在这样的条件下,每次调用的差别也是毫秒级的。
快速小结
1. 处理异常的代价未必会很高,不过还是应该在适合的时候才用。
2. 栈越深,处理异常的代价越高。
3. 对于会频繁创建的系统异常,JVM 会将栈上的性能损失优化掉。
4. 关闭异常中的栈轨迹信息,有时可以提高性能,不过这个过程往往会丢失一些关键信息。
快速小结