12.5 异常

Java 的异常处理一直有代价高昂的坏名声。其代价确实比处理正常的控制流高一些,不过在大多数情况下,这种代价并不值得浪费精力去绕过。另一方面,因为异常处理是有成本的,所以不应将其用作一种通用机制。这里的指导方针是,根据良好程序设计的一般原则来使用异常:基本上,代码仅应该通过抛出异常来说明发生了意料之外的情况。遵循良好的代码设计原则,意味着 Java 代码不会因异常处理而变慢。

有两个因素会影响异常处理的一般性能。一个是代码块本身:创建一个 try-catch 块代价高吗?尽管很久以前可能是这样,但是近几年来,情况已非如此。不过在互联网上有些信息会留存很久,所以偶尔还会看到有人建议避免使用异常,因为 try-catch 块代价较高。这些建议都是老黄历了,因为现代 JVM 生成的代码可以非常高效地处理异常。

第二个方面是,(大部分)异常会涉及获取该异常发生时的栈轨迹信息。这一操作代价可能会很高,特别是在栈的轨迹很深时。

下面看一个例子。假如现在有一个特定方法的 3 种实现:

  1. public ArrayList<String> testSystemException() {
  2. ArrayList<String> al = new ArrayList<>();
  3. for (int i = 0; i < numTestLoops; i++) {
  4. Object o = null;
  5. if ((i % exceptionFactor) != 0) {
  6. o = new Object();
  7. }
  8. try {
  9. al.add(o.toString());
  10. } catch (NullPointerException npe) {
  11. // 继续获取下一个字符串
  12. }
  13. }
  14. return al;
  15. }
  16. public ArrayList<String> testCodeException() {
  17. ArrayList<String> al = new ArrayList<>();
  18. for (int i = 0; i < numTestLoops; i++) {
  19. try {
  20. if ((i % exceptionFactor) == 0) {
  21. throw new NullPointerException("Force Exception");
  22. }
  23. Object o = new Object();
  24. al.add(o.toString());
  25. } catch (NullPointerException npe) {
  26. // 继续获取下一个字符串
  27. }
  28. }
  29. return al;
  30. }
  31. public ArrayList<String> testDefensiveProgramming() {
  32. ArrayList<String> al = new ArrayList<>();
  33. for (int i = 0; i < numTestLoops; i++) {
  34. Object o = null;
  35. if ((i % exceptionFactor) != 0) {
  36. o = new Object();
  37. }
  38. if (o != null) {
  39. al.add(o.toString());
  40. }
  41. }
  42. return al;
  43. }

每个方法都返回一个字符串数组,其元素是从新创建的对象得到的。数组的大小会变化,跟抛出异常的次数有关。

表 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 做的微基准测试,而且即便是在这样的条件下,每次调用的差别也是毫秒级的。

12.5 异常 - 图1 快速小结

1. 处理异常的代价未必会很高,不过还是应该在适合的时候才用。

2. 栈越深,处理异常的代价越高。

3. 对于会频繁创建的系统异常,JVM 会将栈上的性能损失优化掉。

4. 关闭异常中的栈轨迹信息,有时可以提高性能,不过这个过程往往会丢失一些关键信息。