Java解惑(四) puzzle 36--45
Chapter 5讲的是异常之谜,这些puzzle都是发生在使用异常的这些特性里面的,文中假设大家对java异常处理机制有所了解,当然也可以通过这里 获取一些关于异常机制的讨论。异常是为了定位程序错误和增强带代码的健壮性而出现的,java的异常机制也是建立在C++的基础之上的。由于使用率很高所以也经常引起一些使用心得讨论,比较认同的一点是不要将异常和程序控制结构混为一谈。这样很容易出现问题。这次的几个puzzle有几个很是具有难度,涉及到了JVM处理的一些东西,目前我表示也很无力。
public class Indecisive { public static void main(String[] args) { System.out.println(decision()); } static boolean decision() { try { return true; } finally { return false; } } }
这个最终打印的是true还是false呢?首先要明确,finally语句块一定会在try语句块执行结束之后执行(为什么会强调结束后呢),不管try是正常的结束还是意外的结束。意外的结束意味着try中可能调用了continue、break或者return这样的语句。但是try..finally结构的最终状态是和finally语句块保持一致的,也就是在这个例子中,try经过return后意外结束,结果也会被丢弃,而去执行finally语句,所以最后打印的是false。
这个给我们的启发是,不要在投try 语句中使用那些结构控制,也不要把try catch finally当做语句结构控制来使用。
import java.io.IOException; public class Arcane1 { public static void main(String[] args) { try { System.out.println("Hello world"); } catch(IOException e) { System.out.println("I've never seen println fail!"); } } }
第一个会打印什么呢?
好奇的你可以将代码拷贝一下,发现报错了。原因就是IOException是一个checked检查类型的异常,但是try语句中不可能抛出这个异常,所以编译器就会报错。解决的办法是,要么你把try catch去掉,要么写一个声明throw IOException的方法,在try语句块中调用。这就是万恶的checked类型异常,关于他为什么有时候不好用,可以参考这里
public class Arcane2 { public static void main(String[] args) { try { // If you have nothing nice to say, say nothing } catch(Exception e) { System.out.println("This can't happen"); } } }
有了上面的经验,这个应该也会报错吧.但其实不会报错,当捕获Exception或者Throwable或者unchecked类型的异常的时候,try语句块中是不需要抛出异常的,显然这段代码什么都不会打印。
interface Type1 { void f() throws CloneNotSupportedException; } interface Type2 { void f() throws InterruptedException; } interface Type3 extends Type1, Type2 { } public class Arcane3 implements Type3 { public void f() { System.out.println("Hello world"); } public static void main(String[] args) { Type3 t3 = new Arcane3(); t3.f(); } }
看过《Thinking in java》关于异常的讲解的童鞋,一定有印象关于java异常继承的限制.就是子类或者实现接口的类,一定不允许抛出基类或者接口中未声明的异常。这是为了使得基类的代码可以继续使用。
这个例子中,我们可能会想到f 需要声明异常或者对其进行处理,但实际上不需要,引用《java puzzlers》的原文是说“一个方法可以抛出的受检查类型的异常集合是它所适用的所有类型声明要抛出的受检查类型异常的交集”,显然本例中交集为空,所以不能抛出其他的异常。
public class UnwelcomeGuest { public static final long GUEST_USER_ID = -1; private static final long USER_ID; static { try { USER_ID = getUserIdFromEnvironment(); } catch (IdUnavailableException e) { USER_ID = GUEST_USER_ID; System.out.println("Logging in as guest"); } } private static long getUserIdFromEnvironment() throws IdUnavailableException { throw new IdUnavailableException(); // Simulate an error } public static void main(String[] args) { System.out.println("User ID: " + USER_ID); } } class IdUnavailableException extends Exception { }
这个是一个用于判断用户ID类型的陈程序,USER_ID是一个未初始化的final类型,但是程序中出现了两次赋值的地方,虽然看起来似乎是合乎逻辑的,java编译器对于final赋值采用了保守的策略,因为检测final是不是会多次赋值很困难,所以干脆在编译阶段就进行了限制。这保证了程序是安全的。Bloch推荐了一个重构代码的方法就是:添加一个返回USER_ID的方法,getUserIdOrGuest()这个方法内去处理异常,他最终只返回一个值用来赋给USER_ID.这是一种非常好的习惯,代码有更好的可读性。一个启发就是用一个方法去替换赋值处理逻辑,使得做事更加专注。
public class HelloGoodbye { public static void main(String[] args) { try { System.out.println("Hello world"); System.exit(0); } finally { System.out.println("Goodbye world"); } } }
你可能想起了puzzle 36了,没错,所以可能会认为打印的结果是Hello world 和Goddbye world,但是很不幸的是后者并不能打印出来。我们注意finally保证执行的前提是try语句的结束,不管是正常还是意外。但是本例中System.exit(0)迫使VM关闭了所有的线程,finally无法继续执行了。这里Bloch详细介绍了System.exit执行的动作,首先执行关闭HOOK的操作,这个的作用是释放VM之外的资源。我们可以通过addShutdownHook方法进行这些处理。
public class Reluctant { private Reluctant internalInstance = new Reluctant(); public Reluctant() throws Exception { throw new Exception("I'm not coming out"); } public static void main(String[] args) { try { Reluctant b = new Reluctant(); System.out.println("Surprise!"); } catch (Exception ex) { System.out.println("I told you so"); } } }
当我们运行上面的代码的时候发现会发生StackOverFlowError,当然这是一个error,catch无法捕获。当然这并不是程序的关键,关键之一是:这个程序会陷入一个无限的递归中去。由于java的初始化顺序,成员变量初始化要先于构造器执行,(关于java的初始化顺序可以看这里)。所以这就陷入了一个死循环了,当然导致了StackOverFlowError。关键之二是:构造器必须声明其初始化过程会抛出的所有checked类型的异常。
这个例子给我们的启发是,注意java初始化的顺序,成员变量初始化中如果发生异常否会抛给构造器。
import java.io.*; public class Copy { static void copy(String src, String dest) throws IOException { InputStream in = null; OutputStream out = null; try { in = new FileInputStream(src); out = new FileOutputStream(dest); byte[] buf = new byte[1024]; int n; while ((n = in.read(buf)) > 0) out.write(buf, 0, n); } finally { if (in != null) in.close(); if (out != null) out.close(); } } public static void main(String[] args) throws IOException { if (args.length != 2) System.out.println("Usage: java Copy <source> <dest>"); else copy(args[0], args[1]); } }
这个例子中,我们将输入流的数据复制到输出流,那么他会成功吗?
答案是不确定的,我们想程序可能已经很完备了,但是请注意finally中的in.close。这也是一个可以抛出异常的方法。所以这就是问题的所在。解决的方法最简单的是加try。catch但是这样的风格就不好了。可以利用Closeable接口来重构这部分代码。(FileInputStream和FileOutputStream都实现了这个借口),所以可以写一个closeStreamWhenException()在里面处理就好了。这样更加的优雅。
public class Loop { public static void main(String[] args) { int[][] tests = { { 6, 5, 4, 3, 2, 1 }, { 1, 2 }, { 1, 2, 3 }, { 1, 2, 3, 4 }, { 1 } }; int n = 0; try { int i = 0; while (true) { if (thirdElementIsThree(tests[i++])) n++; } } catch(ArrayIndexOutOfBoundsException e) { // No more tests to process } System.out.println(n); } private static boolean thirdElementIsThree(int[] a) { return a.length >= 3 & a[2] == 3; } }
这个会打印什么呢?我们想可能是2.但结果是0.
首先这是非常不好的代码,因为它试图用抛出异常来结束循环,坏处有两点:1是代码可读性很差,2是效率很低。那么为什么是0呢?看thirdElementIsThree方法,因为他在处理{1,2}的时候就已经抛出异常了。这个,反而有点经验的程序员会认为不会抛出异常,原因是a.length已经小于3了,不会导致另一表达式里数组越界,但注意这里用的是&而不是&&,我们知道&&操作符当左面有false的时候,他就停止计算后面的了。但是&则不同这个逻辑与操作符虽然在处理布尔的时候被重载了,但是仍旧要计算两个操作数,因而即使a<3,他也会去计算a[2],所以直接导致越界。
public class Thrower { public static void main(String[] args) { sneakyThrow(new Exception("This is a checked exception")); } /* * Provide a body for this method to make it throw the specified exception. * You must not use any deprecated methods. */ public static void sneakyThrow(Throwable t) { } }
这是要你实现一个可以绕过编译器抛出异常的方法,也就是说你可以随便抛出异常,而不被编译器检查。这个实在是蛋疼,利用了反射的一个缺陷。通过静态变量存储一个throwable,因为newInstance方法可以传递无参数构造器的抛出的异常。不想介绍这个,意义不大,完全是java设计上的问题。
class Missing { Missing() { } }
public class Strange1 { public static void main(String[] args) { try { Missing m = new Missing(); } catch (java.lang.NoClassDefFoundError ex) { System.out.println("Got it!"); } } }
public class Strange2 { public static void main(String[] args) { Missing m; try { m = new Missing(); } catch (java.lang.NoClassDefFoundError ex) { System.out.println("Got it!"); } } }
作者在本章的一开始就提到这个例子了,果然很难。这例子说的是我们正常编译上面三段代码,肯定没有问题,但是编译之后,我们将Missing.class删掉,运行会发生什么呢?
我们想可能是Strange1会捕获到NoClassDefFoundError,然后打印Got it,而Strange2会直接抛出NoClassDefFoundError。但是结果恰恰相反。这个怎么解释呢?下面是JVM生成的字节码:Strange1.main
0: new
3: dup
4: invokespecial #3; //Method Missing."<init>":()V
7: astore_1
8: goto 20
11: astore_1
12: getstatic #5; // Field System.out:Ljava/io/PrintStream;
15: ldc #6; // String "Got it!"
17: invokevirtual #7;//Method PrintStream.println: (String); V
20: return
Exception table:
from to target type
0 8 11 Class java/lang/NoClassDefFoundError
strange.main字节码和上面的唯一区别是 11:astore_2
这条指令的作用是将捕获的异常存到捕获参数EX的指令,strange1将它存在了VM变量1中,而strange2将它存在了VM变量2中。JVM加载main的时候要执行类似C的链接操作。两个main都有一个连接点,连接点是汇聚控制流的,上面main的连接点就是指令20.try如果正常结束,就会执行指令8,goto到20,catch语句块结束也要从指令17走到指令20.
问题就出在这里,现在有两条路径达到链接点,由于两条路径中各有一个astore(指令7和指令11),Strange1.main在路径1(try到return)中,用VM变量1存储了m,在路径2(catch到return)中又用VM变量1存储异常,因此要进行变量类型合并。所以要检测Missing和NoClassDefFoundError的超类,因为Missing.class已经被删除了,所以问题出现了,要抛出NoClassDefFoundError异常。因为此时还是在main执行之前发生的,所以当然无法捕获了。
这个问题的确还是略夸张了。
public class Workout { public static void main(String[] args) { workHard(); System.out.println("It's nap time."); } private static void workHard() { try { workHard(); } finally { workHard(); } } }
这个例子是一个近似的无限循环,我们发现try之后会不断的调用自己workHard,直到java的栈满,当要抛出异常的时候,finally就会被执行,继续调用自身。如何分析这个问题呢?首先假设java栈深为2(当然肯定不是这样的),可以用下图做解释:
其实每一个try finally就像二叉树的两个分支一样,对于孩子节点来讲,左孩子就是try右孩子就是finally,每一个叶子都是会产生stackoverflow异常的操作,这样计算一下,假设java栈深1024,根据二叉树的性质,总调用就有2^1025-1次。这样程序和无限循环也差不多了。
本章讲的是关于异常的谜题,其中有的是给我们写代码的风格上的启发,也有puzzle 44这样的大难题,觉得有必要下一步看下JVM相关的书籍了,还是有助于分析一些非常有难度的问题的。