Java 谜题 Java 谜题 4——异常谜题
谜题 36:优柔寡断
下面这个可怜的小程序并不能很好地做出其自己的决定。它的 decision 方法将
返回 true,但是它还返回了 false。那么,它到底打印的是什么呢?甚至,它是
合法的吗?
1 | public class Indecisive { |
你可能会认为这个程序是不合法的。毕竟,decision 方法不能同时返回 true 和
false。如果你尝试一下,就会发现它编译时没有任何错误,并且它所打印的是
false。为什么呢?
原因就是在一个 try-finally 语句中,finally 语句块总是在控制权离开 try 语
句块时执行的[JLS 14.20.2]。无论 try 语句块是正常结束的,还是意外结束的,
情况都是如此。一条语句或一个语句块在它抛出了一个异常,或者对某个封闭型
语句执行了一个 break 或 continue,或是象这个程序一样在方法中执行了一个
return 时,将发生意外结束。它们之所以被称为意外结束,是因为它们阻止程
序去按顺序执行下面的语句。 当 try 语句块和 finally 语句块都意外结束时,在 try 语句块中引发意外结束的
原因将被丢弃,而整个 try-finally 语句意外结束的原因将于 finally 语句块意
外结束的原因相同。在这个程序中,在 try 语句块中的 return 语句所引发的意
外结束将被丢弃,而 try-finally 语句意外结束是由 finally 语句块中的 return
造成的。简单地讲,程序尝试着(try)返回(return)true,但是它最终(finally)
返回(return)的是 false。
丢弃意外结束的原因几乎永远都不是你想要的行为,因为意外结束的最初原因可
能对程序的行为来说会显得更重要。对于那些在 try 语句块中执行 break、
continue 或 return 语句,只是为了使其行为被 finally 语句块所否决掉的程序,
要理解其行为是特别困难的。
总之,每一个 finally 语句块都应该正常结束,除非抛出的是不受检查的异常。
千万不要用一个 return、break、continue 或 throw 来退出一个 finally 语句块,
并且千万不要允许将一个受检查的异常传播到一个 finally 语句块之外去。
对于语言设计者,也许应该要求 finally 语句块在未出现不受检查的异常时必须
正常结束。朝着这个目标,try-finally 结构将要求 finally 语句块可以正常结
束[JLS 14.21]。return、break 或 continue 语句把控制权传递到 finally 语句
块之外应该是被禁止的,任何可以引发将被检查异常传播到 finally 语句块之外
的语句也同样应该是被禁止的。
谜题 37:极端不可思议
本谜题测试的是你对某些规则的掌握程度,这些规则用于声明从方法中抛出并被
catch 语句块所捕获的异常。下面的三个程序每一个都会打印些什么?不要假设
它们都可以通过编译:
1 | import java.io.IOException; |
第一个程序,Arcane1,展示了被检查异常的一个基本原则。它看起来应该是可
以编译的:try 子句执行 I/O,并且 catch 子句捕获 IOException 异常。但是这
个程序不能编译,因为 println 方法没有声明会抛出任何被检查异常,而
IOException 却正是一个被检查异常。语言规范中描述道:如果一个 catch 子句
要捕获一个类型为 E 的被检查异常,而其相对应的 try 子句不能抛出 E 的某种子
类型的异常,那么这就是一个编译期错误[JLS 11.2.3]。
基于同样的理由,第二个程序,Arcane2,看起来应该是不可以编译的,但是它
却可以。它之所以可以编译,是因为它唯一的 catch 子句检查了 Exception。尽
管 JLS 在这一点上十分含混不清,但是捕获 Exception 或 Throwble 的 catch 子
句是合法的,不管与其相对应的 try 子句的内容为何。尽管 Arcane2 是一个合法
的程序,但是 catch 子句的内容永远的不会被执行,这个程序什么都不会打印。
第三个程序,Arcane3,看起来它也不能编译。方法 f 在 Type1 接口中声明要抛
出被检查异常 CloneNotSupportedException,并且在 Type2 接口中声明要抛出
被检查异常 InterruptedException。Type3 接口继承了 Type1 和 Type2,因此,
看起来在静态类型为Type3的对象上调用方法f时,有潜在可能会抛出这些异常。
一个方法必须要么捕获其方法体可以抛出的所有被检查异常,要么声明它将抛出这些异常。Arcane3 的 main 方法在静态类型为 Type3 的对象上调用了方法 f,但
它对 CloneNotSupportedException 和 InterruptedExceptioin 并没有作这些处
理。那么,为什么这个程序可以编译呢?
上述分析的缺陷在于对“Type3.f 可以抛出在 Type1.f 上声明的异常和在
Type2.f 上声明的异常”所做的假设。这并不正确,因为每一个接口都限制了方
法 f 可以抛出的被检查异常集合。一个方法可以抛出的被检查异常集合是它所适
用的所有类型声明要抛出的被检查异常集合的交集,而不是合集。因此,静态类
型为 Type3 的对象上的 f 方法根本就不能抛出任何被检查异常。因此,Arcane3
可以毫无错误地通过编译,并且打印 Hello world。
总之,第一个程序说明了一项基本要求,即对于捕获被检查异常的 catch 子句,
只有在相应的 try 子句可以抛出这些异常时才被允许。第二个程序说明了这项要
求不会应用到的冷僻案例。第三个程序说明了多个继承而来的 throws 子句的交
集,将减少而不是增加方法允许抛出的异常数量。本谜题所说明的行为一般不会
引发难以捉摸的 bug,但是你第一次看到它们时,可能会有点吃惊。
谜题 38:不受欢迎的宾客 不受欢迎的宾客
本谜题中的程序所建模的系统,将尝试着从其环境中读取一个用户 ID,如果这
种尝试失败了,则缺省地认为它是一个来宾用户。该程序的作者将面对有一个静
态域的初始化表达式可能会抛出异常的情况。因为 Java 不允许静态初始化操作
抛出被检查异常,所以初始化必须包装在 try-finally 语句块中。那么,下面的
程序会打印出什么呢?
1 | public class UnwelcomeGuest { |
该程序看起来很直观。对 getUserIdFromEnvironment 的调用将抛出一个异常,
从而使程序将 GUEST_USER_ID(-1L)赋值给 USER_ID,并打印 Loggin in as guest。
然后 main 方法执行,使程序打印 User ID: -1。表象再次欺骗了我们,该程序
并不能编译。如果你尝试着去编译它,你将看到和下面内容类似的一条错误信息:
1 | UnwelcomeGuest.java:10: |
问题出在哪里了?USER_ID 域是一个空 final(blank final),它是一个在声明
中没有进行初始化操作的 final 域[JLS 4.12.4]。很明显,只有在对 USER_ID
赋值失败时,才会在 try 语句块中抛出异常,因此,在 catch 语句块中赋值是相
当安全的。不管怎样执行静态初始化操作语句块,只会对 USER_ID 赋值一次,这
正是空 final 所要求的。为什么编译器不知道这些呢?
要确定一个程序是否可以不止一次地对一个空 final 进行赋值是一个很困难的
问题。事实上,这是不可能的。这等价于经典的停机问题,它通常被认为是不可
能解决的[Turing 36]。为了能够编写出一个编译器,语言规范在这一点上采用
了保守的方式。在程序中,一个空 final 域只有在它是明确未赋过值的地方才可
以被赋值。规范长篇大论,对此术语提供了一个准确的但保守的定义[JLS 16]。
因为它是保守的,所以编译器必须拒绝某些可以证明是安全的程序。这个谜题就
展示了这样的一个程序。
幸运的是,你不必为了编写 Java 程序而去学习那些骇人的用于明确赋值的细节。
通常明确赋值规则不会有任何妨碍。如果碰巧你编写了一个真的可能会对一个空
final 赋值超过一次的程序,编译器会帮你指出的。只有在极少的情况下,就像
本谜题一样,你才会编写出一个安全的程序,但是它并不满足规范的形式化要求。
编译器的抱怨就好像是你编写了一个不安全的程序一样,而且你必须修改你的程
序以满足它。
解决这类问题的最好方式就是将这个烦人的域从空 final 类型改变为普通的
final 类型,用一个静态域的初始化操作替换掉静态的初始化语句块。实现这一
点的最佳方式是重构静态语句块中的代码为一个助手方法:
1 | public class UnwelcomeGuest { |
程序的这个版本很显然是正确的,而且比最初的版本根据可读性,因为它为了域
值的计算而增加了一个描述性的名字,而最初的版本只有一个匿名的静态初始化
操作语句块。将这样的修改作用于程序,它就可以如我们的期望来运行了。
总之,大多数程序员都不需要学习明确赋值规则的细节。该规则的作为通常都是
正确的。如果你必须重构一个程序,以消除由明确赋值规则所引发的错误,那么
你应该考虑添加一个新方法。这样做除了可以解决明确赋值问题,还可以使程序
的可读性提高。
谜题 39:您好,再见!
下面的程序在寻常的 Hello world 程序中添加了一段不寻常的曲折操作。那么,
它将会打印出什么呢?
1 | public class HelloGoodbye { |
这个程序包含两个 println 语句:一个在 try 语句块中,另一个在相应的 finally
语句块中。try 语句块执行它的 println 语句,并且通过调用 System.exit 来提
前结束执行。在此时,你可能希望控制权会转交给 finally 语句块。然而,如果
你运行该程序,就会发现它永远不会说再见:它只打印了 Hello world。这是否
违背了谜题 36 中所解释的原则呢?
不论 try 语句块的执行是正常地还是意外地结束,finally 语句块确实都会执行。
然而在这个程序中,try 语句块根本就没有结束其执行过程。System.exit 方法
将停止当前线程和所有其他当场死亡的线程。finally 子句的出现并不能给予线
程继续去执行的特殊权限。 当 System.exit 被调用时,虚拟机在关闭前要执行两项清理工作。首先,它执行
所有的关闭挂钩操作,这些挂钩已经注册到了 Runtime.addShutdownHook 上。这
对于释放 VM 之外的资源将很有帮助。务必要为那些必须在 VM 退出之前发生的行
为关闭挂钩。下面的程序版本示范了这种技术,它可以如我们所期望地打印出
Hello world 和 Goodbye world:
1 | public class HelloGoodbye1 { |
VM 执行在 System.exit 被调用时执行的第二个清理任务与终结器有关。如果
System.runFinalizerOnExit 或它的魔鬼双胞胎 Runtime.runFinalizersOnExit
被调用了,那么 VM 将在所有还未终结的对象上面调用终结器。这些方法很久以
前就已经过时了,而且其原因也很合理。无论什么原因,永远不要调用
System.runFinalizersOnExit 和 Runtime.runFinalizersOnExit:它们属于 Java
类库中最危险的方法之一[ThreadStop]。调用这些方法导致的结果是,终结器会
在那些其他线程正在并发操作的对象上面运行,从而导致不确定的行为或导致死
锁。
总之,System.exit 将立即停止所有的程序线程,它并不会使 finally 语句块得
到调用,但是它在停止 VM 之前会执行关闭挂钩操作。当 VM 被关闭时,请使用关
闭挂钩来终止外部资源。通过调用 System.halt 可以在不执行关闭挂钩的情况下
停止 VM,但是这个方法很少使用。
谜题 40:不情愿的构造器 不情愿的构造器
尽管在一个方法声明中看到一个 throws 子句是很常见的,但是在构造器的声明
中看到一个 throws 子句就很少见了。下面的程序就有这样的一个声明。那么,
它将打印出什么呢?
1 | public class Reluctant { |
main 方法调用了 Reluctant 构造器,它将抛出一个异常。你可能期望 catch 子
句能够捕获这个异常,并且打印 I told you so。凑近仔细看看这个程序就会发
现,Reluctant 实例还包含第二个内部实例,它的构造器也会抛出一个异常。无
论抛出哪一个异常,看起来 main 中的 catch 子句都应该捕获它,因此预测该程
序将打印 I told you 应该是一个安全的赌注。但是当你尝试着去运行它时,就
会发现它压根没有去做这类的事情:它抛出了 StackOverflowError 异常,为什
么呢?
与大多数抛出 StackOverflowError 异常的程序一样,本程序也包含了一个无限
递归。当你调用一个构造器时,实例变量的初始化操作将先于构造器的程序体而
运行[JLS 12.5]。在本谜题中, internalInstance 变量的初始化操作递归调用
了构造器,而该构造器通过再次调用 Reluctant 构造器而初始化该变量自己的
internalInstance 域,如此无限递归下去。这些递归调用在构造器程序体获得
执行机会之前就会抛出 StackOverflowError 异常,因为 StackOverflowError
是 Error 的子类型而不是 Exception 的子类型,所以 catch 子句无法捕获它。
对于一个对象包含与它自己类型相同的实例的情况,并不少见。例如,链接列表
节点、树节点和图节点都属于这种情况。你必须非常小心地初始化这样的包含实
例,以避免 StackOverflowError 异常。
至于本谜题名义上的题目:声明将抛出异常的构造器,你需要注意,构造器必须
声明其实例初始化操作会抛出的所有被检查异常。下面这个展示了常见的“服务
提供商”模式的程序,将不能编译,因为它违反了这条规则:
1 | public class Car { |
尽管其构造器没有任何程序体,但是它将抛出两个被检查异常,
InstantiationException 和 IllegalAccessException。它们是 Class.Instance
抛出的,该方法是在初始化 engine 域的时候被调用的。订正该程序的最好方式
是创建一个私有的、静态的助手方法,它负责计算域的初始值,并恰当地处理异
常。在本案中,我们假设选择 engineClass 所引用的 Class 对象,保证它是可访
问的并且是可实例化的。
下面的 Car 版本将可以毫无错误地通过编译: //Fixed - instance initializers don’t throw checked exceptions
1 | public class Car { |
总之,实例初始化操作是先于构造器的程序体而运行的。实例初始化操作抛出的
任何异常都会传播给构造器。如果初始化操作抛出的是被检查异常,那么构造器
必须声明也会抛出这些异常,但是应该避免这样做,因为它会造成混乱。最后,
对于我们所设计的类,如果其实例包含同样属于这个类的其他实例,那么对这种
无限递归要格外当心。
谜题 41:域和流
下面的方法将一个文件拷贝到另一个文件,并且被设计为要关闭它所创建的每一
个流,即使它碰到 I/O 错误也要如此。遗憾的是,它并非总是能够做到这一点。
为什么不能呢,你如何才能订正它呢?
1 | static void copy(String src, String dest) throws IOException { |
这个程序看起来已经面面俱到了。其流域(in 和 out)被初始化为 null,并且
新的流一旦被创建,它们马上就被设置为这些流域的新值。对于这些域所引用的流,如果不为空,则 finally 语句块会将其关闭。即便在拷贝操作引发了一个
IOException 的情况下,finally 语句块也会在方法返回之前执行。出什么错了
呢?
问题在 finally 语句块自身中。close 方法也可能会抛出 IOException 异常。如
果这正好发生在 in.close 被调用之时,那么这个异常就会阻止 out.close 被调
用,从而使输出流仍保持在开放状态。
请注意,该程序违反了谜题 36 的建议:对 close 的调用可能会导致 finally 语
句块意外结束。遗憾的是,编译器并不能帮助你发现此问题,因为 close 方法抛
出的异常与 read 和 write 抛出的异常类型相同,而其外围方法(copy)声明将
传播该异常。
解决方式是将每一个 close 都包装在一个嵌套的 try 语句块中。下面的 finally
语句块的版本可以保证在两个流上都会调用 close:
1 | } finally { |
1 | private static void closeIgnoringException(Closeable c) { |
总之,当你在 finally 语句块中调用 close 方法时,要用一个嵌套的 try-catch
语句来保护它,以防止 IOException 的传播。更一般地讲,对于任何在 finally
语句块中可能会抛出的被检查异常都要进行处理,而不是任其传播。这是谜题
36 中的教训的一种特例,而对语言设计着的教训情况也相同。
谜题 42:异常为循环而抛 异常为循环而抛
下面的程序循环遍历了一个 int 类型的数组序列,并且记录了满足某个特定属性
的数组个数。那么,该程序会打印出什么呢?
1 | public class Loop { |
该程序用 thirdElementIsThree 方法测试了 tests 数组中的每一个元素。遍历这
个数组的循环显然是非传统的循环:它不是在循环变量等于数组长度的时候终
止,而是在它试图访问一个并不在数组中的元素时终止。尽管它是非传统的,但
是这个循环应该可以工作。如果传递给 thirdElementIsThree 的参数具有 3 个或
更多的元素,并且其第三个元素等于 3,那么该方法将返回 true。对于 tests
中的 5 个元素来说,有 2 个将返回 true,因此看起来该程序应该打印 2。如果你
运行它,就会发现它打印的时 0。肯定是哪里出了问题,你能确定吗?
事实上,这个程序犯了两个错误。第一个错误是该程序使用了一种可怕的循环惯
用法,该惯用法依赖的是对数组的访问会抛出异常。这种惯用法不仅难以阅读,
而且运行速度还非常地慢。不要使用异常来进行循环控制;应该只为异常条件而使用异常[EJ Item 39]。为了纠正这个错误,可以将整个 try-finally 语句块替
换为循环遍历数组的标准惯用法:
1 | for (int i = 0; i < test.length; i++) |
如果你使用的是 5.0 或者是更新的版本,那么你可以用 for 循环结构来代替:
1 | for (int[] test : tests) |
就第一个错误的糟糕情况来说,只有它自己还不足以产生我们所观察到的行为。
然而,订正该错误可以帮助我们找到真正的 bug,它更加深奥:
1 | Exception in thread "main" |
很明显,在 thirdElementIsThree 方法中有一个 bug:它抛出了一个
ArrayIndexOutOfBoundsException 异常。这个异常先前伪装成了那个可怕的基
于异常的循环的终止条件。
如果传递给 thirdElementIsThree 的参数具有 3 个或更多的元素,并且其第三个
元素等于 3,那么该方法将返回 true。问题是在这些条件不满足时它会做些什么
呢。如果你仔细观察其值将会被返回的那个布尔表达式,你就会发现它与大多数
布尔 AND 操作有一点不一样。这个表达式是 a.length >= 3 & a[2] == 3。通常,
你在这种情况下看到的是 && 操作符,而这个表达式使用的是 & 操作符。那是
一个位 AND 操作符吗?
事实证明 & 操作符有其他的含义。除了常见的被当作整型操作数的位 AND 操作
符之外,当被用于布尔操作数时,它的功能被重载为逻辑 AND 操作符[JLS
15.22.2]。这个操作符与更经常被使用的条件 AND 操作符有所不同,& 操作符总
是要计算它的两个操作数,而 && 操作符在其左边的操作数被计算为 false 时,
就不再计算右边的操作数了[JLS 15.23]。因此,thirdElementIsThree 方法总
是要试图访问其数组参数的第三个元素,即使该数组参数的元素不足 3 个也是如
此。订正这个方法只需将 & 操作符替换为 && 操作符即可。通过这样的修改,
这个程序就可以打印出我们所期望的 2 了:
1 | private static boolean thirdElementIsThree(int[] a) { |
正像有一个逻辑 AND 操作符伴随着更经常被使用的条件 AND 操作符一样,还有一
个逻辑 OR 操作符(|)也伴随着条件 OR 操作符(||)[JLS 15.22.2,15.24]。| 操
作符总是要计算它的两个操作数,而 || 操作符在其左边的操作数被计算为
true 时,就不再计算右边的操作数了。我们一不注意,就很容易使用了逻辑操
作符而不是条件操作符。遗憾的是,编译器并不能帮助你发现这种错误。有意识
地使用逻辑操作符的情形非常少见,少到了我们对所有这样使用的程序都应该持怀疑态度的地步。如果你真的想使用这样的操作符,为了是你的意图清楚起见,
请加上注释。
总之,不要去用那些可怕的使用异常而不是使用显式的终止测试的循环惯用法,
因为这种惯用法非常不清晰,而且会掩盖 bug。要意识到逻辑 AND 和 OR 操作符
的存在,并且不要因无意识的误用而受害。对语言设计者来说,这又是一个操作
符重载会导致混乱的明证。对于在条件 AND 和 OR 操作符之外还要提供逻辑 AND
和 OR 操作符这一点,并没有很明显的理由。如果这些操作符确实要得到支持的
话,它们应该与其相对应的条件操作符存在着视觉上的明显差异。
谜题 43:异常地危险
1 | 在 JDK1.2 中,Thread.stop、Thread.suspend 以及其他许多线程相关的方法都 |
这个讨厌的小方法所做的事情正是 throw 语句要做的事情,但是它绕过了编译器
的所有异常检查操作。你可以(卑鄙地)在你的代码的任意一点上抛出任何受检
查的或不受检查的异常,而编译器对此连眉头都不会皱一下。
不使用任何不推荐的方法,你也可以编写出在功能上等价于 sneakyThrow 的方
法。事实上,至少有两种方式可以这么实现这一点,其中一种只能在 5.0 或更新
的版本中运行。你能够编写出这样的方法吗?它必须是用 Java 而不是用 JVM 字
节码编写的,你不能在其客户对它编译完之后再去修改它。你的方法不必是完美
无瑕的:如果它不能抛出一两个 Exception 的子类,也是可以接受的。
本谜题的一种解决之道是利用 Class.newInstance 方法中的设计缺陷,该方法通
过反射来对一个类进行实例化。引用有关该方法的文档中的话[Java-API]:“请
注意,该方法将传播从空的[换句话说,就是无参数的]构造器所抛出的任何异常,
包括受检查的异常。使用这个方法可以有效地绕开在其他情况下都会执行的编译
期异常检查。”一旦你了解了这一点,编写一个 sneakyThrow 的等价方法就不是
太难了。
1 | public class Thrower { |
在这个解决方案中将会发生许多微妙的事情。我们想要在构造器执行期间所抛出
的异常不能作为一个参数传递给该构造器,因为 Class.newInstance 调用的是一
个类的无参数构造器。因此,sneakyThrow 方法将这个异常藏匿于一个静态变量
中。为了使该方法是线程安全的,它必须被同步,这使得对其的并发调用将顺序
地使用静态域 t。
要注意的是,t 这个域在从 finally 语句块中出来时是被赋为空的:这只是因为
该方法虽然是卑鄙的,但这并不意味着它还应该是内存泄漏的。如果这个域不是
被赋为空出来的,那么它阻止该异常被垃圾回收。最后,请注意,如果你让该方
法抛出一个 InstantiationException 或是一个 IllegalAccessException 异常,
它将以抛出一个 IllegalArgumentException 而失败。这是这项技术的一个内在
限制。
Class.newInstance 的文档继续描述道“Constructor.newInstance 方法通过将
构造器抛出的任何异常都包装在一个(受检查的)InvocationTargetException
异常中而避免了这个问题。”很明显,Class.newInstance 应该是做了相同的处
理,但是纠正这个缺陷已经为时过晚,因为这么做将引入源代码级别的不兼容性,
这将使许多依赖于 Class.newInstance 的程序崩溃。而弃用这个方法也不切实
际,因为它太常用了。当你在使用它时,一定要意识到 Class.newInstance 可以
抛出它没有声明过的受检查异常。
被添加到 5.0 版本中的“通用类型(generics)”可以为本谜题提供一个完全不
同的解决方案。为了实现最大的兼容性,通用类型是通过类型擦除(type
erasure)来实现的:通用类型信息是在编译期而非运行期检查的[JLS 4.7]。
下面的解决方案就利用了这项技术:
1 | // Don't do this either - circumvents exception checking! |
这个程序在编译时将产生一条警告信息:
1 | TigerThrower.java:7:warning: [unchecked] unchecked cast |
警告信息是编译器所采用的一种手段,用来告诉你:你可能正在搬起石头砸自己
的脚,而且事实也正是如此。“不受检查的转型”警告告诉你这个有问题的转型
将不会在运行时刻受到检查。当你获得了一个不受检查的转型警告时,你应该修
改你的程序以消除它,或者你可以确信这个转型不会失败。如果你不这么做,那
么某个其他的转型可能会在未来不确定的某个时刻失败,而你也就很难跟踪此错
误到其源头了。对于本谜题所示的情况,其情况更糟糕:在运行期抛出的异常可
能与方法的签名不一致。sneakyThrow2 方法正是利用了这一点。
对平台设计者来说,有好几条教训。在设计诸如反射类库之类在语言之外实现的
类库时, 要保留语言所作的所有承诺。当从头设计一个支持通用类型的平台时,
要考虑强制要求其在运行期的正确性。Java 通用类型工具的设计者可没有这么
做,因为他们受制于通用类库必须能够与现有客户进行互操作的要求。对于违反
方法签名的异常,为了消除其产生的可能性,应该考虑强制在运行期进行异常检
查。
总之,Java 的异常检查机制并不是虚拟机强制执行的。它只是一个编译期工具,
被设计用来帮助我们更加容易地编写正确的程序,但是在运行期可以绕过它。要
想减少你因为这类问题而被曝光的次数,就不要忽视编译器给出的警告信息。
谜题 44:切掉类
请考虑下面的两个类:
1 | public class Strange1 { |
Strange1 和 Strange2 都用到了下面这个类:
1 | class Missing { |
如果你编译所有这三个类,然后在运行 Strange1 和 Strange2 之前删除
Missing.class 文件,你就会发现这两个程序的行为有所不同。其中一个抛出了
一个未被捕获的 NoClassDefFoundError 异常,而另一个却打印出了 Got it! 到
底哪一个程序具有哪一种行为,你又如何去解释这种行为上的差异呢?
程序 Strange1 只在其 try 语句块中提及 Missing 类型,因此你可能会认为它捕
获 NoClassDefFoundError 异常,并打印 Got it!另一方面,程序 Strange2 在
try 语句块之外声明了一个 Missing 类型的变量,因此你可能会认为所产生的
NoClassDefFoundError 异常不会被捕获。如果你试着运行这些程序,就会看到
它们的行为正好相反:Strange1 抛出了未被捕获的 NoClassDefFoundError 异常,
而 Strange2 却打印出了 Got it!怎样才能解释这些奇怪的行为呢?
如果你去查看 Java 规范以找出应该抛出 NoClassDefFoundError 异常的地方,那
么你不会得到很多的指导信息。该规范描述道,这个错误可以“在(直接或间接)
使用某个类的程序中的任何地方”抛出[JLS 12.2.1]。当 VM 调用 Strange1 和
Strange2 的 main 方法时,这些程序都间接使用了 Missing 类,因此,它们都在
其权利范围内于这一点上抛出了该错误。
于是,本谜题的答案就是这两个程序可以依据其实现而展示出各自不同的行为。
但是这并不能解释为什么这些程序在所有我们所知的 Java 实现上的实际行为,
与你所认为的必然行为都正好相反。要查明为什么会是这样,我们需要研究一下
由编译器生成的这些程序的字节码。
如果你去比较 Strange1 和 Strange2 的字节码,就会发现几乎是一样的。除了类
名之外,唯一的差异就是 catch 语句块所捕获的参数 ex 与 VM 本地变量之间的映
射关系不同。尽管哪一个程序变量被指派给了哪一个 VM 变量的具体细节会因编
译器的不同而有所差异,但是对于和上述程序一样简单的程序来说,这些细节不
太可能会差异很大。下面是通过执行 javap -c Strange1 命令而显示的
Strange1.main 的字节码:
1 | 0: new |
Strange2.main 相对应的字节码与其只有一条指令不同:
1 | 11: astore_2 |
这是一条将 catch 语句块中的捕获异常存储到捕获参数 ex 中的指令。在
Strange1 中,这个参数是存储在 VM 变量 1 中的,而在 Strange2 中,它是存储
在 VM 变量 2 中的。这就是两个类之间唯一的差异,但是它所造成的程序行为上
的差异是多么地大呀!
为了运行一个程序,VM 要加载和初始化包含 main 方法的类。在加载和初始化之
间,VM 必须链接(link)类[JLS 12.3]。链接的第一阶段是校验,校验要确保
一个类是良构的,并且遵循语言的语法要求。校验非常关键,它维护着可以将像
Java 这样的安全语言与像 C 或 C++这样的不安全语言区分开的各种承诺。
在 Strange1 和 Strange2 这两个类中,本地变量 m 碰巧都被存储在 VM 变量 1 中。
两个版本的 main 都有一个连接点,从两个不同位置而来的控制流汇聚于此。该
连接点就是指令 20,即从 main 返回的指令。在正常结束 try 语句块的情况下,
我们执行到指令 8,即 goto 20,从而可以到达指令 20;而对于在 catch 语句块
中结束的情况,我们将执行指令 17,并按顺序执行下去,到达指令 20。
连接点的存在使得在校验 Strange1 类时产生异常,而在校验 Strange2 类时并不
会产生异常。当校验去执行对 Strange1.main 的流分析(flow analysis)[JLS
12.3.1]时,由于指令 20 可以通过两条不同的路径到达,因此校验器必须合并在
变量 1 中的类型。两种类型是通过计算它们的首个公共超类(first common
superclass)[JVMS 4.9.2]而合并的。两个类的首个公共超类是它们所共有的最
详细而精确的超类。
在 Strange1.main 方法中,当从指令 8 到达指令 20 时,VM 变量 1 的状态包含了
一个 Missing 类的实例。当从指令 17 到达时,它包含了一个
NoClassDefFoundError 类的实例。为了计算首个公共超类,校验器必须加载
Missing 类以确定其超类。因为 Missing.class 文件已经被删除了,所以校验器
不能加载它,因而抛出了一个 NoClassDefFoundError 异常。请注意,这个异常
是在校验期间、在类被初始化之前,并且在 main 方法开始执行之前很早就抛出
的。这就解释了为什么没有打印出任何关于这个未被捕获异常的跟踪栈信息。
要想编写一个能够探测出某个类是否丢失的程序,请使用反射来引用类而不要使
用通常的语言结构[EJ Item35]。 下面展示了用这种技巧重写的程序:
1 | public class Strange { |
总之,不要对捕获 NoClassDefFoundError 形成依赖。语言规范非常仔细地描述
了类初始化是在何时发生的[JLS 12.4.1],但是类被加载的时机却显得更加不可
预测。更一般地讲,捕获 Error 及其子类型几乎是完全不恰当的。这些异常是为
那些不能被恢复的错误而保留的。
谜题 45:令人疲惫不堪的测验
本谜题将测试你对递归的了解程度。下面的程序将做些什么呢?
1 | public class Workout { |
要不是有 try-finally 语句,该程序的行为将非常明显:workHard 方法递归地
调用它自身,直到程序抛出 StackOverflowError,在此刻它以这个未捕获的异
常而终止。但是,try-finally 语句把事情搞得复杂了。当它试图抛出
StackOverflowError 时,程序将会在 finally 语句块的 workHard 方法中终止,
这样,它就递归调用了自己。这看起来确实就像是一个无限循环的秘方,但是这
个程序真的会无限循环下去吗?如果你运行它,它似乎确实是这么做的,但是要
想确认的唯一方式就是分析它的行为。 Java 虚拟机对栈的深度限制到了某个预设的水平。当超过这个水平时,VM 就抛
出 StackOverflowError。为了让我们能够更方便地考虑程序的行为,我们假设
栈的深度为 3,这比它实际的深度要小得多。现在让我们来跟踪其执行过程。
main 方法调用 workHard,而它又从其 try 语句块中递归地调用了自己,然后它
再一次从其 try 语句块中调用了自己。在此时,栈的深度是 3。当 workHard 方
法试图从其 try 语句块中再次调用自己时,该调用立即就会以
StackOverflowError 而失败。这个错误是在最内部的 finally 语句块中被捕获
的,在此处栈的深度已经达到了 3。在那里,workHard 方法试图递归地调用它自
己,但是该调用却以 StackOverflowError 而失败。这个错误将在上一级的
finally 语句块中被捕获,在此处站的深度是 2。该 finally 中的调用将与相对
应的 try 语句块具有相同的行为:最终都会产生一个 StackOverflowError。这
似乎形成了一种模式,而事实也确实如此。
WorkOut 的运行过程如左面的图所示。在这张图中,对 workHard 的调用用
箭头表示,workHard 的执行用圆圈表示。所有的调用除了一个之外,都是递归
的。会立即产生 StackOverflowError 异常的调用用由灰色圆圈前导的箭头表示,
try 语句块中的调用用向左边的向下箭头表示,finally 语句块中的调用用向右
边的向下箭头表示。箭头上的数字描述了调用的顺序。
这张图展示了一个深度为 0 的调用(即 main 中的调用),两个深度为 1 的调用,
四个深度为 2 的调用,和八个深度为 3 的调用,总共是 15 个调用。那八个深度
为 3 的调用每一个都会立即产生 StackOverflowError。至少在把栈的深度限制
为 3 的 VM 上,该程序不会是一个无限循环:它在 15 个调用和 8 个异常之后就会
终止。但是对于真实的 VM 又会怎样呢?它仍然不会是一个无限循环。其调用图
与前面的图相似,只不过要大得多得多而已。
那么,究竟大到什么程度呢?有一个快速的试验表明许多 VM 都将栈的深度限制
为 1024,因此,调用的数量就是 1+2+4+8…+21,024=21,025-1,而抛出的异常的
数量是 21,024。假设我们的机器可以在每秒钟内执行 1010 个调用,并产生 1010
个异常,按照当前的标准,这个假设的数量已经相当高了。在这样的假设条件下,
程序将在大约 1.7×10291 年后终止。为了让你对这个时间有直观的概念,我告
诉你,我们的太阳的生命周期大约是 1010 年,所以我们可以很确定,我们中没
有任何人能够看到这个程序终止的时刻。尽管它不是一个无限循环,但是它也就
算是一个无限循环吧。
从技术角度讲,调用图是一棵完全二叉树,它的深度就是 VM 的栈深度的上限。
WorkOut 程序的执行过程等于是在先序遍历这棵树。在先序遍历中,程序先访问
一个节点,然后递归地访问它的左子树和右子树。对于树中的每一条边,都会产
生一个调用,而对于树中的每一个节点,都会抛出一个异常。
本谜题没有很多关于教训方面的东西。它证明了指数算法对于除了最小输入之外
的所有情况都是不可行的,它还表明了你甚至可以不费什么劲就可以编写出一个
指数算法。