Java解惑4——异常谜题(易混淆10处)
备注:
本篇博客所有代码,本人均在Myeclipse,JDK版本1.6下实验调试通过,调试时间为2013.11.19。
Java谜题:
谜题36:优柔寡断
下面这个可怜的小程序并不能很好地做出其自己的决定。它的decision方法将返回true,但是它还返回了false。那么,它到底打印的是什么呢?甚至,它是合法的吗?
publicclassIndecisive{
publicstaticvoidmain(String[]args){
System.out.println(decision());
}
staticbooleandecision(){
try{
returntrue;
}finally{
returnfalse;
}
}
}
你可能会认为这个程序是不合法的。毕竟,decision方法不能同时返回true和false。如果你尝试一下,就会发现它编译时没有任何错误,并且它所打印的是false。为什么呢?
原因就是在一个try-finally语句中,finally语句块总是在控制权离开try语句块时执行的[JLS14.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语句块可以正常结束[JLS14.21]。return、break或continue语句把控制权传递到finally语句块之外应该是被禁止的,任何可以引发将被检查异常传播到finally语句块之外的语句也同样应该是被禁止的。
谜题37:极端不可思议
本谜题测试的是你对某些规则的掌握程度,这些规则用于声明从方法中抛出并被catch语句块所捕获的异常。下面的三个程序每一个都会打印些什么?不要假设它们都可以通过编译:
importjava.io.IOException;
publicclassArcane1{
publicstaticvoidmain(String[]args){
try{
System.out.println("Helloworld");
}catch(IOExceptione){
System.out.println("I'veneverseen
printlnfail!");
}
}
}
publicclassArcane2{
publicstaticvoidmain(String[]args){
try{
//Ifyouhavenothingnicetosay,saynothing
}catch(Exceptione){
System.out.println("Thiscan't
happen");
}
}
}
interfaceType1{
voidf()throwsCloneNotSupportedException;
}
interfaceType2{
voidf()throwsInterruptedException;
}
interfaceType3extendsType1,Type2{
}
publicclassArcane3implementsType3{
publicvoidf(){
System.out.println("Helloworld");
}
publicstaticvoidmain(String[]args){
Type3t3=newArcane3();
t3.f();
}
}
第一个程序,Arcane1,展示了被检查异常的一个基本原则。它看起来应该是可以编译的:try子句执行I/O,并且catch子句捕获IOException异常。但是这个程序不能编译,因为println方法没有声明会抛出任何被检查异常,而IOException却正是一个被检查异常。语言规范中描述道:如果一个catch子句要捕获一个类型为E的被检查异常,而其相对应的try子句不能抛出E的某种子类型的异常,那么这就是一个编译期错误[JLS11.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可以毫无错误地通过编译,并且打印Helloworld。
总之,第一个程序说明了一项基本要求,即对于捕获被检查异常的catch子句,只有在相应的try子句可以抛出这些异常时才被允许。第二个程序说明了这项要求不会应用到的冷僻案例。第三个程序说明了多个继承而来的throws子句的交集,将减少而不是增加方法允许抛出的异常数量。本谜题所说明的行为一般不会引发难以捉摸的bug,但是你第一次看到它们时,可能会有点吃惊。
谜题38:不受欢迎的宾客
本谜题中的程序所建模的系统,将尝试着从其环境中读取一个用户ID,如果这种尝试失败了,则缺省地认为它是一个来宾用户。该程序的作者将面对有一个静态域的初始化表达式可能会抛出异常的情况。因为Java不允许静态初始化操作抛出被检查异常,所以初始化必须包装在try-finally语句块中。那么,下面的程序会打印出什么呢?
publicclassUnwelcomeGuest{
publicstaticfinallongGUEST_USER_ID=-1;
privatestaticfinallongUSER_ID;
static{
try{
USER_ID=getUserIdFromEnvironment();
}catch(IdUnavailableExceptione){
USER_ID=GUEST_USER_ID;
System.out.println("Logginginasguest");
}
}
privatestaticlonggetUserIdFromEnvironment()
throwsIdUnavailableException{
thrownewIdUnavailableException();
}
publicstaticvoidmain(String[]args){
System.out.println("UserID:"+USER_ID);
}
}
classIdUnavailableExceptionextendsException{
}
该程序看起来很直观。对getUserIdFromEnvironment的调用将抛出一个异常,从而使程序将GUEST_USER_ID(-1L)赋值给USER_ID,并打印Loggininasguest。然后main方法执行,使程序打印UserID:-1。表象再次欺骗了我们,该程序并不能编译。如果你尝试着去编译它,你将看到和下面内容类似的一条错误信息:
UnwelcomeGuest.java:10:
variableUSER_IDmightalreadyhavebeenassigned
USER_ID=GUEST_USER_ID;
^
题出在哪里了?USER_ID域是一个空final(blankfinal),它是一个在声明中没有进行初始化操作的final域[JLS4.12.4]。很明显,只有在对USER_ID赋值失败时,才会在try语句块中抛出异常,因此,在catch语句块中赋值是相当安全的。不管怎样执行静态初始化操作语句块,只会对USER_ID赋值一次,这正是空final所要求的。为什么编译器不知道这些呢?
要确定一个程序是否可以不止一次地对一个空final进行赋值是一个很困难的问题。事实上,这是不可能的。这等价于经典的停机问题,它通常被认为是不可能解决的[Turing36]。为了能够编写出一个编译器,语言规范在这一点上采用了保守的方式。在程序中,一个空final域只有在它是明确未赋过值的地方才可以被赋值。规范长篇大论,对此术语提供了一个准确的但保守的定义[JLS16]。因为它是保守的,所以编译器必须拒绝某些可以证明是安全的程序。这个谜题就展示了这样的一个程序。
幸运的是,你不必为了编写Java程序而去学习那些骇人的用于明确赋值的细节。通常明确赋值规则不会有任何妨碍。如果碰巧你编写了一个真的可能会对一个空final赋值超过一次的程序,编译器会帮你指出的。只有在极少的情况下,就像本谜题一样,你才会编写出一个安全的程序,但是它并不满足规范的形式化要求。编译器的抱怨就好像是你编写了一个不安全的程序一样,而且你必须修改你的程序以满足它。
解决这类问题的最好方式就是将这个烦人的域从空final类型改变为普通的final类型,用一个静态域的初始化操作替换掉静态的初始化语句块。实现这一点的最佳方式是重构静态语句块中的代码为一个助手方法:
publicclassUnwelcomeGuest{
publicstaticfinallongGUEST_USER_ID=-1;
privatestaticfinallongUSER_ID=getUserIdOrGuest;
privatestaticlonggetUserIdOrGuest{
try{
returngetUserIdFromEnvironment();
}catch(IdUnavailableExceptione){
System.out.println("Logginginasguest");
returnGUEST_USER_ID;
}
}
...//Therestoftheprogramisunchanged
}
程序的这个版本很显然是正确的,而且比最初的版本根据可读性,因为它为了域值的计算而增加了一个描述性的名字,而最初的版本只有一个匿名的静态初始化操作语句块。将这样的修改作用于程序,它就可以如我们的期望来运行了。
总之,大多数程序员都不需要学习明确赋值规则的细节。该规则的作为通常都是正确的。如果你必须重构一个程序,以消除由明确赋值规则所引发的错误,那么你应该考虑添加一个新方法。这样做除了可以解决明确赋值问题,还可以使程序的可读性提高。
谜题39:您好,再见!
下面的程序在寻常的Helloworld程序中添加了一段不寻常的曲折操作。那么,它将会打印出什么呢?
publicclassHelloGoodbye{
publicstaticvoidmain(String[]args){
try{
System.out.println("Helloworld");
System.exit(0);
}finally{
System.out.println("Goodbyeworld");
}
}
}
这个程序包含两个println语句:一个在try语句块中,另一个在相应的finally语句块中。try语句块执行它的println语句,并且通过调用System.exit来提前结束执行。在此时,你可能希望控制权会转交给finally语句块。然而,如果你运行该程序,就会发现它永远不会说再见:它只打印了Helloworld。这是否违背了谜题36中所解释的原则呢?
不论try语句块的执行是正常地还是意外地结束,finally语句块确实都会执行。然而在这个程序中,try语句块根本就没有结束其执行过程。System.exit方法将停止当前线程和所有其他当场死亡的线程。finally子句的出现并不能给予线程继续去执行的特殊权限。
当System.exit被调用时,虚拟机在关闭前要执行两项清理工作。首先,它执行所有的关闭挂钩操作,这些挂钩已经注册到了Runtime.addShutdownHook上。这对于释放VM之外的资源将很有帮助。务必要为那些必须在VM退出之前发生的行为关闭挂钩。下面的程序版本示范了这种技术,它可以如我们所期望地打印出Helloworld和Goodbyeworld:
publicclassHelloGoodbye1{
publicstaticvoidmain(String[]args){
System.out.println("Helloworld");
Runtime.getRuntime().addShutdownHook(
newThread(){
publicvoidrun(){
System.out.println("Goodbyeworld");
}
});
System.exit(0);
}
}
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子句就很少见了。下面的程序就有这样的一个声明。那么,它将打印出什么呢?
publicclassReluctant{
privateReluctantinternalInstance=newReluctant();
publicReluctant()throwsException{
thrownewException("I'mnotcomingout");
}
publicstaticvoidmain(String[]args){
try{
Reluctantb=newReluctant();
System.out.println("Surprise!");
}catch(Exceptionex){
System.out.println("Itoldyouso");
}
}
}
main方法调用了Reluctant构造器,它将抛出一个异常。你可能期望catch子句能够捕获这个异常,并且打印Itoldyouso。凑近仔细看看这个程序就会发现,Reluctant实例还包含第二个内部实例,它的构造器也会抛出一个异常。无论抛出哪一个异常,看起来main中的catch子句都应该捕获它,因此预测该程序将打印Itoldyou应该是一个安全的赌注。但是当你尝试着去运行它时,就会发现它压根没有去做这类的事情:它抛出了StackOverflowError异常,为什么呢?
与大多数抛出StackOverflowError异常的程序一样,本程序也包含了一个无限递归。当你调用一个构造器时,实例变量的初始化操作将先于构造器的程序体而运行[JLS12.5]。在本谜题中,internalInstance变量的初始化操作递归调用了构造器,而该构造器通过再次调用Reluctant构造器而初始化该变量自己的internalInstance域,如此无限递归下去。这些递归调用在构造器程序体获得执行机会之前就会抛出StackOverflowError异常,因为StackOverflowError是Error的子类型而不是Exception的子类型,所以catch子句无法捕获它。
对于一个对象包含与它自己类型相同的实例的情况,并不少见。例如,链接列表节点、树节点和图节点都属于这种情况。你必须非常小心地初始化这样的包含实例,以避免StackOverflowError异常。
至于本谜题名义上的题目:声明将抛出异常的构造器,你需要注意,构造器必须声明其实例初始化操作会抛出的所有被检查异常。下面这个展示了常见的“服务提供商”模式的程序,将不能编译,因为它违反了这条规则:
publicclassCar{
privatestaticClassengineClass=...;
privateEngineengine=
(Engine)enginClass.newInstance();
publicCar(){}
}
尽管其构造器没有任何程序体,但是它将抛出两个被检查异常,InstantiationException和IllegalAccessException。它们是Class.Instance抛出的,该方法是在初始化engine域的时候被调用的。订正该程序的最好方式是创建一个私有的、静态的助手方法,它负责计算域的初始值,并恰当地处理异常。在本案中,我们假设选择engineClass所引用的Class对象,保证它是可访问的并且是可实例化的。
下面的Car版本将可以毫无错误地通过编译:
//Fixed-instanceinitializersdon’tthrowcheckedexceptions
publicclassCar{
privatestaticClassengineClass=...;
privateEngineengine=newEngine;
privatestaticEnginenewEngine(){
try{
return(Engine)engineClass.newInstance();
}catch(IllegalAccessExceptione){
thrownewAssertionError(e);
}catch(InstantiationExceptione){
thrownewAssertionError(e);
}
}
publicCar(){}
}
总之,实例初始化操作是先于构造器的程序体而运行的。实例初始化操作抛出的任何异常都会传播给构造器。如果初始化操作抛出的是被检查异常,那么构造器必须声明也会抛出这些异常,但是应该避免这样做,因为它会造成混乱。最后,对于我们所设计的类,如果其实例包含同样属于这个类的其他实例,那么对这种无限递归要格外当心。
谜题41:域和流
下面的方法将一个文件拷贝到另一个文件,并且被设计为要关闭它所创建的每一个流,即使它碰到I/O错误也要如此。遗憾的是,它并非总是能够做到这一点。为什么不能呢,你如何才能订正它呢?
staticvoidcopy(Stringsrc,Stringdest)throwsIOException{
InputStreamin=null;
OutputStreamout=null;
try{
in=newFileInputStream(src);
out=newFileOutputStream(dest);
byte[]buf=newbyte[1024];
intn;
while((n=in.read(buf))>0)
out.write(buf,0,n);
}finally{
if(in!=null)in.close();
if(out!=null)out.close();
}
}
这个程序看起来已经面面俱到了。其流域(in和out)被初始化为null,并且新的流一旦被创建,它们马上就被设置为这些流域的新值。对于这些域所引用的流,如果不为空,则finally语句块会将其关闭。即便在拷贝操作引发了一个IOException的情况下,finally语句块也会在方法返回之前执行。出什么错了呢?
问题在finally语句块自身中。close方法也可能会抛出IOException异常。如果这正好发生在in.close被调用之时,那么这个异常就会阻止out.close被调用,从而使输出流仍保持在开放状态。
请注意,该程序违反了谜题36的建议:对close的调用可能会导致finally语句块意外结束。遗憾的是,编译器并不能帮助你发现此问题,因为close方法抛出的异常与read和write抛出的异常类型相同,而其外围方法(copy)声明将传播该异常。
解决方式是将每一个close都包装在一个嵌套的try语句块中。下面的finally语句块的版本可以保证在两个流上都会调用close:
}finally{
if(in!=null){
try{
in.close();
}catch(IOExceptionex){
//Thereisnothingwecandoifclosefails
}
if(out!=null)
try{
out.close();
}catch(IOExceptionex){
//Thereisnothingwecandoifclosefails
}
}
}
从5.0版本开始,你可以对代码进行重构,以利用Closeable接口:
}finally{
closeIgnoringException(in);
closeIgnoringEcception(out);
}
privatestaticvoidcloseIgnoringException(Closeablec){
if(c!=null){
try{
c.close();
}catch(IOExceptionex){
//Thereisnothingwecandoifclosefails
}
}
}
总之,当你在finally语句块中调用close方法时,要用一个嵌套的try-catch语句来保护它,以防止IOException的传播。更一般地讲,对于任何在finally语句块中可能会抛出的被检查异常都要进行处理,而不是任其传播。这是谜题36中的教训的一种特例,而对语言设计着的教训情况也相同。
谜题42:异常为循环而抛
下面的程序循环遍历了一个int类型的数组序列,并且记录了满足某个特定属性的数组个数。那么,该程序会打印出什么呢?
publicclassLoop{
publicstaticvoidmain(String[]args){
int[][]tests={{6,5,4,3,2,1},{1,2},
{1,2,3},{1,2,3,4},{1}};
intsuccessCount=0;
try{
inti=0;
while(true){
if(thirdElementIsThree(tests[i++]))
successCount++;
}
}catch(ArrayIndexOutOfBoundsExceptione){
//Nomoreteststoprocess
}
System.out.println(successCount);
}
privatestaticbooleanthirdElementIsThree(int[]a){
returna.length>=3&a[2]==3;
}
}
该程序用thirdElementIsThree方法测试了tests数组中的每一个元素。遍历这个数组的循环显然是非传统的循环:它不是在循环变量等于数组长度的时候终止,而是在它试图访问一个并不在数组中的元素时终止。尽管它是非传统的,但是这个循环应该可以工作。如果传递给thirdElementIsThree的参数具有3个或更多的元素,并且其第三个元素等于3,那么该方法将返回true。对于tests中的5个元素来说,有2个将返回true,因此看起来该程序应该打印2。如果你运行它,就会发现它打印的时0。肯定是哪里出了问题,你能确定吗?
事实上,这个程序犯了两个错误。第一个错误是该程序使用了一种可怕的循环惯用法,该惯用法依赖的是对数组的访问会抛出异常。这种惯用法不仅难以阅读,而且运行速度还非常地慢。不要使用异常来进行循环控制;应该只为异常条件而使用异常[EJItem39]。为了纠正这个错误,可以将整个try-finally语句块替换为循环遍历数组的标准惯用法:
for(inti=0;i<test.length;i++)
if(thirdElementIsThree(tests[i]))
successCount++;
如果你使用的是5.0或者是更新的版本,那么你可以用for循环结构来代替:
for(int[]test:tests)
if(thirdElementIsThree(test))
successCount++;
就第一个错误的糟糕情况来说,只有它自己还不足以产生我们所观察到的行为。然而,订正该错误可以帮助我们找到真正的bug,它更加深奥:
Exceptioninthread"main"
java.lang.ArrayIndexOutOfBoundsException:2
atLoop1.thirdElementIsThree(Loop1.java:19)
atLoop1.main(Loop1.java:13)
很明显,在thirdElementIsThree方法中有一个bug:它抛出了一个ArrayIndexOutOfBoundsException异常。这个异常先前伪装成了那个可怕的基于异常的循环的终止条件。
如果传递给thirdElementIsThree的参数具有3个或更多的元素,并且其第三个元素等于3,那么该方法将返回true。问题是在这些条件不满足时它会做些什么呢。如果你仔细观察其值将会被返回的那个布尔表达式,你就会发现它与大多数布尔AND操作有一点不一样。这个表达式是a.length>=3&a[2]==3。通常,你在这种情况下看到的是&&操作符,而这个表达式使用的是&操作符。那是一个位AND操作符吗?
事实证明&操作符有其他的含义。除了常见的被当作整型操作数的位AND操作符之外,当被用于布尔操作数时,它的功能被重载为逻辑AND操作符[JLS15.22.2]。这个操作符与更经常被使用的条件AND操作符有所不同,&操作符总是要计算它的两个操作数,而&&操作符在其左边的操作数被计算为false时,就不再计算右边的操作数了[JLS15.23]。因此,thirdElementIsThree方法总是要试图访问其数组参数的第三个元素,即使该数组参数的元素不足3个也是如此。订正这个方法只需将&操作符替换为&&操作符即可。通过这样的修改,这个程序就可以打印出我们所期望的2了:
privatestaticbooleanthirdElementIsThree(int[]a){
returna.length>=3&&a[2]==3;
}
正像有一个逻辑AND操作符伴随着更经常被使用的条件AND操作符一样,还有一个逻辑OR操作符(|)也伴随着条件OR操作符(||)[JLS15.22.2,15.24]。|操作符总是要计算它的两个操作数,而||操作符在其左边的操作数被计算为true时,就不再计算右边的操作数了。我们一不注意,就很容易使用了逻辑操作符而不是条件操作符。遗憾的是,编译器并不能帮助你发现这种错误。有意识地使用逻辑操作符的情形非常少见,少到了我们对所有这样使用的程序都应该持怀疑态度的地步。如果你真的想使用这样的操作符,为了是你的意图清楚起见,请加上注释。
总之,不要去用那些可怕的使用异常而不是使用显式的终止测试的循环惯用法,因为这种惯用法非常不清晰,而且会掩盖bug。要意识到逻辑AND和OR操作符的存在,并且不要因无意识的误用而受害。对语言设计者来说,这又是一个操作符重载会导致混乱的明证。对于在条件AND和OR操作符之外还要提供逻辑AND和OR操作符这一点,并没有很明显的理由。如果这些操作符确实要得到支持的话,它们应该与其相对应的条件操作符存在着视觉上的明显差异。
谜题43:异常地危险
在JDK1.2中,Thread.stop、Thread.suspend以及其他许多线程相关的方法都因为它们不安全而不推荐使用了[ThreadStop]。下面的方法展示了你用Thread.stop可以实现的可怕事情之一。
//Don’tdothis-circumventsexceptionchecking!
publicstaticvoidsneakyThrow(Throwablet){
Thread.currentThread().stop(t);//Deprecated!!
}
这个讨厌的小方法所做的事情正是throw语句要做的事情,但是它绕过了编译器的所有异常检查操作。你可以(卑鄙地)在你的代码的任意一点上抛出任何受检查的或不受检查的异常,而编译器对此连眉头都不会皱一下。
不使用任何不推荐的方法,你也可以编写出在功能上等价于sneakyThrow的方法。事实上,至少有两种方式可以这么实现这一点,其中一种只能在5.0或更新的版本中运行。你能够编写出这样的方法吗?它必须是用Java而不是用JVM字节码编写的,你不能在其客户对它编译完之后再去修改它。你的方法不必是完美无瑕的:如果它不能抛出一两个Exception的子类,也是可以接受的。
本谜题的一种解决之道是利用Class.newInstance方法中的设计缺陷,该方法通过反射来对一个类进行实例化。引用有关该方法的文档中的话[Java-API]:“请注意,该方法将传播从空的[换句话说,就是无参数的]构造器所抛出的任何异常,包括受检查的异常。使用这个方法可以有效地绕开在其他情况下都会执行的编译期异常检查。”一旦你了解了这一点,编写一个sneakyThrow的等价方法就不是太难了。
publicclassThrower{
privatestaticThrowablet;
privateThrower()throwsThrowable{
throwt;
}
publicstaticsynchronizedvoidsneakyThrow(Throwablet){
Thrower.t=t;
try{
Thrower.class.newInstance();
}catch(InstantiationExceptione){
thrownewIllegalArgumentException();
}catch(IllegalAccessExceptione){
thrownewIllegalArgumentException();
}finally{
Thrower.t=null;//Avoidmemoryleak
}
}
}
在这个解决方案中将会发生许多微妙的事情。我们想要在构造器执行期间所抛出的异常不能作为一个参数传递给该构造器,因为Class.newInstance调用的是一个类的无参数构造器。因此,sneakyThrow方法将这个异常藏匿于一个静态变量中。为了使该方法是线程安全的,它必须被同步,这使得对其的并发调用将顺序地使用静态域t。
要注意的是,t这个域在从finally语句块中出来时是被赋为空的:这只是因为该方法虽然是卑鄙的,但这并不意味着它还应该是内存泄漏的。如果这个域不是被赋为空出来的,那么它阻止该异常被垃圾回收。最后,请注意,如果你让该方法抛出一个InstantiationException或是一个IllegalAccessException异常,它将以抛出一个IllegalArgumentException而失败。这是这项技术的一个内在限制。
Class.newInstance的文档继续描述道“Constructor.newInstance方法通过将构造器抛出的任何异常都包装在一个(受检查的)InvocationTargetException异常中而避免了这个问题。”很明显,Class.newInstance应该是做了相同的处理,但是纠正这个缺陷已经为时过晚,因为这么做将引入源代码级别的不兼容性,这将使许多依赖于Class.newInstance的程序崩溃。而弃用这个方法也不切实际,因为它太常用了。当你在使用它时,一定要意识到Class.newInstance可以抛出它没有声明过的受检查异常。
被添加到5.0版本中的“通用类型(generics)”可以为本谜题提供一个完全不同的解决方案。为了实现最大的兼容性,通用类型是通过类型擦除(typeerasure)来实现的:通用类型信息是在编译期而非运行期检查的[JLS4.7]。
下面的解决方案就利用了这项技术:
//Don'tdothiseither-circumventsexceptionchecking!
classTigerThrower<TextendsThrowable>{
publicstaticvoidsneakyThrow(Throwablet){
newTigerThrower<Error>().sneakyThrow2(t);
}
privatevoidsneakyThrow2(Throwablet)throwsT{
throw(T)t;
}
}
这个程序在编译时将产生一条警告信息:
TigerThrower.java:7:warning:[unchecked]uncheckedcast
found:java.lang.Throwable,required:T
throw(T)t;
^
警告信息是编译器所采用的一种手段,用来告诉你:你可能正在搬起石头砸自己的脚,而且事实也正是如此。“不受检查的转型”警告告诉你这个有问题的转型将不会在运行时刻受到检查。当你获得了一个不受检查的转型警告时,你应该修改你的程序以消除它,或者你可以确信这个转型不会失败。如果你不这么做,那么某个其他的转型可能会在未来不确定的某个时刻失败,而你也就很难跟踪此错误到其源头了。对于本谜题所示的情况,其情况更糟糕:在运行期抛出的异常可能与方法的签名不一致。sneakyThrow2方法正是利用了这一点。
对平台设计者来说,有好几条教训。在设计诸如反射类库之类在语言之外实现的类库时,要保留语言所作的所有承诺。当从头设计一个支持通用类型的平台时,要考虑强制要求其在运行期的正确性。Java通用类型工具的设计者可没有这么做,因为他们受制于通用类库必须能够与现有客户进行互操作的要求。对于违反方法签名的异常,为了消除其产生的可能性,应该考虑强制在运行期进行异常检查。
总之,Java的异常检查机制并不是虚拟机强制执行的。它只是一个编译期工具,被设计用来帮助我们更加容易地编写正确的程序,但是在运行期可以绕过它。要想减少你因为这类问题而被曝光的次数,就不要忽视编译器给出的警告信息。
谜题44:切掉类
请考虑下面的两个类:
publicclassStrange1{
publicstaticvoidmain(String[]args){
try{
Missingm=newMissing();
}catch(java.lang.NoClassDefFoundErrorex){
System.out.println("Gotit!");
}
}
}
publicclassStrange2{
publicstaticvoidmain(String[]args){
Missingm;
try{
m=newMissing();
}catch(java.lang.NoClassDefFoundErrorex){
System.out.println("Gotit!");
}
}
}
Strange1和Strange2都用到了下面这个类:
classMissing{
Missing(){}
}
如果你编译所有这三个类,然后在运行Strange1和Strange2之前删除Missing.class文件,你就会发现这两个程序的行为有所不同。其中一个抛出了一个未被捕获的NoClassDefFoundError异常,而另一个却打印出了Gotit!到底哪一个程序具有哪一种行为,你又如何去解释这种行为上的差异呢?
程序Strange1只在其try语句块中提及Missing类型,因此你可能会认为它捕获NoClassDefFoundError异常,并打印Gotit!另一方面,程序Strange2在try语句块之外声明了一个Missing类型的变量,因此你可能会认为所产生的NoClassDefFoundError异常不会被捕获。如果你试着运行这些程序,就会看到它们的行为正好相反:Strange1抛出了未被捕获的NoClassDefFoundError异常,而Strange2却打印出了Gotit!怎样才能解释这些奇怪的行为呢?
如果你去查看Java规范以找出应该抛出NoClassDefFoundError异常的地方,那么你不会得到很多的指导信息。该规范描述道,这个错误可以“在(直接或间接)使用某个类的程序中的任何地方”抛出[JLS12.2.1]。当VM调用Strange1和Strange2的main方法时,这些程序都间接使用了Missing类,因此,它们都在其权利范围内于这一点上抛出了该错误。
于是,本谜题的答案就是这两个程序可以依据其实现而展示出各自不同的行为。但是这并不能解释为什么这些程序在所有我们所知的Java实现上的实际行为,与你所认为的必然行为都正好相反。要查明为什么会是这样,我们需要研究一下由编译器生成的这些程序的字节码。
如果你去比较Strange1和Strange2的字节码,就会发现几乎是一样的。除了类名之外,唯一的差异就是catch语句块所捕获的参数ex与VM本地变量之间的映射关系不同。尽管哪一个程序变量被指派给了哪一个VM变量的具体细节会因编译器的不同而有所差异,但是对于和上述程序一样简单的程序来说,这些细节不太可能会差异很大。下面是通过执行javap-cStrange1命令而显示的Strange1.main的字节码:
0:new
3:dup
4:invokespecial#3;//MethodMissing."<init>":()V
7:astore_1
8:goto20
11:astore_1
12:getstatic#5;//FieldSystem.out:Ljava/io/PrintStream;
15:ldc#6;//String"Gotit!"
17:invokevirtual#7;//MethodPrintStream.println:(String);V
20:return
Exceptiontable:
fromtotargettype
0811Classjava/lang/NoClassDefFoundError
Strange2.main相对应的字节码与其只有一条指令不同:
11:astore_2
这是一条将catch语句块中的捕获异常存储到捕获参数ex中的指令。在Strange1中,这个参数是存储在VM变量1中的,而在Strange2中,它是存储在VM变量2中的。这就是两个类之间唯一的差异,但是它所造成的程序行为上的差异是多么地大呀!
为了运行一个程序,VM要加载和初始化包含main方法的类。在加载和初始化之间,VM必须链接(link)类[JLS12.3]。链接的第一阶段是校验,校验要确保一个类是良构的,并且遵循语言的语法要求。校验非常关键,它维护着可以将像Java这样的安全语言与像C或C++这样的不安全语言区分开的各种承诺。
在Strange1和Strange2这两个类中,本地变量m碰巧都被存储在VM变量1中。两个版本的main都有一个连接点,从两个不同位置而来的控制流汇聚于此。该连接点就是指令20,即从main返回的指令。在正常结束try语句块的情况下,我们执行到指令8,即goto20,从而可以到达指令20;而对于在catch语句块中结束的情况,我们将执行指令17,并按顺序执行下去,到达指令20。
连接点的存在使得在校验Strange1类时产生异常,而在校验Strange2类时并不会产生异常。当校验去执行对Strange1.main的流分析(flowanalysis)[JLS12.3.1]时,由于指令20可以通过两条不同的路径到达,因此校验器必须合并在变量1中的类型。两种类型是通过计算它们的首个公共超类(firstcommonsuperclass)[JVMS4.9.2]而合并的。两个类的首个公共超类是它们所共有的最详细而精确的超类。
在Strange1.main方法中,当从指令8到达指令20时,VM变量1的状态包含了一个Missing类的实例。当从指令17到达时,它包含了一个NoClassDefFoundError类的实例。为了计算首个公共超类,校验器必须加载Missing类以确定其超类。因为Missing.class文件已经被删除了,所以校验器不能加载它,因而抛出了一个NoClassDefFoundError异常。请注意,这个异常是在校验期间、在类被初始化之前,并且在main方法开始执行之前很早就抛出的。这就解释了为什么没有打印出任何关于这个未被捕获异常的跟踪栈信息。
要想编写一个能够探测出某个类是否丢失的程序,请使用反射来引用类而不要使用通常的语言结构[EJItem35]。
下面展示了用这种技巧重写的程序:
publicclassStrange{
publicstaticvoidmain(String[]args)throws
Exception{
try{
Objectm=Class.forName("Missing").
newInstance();
}catch(ClassNotFoundExceptionex){
System.err.println("Gotit!");
}
}
}
总之,不要对捕获NoClassDefFoundError形成依赖。语言规范非常仔细地描述了类初始化是在何时发生的[JLS12.4.1],但是类被加载的时机却显得更加不可预测。更一般地讲,捕获Error及其子类型几乎是完全不恰当的。这些异常是为那些不能被恢复的错误而保留的。
谜题45:令人疲惫不堪的测验
本谜题将测试你对递归的了解程度。下面的程序将做些什么呢?
publicclassWorkout{
publicstaticvoidmain(String[]args){
workHard();
System.out.println("It'snaptime.");
}
privatestaticvoidworkHard(){
try{
workHard();
}finally{
workHard();
}
}
}
要不是有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程序的执行过程等于是在先序遍历这棵树。在先序遍历中,程序先访问一个节点,然后递归地访问它的左子树和右子树。对于树中的每一条边,都会产生一个调用,而对于树中的每一个节点,都会抛出一个异常。
本谜题没有很多关于教训方面的东西。它证明了指数算法对于除了最小输入之外的所有情况都是不可行的,它还表明了你甚至可以不费什么劲就可以编写出一个指数算法。