Java谜题8——更多的库谜题谜题76:乒乓 | 谜题77:搞乱锁的妖怪 | 谜题78:反射的污染 | 谜题79:这是狗的生活 | 谜题80:更深层的反射 | 谜题81:烧焦(字符化)到无法识别 | 谜题82:啤酒爆炸 | 谜题83:诵读困难者的一神论 | 谜题84:被粗暴地中断 | 谜题85:惰性初始化 谜题76:乒乓下面的程序全部是由同步化(synchronized)的静态方法组成的。那么它会打印出什么呢?在你每次运行这段程序的时候,它都能保证会打印出相同的内容吗? public class PingPong{ public static synchronized void main(String[] a){ Thread t = new Thread(){ public void run(){ pong(); } }; t.run(); System.out.print( "Ping" ); } static synchronized void pong(){ System.out.print( "Pong" ); } } 在多线程程序中,通常正确的观点是程序每次运行的结果都有可能发生变化,但是上面这段程序总是打印出相同的内容。在一个同步化的静态方法执行之前,它会获取与它的Class对象相关联的一个管程(monitor)锁[JLS 8.4.3.6]。所以在上面的程序中,主线程会在创建第二个线程之前获得与PingPong.class相关联的那个锁。只要主线程占有着这个锁,第二个线程就不可能执行同步化的静态方法。具体地讲,在main方法打印了Ping并且执行结束之后,第二个线程才能执行pong方法。只有当主线程放弃那个锁的时候,第二个线程才被允许获得这个锁并且打印Pong 。根据以上的分析,我们似乎可以确信这个程序应该总是打印PingPong。但是这里有一个小问题:当你尝试着运行这个程序的时候,你会发现它总是会打印PongPing。到底发生了什么呢? 正如它看起来的那样奇怪,这段程序并不是一个多线程程序。不是一个多线程程序?怎么可能呢?它肯定会生成第二个线程啊。喔,对的,它确实是创建了第二个线程,但是它从未启动这个线程。相反地,主线程会调用那个新的线程实例的run方法,这个run方法会在主线程中同步地运行。由于一个线程可以重复地获得某个相同的锁 [JLS 17.1] ,所以当run方法调用pong方法的时候,主线程就被允许再次获得与PingPong.class相关联的锁。pong方法打印了Pong 并且返回到了run方法,而run方法又返回到main方法。最后,main方法打印了Ping,这就解释了我们看到的输出结果是怎么来的。 要订正这个程序很简单,只需将 t.run 改写成 t.start。这么做之后,这个程序就会如你所愿的总是打印出 PingPong 了。 这个教训很简单:当你想调用一个线程的start方法时要多加小心,别弄错成调用这个线程的run方法了。遗憾的是,这个错误实在是太普遍了,而且它可能很难被发现。或许这个谜题的教训应该是针对API的设计者的:如果一个线程没有一个公共的run方法,那么程序员就不可能意外地调用到它。Thread 类之所以有一个公共的run方法,是因为它实现了 Runnable 接口,但是这种方式并不是必须的。另外一种可选的设计方案是:使用组合(composition)来替代接口继承(interface inheritance),让每个Thread实例都封装一个Runnable。正如谜题47中所讨论的,组合通常比继承更可取。这个谜题说明了上述的原则甚至对于接口继承也是适用的。 谜题77:搞乱锁的妖怪下面的这段程序模拟了一个小车间。程序首先启动了一个工人线程,该线程在停止时间到来之前会一直工作(至少是假装在工作),然后程序安排了一个定时器任务(timer task)用来模拟一个恶毒的老板,他会试图阻止停止时间的到来。最后,主线程作为一个善良的老板会告诉工人停止时间到了,并且等待工人停止工作。那么这个程序会打印什么呢? import java.util.*; public class Worker extends Thread { private volatile boolean quittingTime = false; public void run() { while (!quittingTime) pretendToWork(); System.out.println("Beer is good"); } private void pretendToWork() { try { Thread.sleep(300); // Sleeping on the job? } catch (InterruptedException ex) { } } // It's quitting time, wait for worker - Called by good boss synchronized void quit() throws InterruptedException { quittingTime = true; join(); } // Rescind quitting time - Called by evil boss synchronized void keepWorking() { quittingTime = false; } public static void main(String[] args) throws InterruptedException { final Worker worker = new Worker(); worker.start(); Timer t = new Timer(true); // Daemon thread t.schedule(new TimerTask() { public void run() { worker.keepWorking(); } }, 500); Thread.sleep(400); worker.quit(); } } 想要探究这个程序到底做了什么的最好方法就是手动地模拟一下它的执行过程。下面是一个近似的时间轴,这些时间点的数值是相对于程序的开始时刻进行计算的: 300 ms:工人线程去检查易变的quittingTime 域,看看停止时间是否已经到了。这个时候并没有到停止时间,所以工人线程会回去继续“工作”。 400ms:作为善良的老板的主线程会去调用工人线程的quit方法。主线程会获得工人线程实例上的锁(因为quit是一个同步化的方法),将quittingTime的值设为true,并且调用工人线程上的join方法。这个对join方法的调用并不会马上返回,而是会等待工人线程执行完毕。 500m:作为恶毒的老板定时器任务开始执行。它将试图调用工人线程的keepWorking方法,但是这个调用将会被阻塞,因为keepWorking是一个同步化的方法,而主线程当时正在执行工人线程上的另一个同步化方法(quit方法)。 600ms:工人线程会再次检查停止时间是否已经到来。由于quittingTime域是易变的,那么工人线程肯定会看到新的值true,所以它会打印 Beer is good 并结束运行。这会让主线程对join方法的调用执行返回,随后主线程也结束了运行。而定时器线程是后台的,所以它也会随之结束运行,整个程序也就结束了。 所以,我们会认为程序将运行不到1秒钟,打印 Beer is good ,然后正常的结束。但是当你尝试运行这个程序的时候,你会发现它没有打印任何东西,而是一直处于挂起状态(没有结束)。我们的分析哪里出错了呢? 其实,并没有什么可以保证上述几个交叉的事件会按照上面的时间轴发生。无论是Timer类还是Thread.sleep方法,都不能保证具有实时(real-time)性。这就是说,由于这里计时的粒度太粗,所以上述几个事件很有可能会在时间轴上互有重叠地交替发生。100毫秒对于计算机来说是一段很长的时间。此外,这个程序被重复地挂起;看起来好像有什么其他的东西在工作着,事实上,确实是有这种东西。 我们的分析存在着一个基本的错误。在500ms时,当作为恶毒老板的定时器任务运行时,根据时间轴的显示,它对keepWorking方法的调用会被阻塞,因为keepWorking是一个同步化的方法并且主线程正在同一个对象上执行着同步化方法quit(在Thread.join中等待着)。这些都是对的,keepWorking确实是一个同步化的方法,并且主线程确实正在同一个对象上执行着同步化的quit方法。即使如此,定时器线程仍然可以获得这个对象上的锁,并且执行keepWorking方法。这是如何发生的呢? 问题的答案涉及到了Thread.join的实现。这部分内容在关于该方法的文档中(JDK文档)是找不到的,至少在迄今为止发布的文档中如此,也包括5.0版。在内部,Thread.join方法在表示正在被连接(join)的那个Thread实例上调用Object.wait方法。这样就在等待期间释放了该对象上的锁。在我们的程序中,这就使得作为恶毒老板的定时器线程能够堂而皇之的将quittingTime重新设置成false,尽管此时主线程正在执行同步化的quit方法。这样的结果是,工人线程永远不会看到停止时间的到来,它会永远运行下去。作为善良的老板的主线程也就永远不会从join方法中返回了。 使这个程序产生了预料之外的行为的根本原因就是WorkerThread类的作者使用了实例上的锁来确保quit方法和keepWorking方法的互斥,但是这种用法与超类(Thread)内部对该锁的用法发生了冲突。这里的教训是:除非有关于某个类的详细说明作为保证,否则千万不要假设库中的这个类对它的实例或类上的锁会做(或者不会做)某些事情。对于库的任何调用都可能会产生对wait、notify、notifyAll方法或者某个同步化方法的调用。所有这些,都可能对应用级的代码产生影响。 如果你需要获得某个锁的完全控制权,那么就要确定没有任何其他人能够访问到它。如果你的类扩展了库中的某个类,而这个库中的类可能使用了它的锁,或者如果某些不可信的人可能会获得对你的类的实例的访问权,那么请不要使用与这个类或它的实例自动关联的那些锁。取而代之的,你应该在一个私有的域中创建一个单独的锁对象。在5.0版本发布之前,用于这种锁对象的正确类型只有Object或者它的某个普通的子类。从5.0版本开始,java.util.concurrent.locks提供了2种可选方案:ReentrantLock和ReentrantReadWriteLock。相对于Object类,这2个类提供了更好的机动性,但是它们使用起来也要更麻烦一点。它们不能被用在同步化的语句块(synchronized block)中,而且必须辅以try-finally语句对其进行显式的获取和释放。 订正这个程序最直接的方法是添加一个Object类型的私有域作为锁,并且在quit和keepWorking方法中对这个锁对象进行同步。通过上述修改之后,该程序就会打印出我们所期望的Beer is good。可以看出,该程序能够产生正确行为并不依赖于它必须遵从我们前面分析的时间轴: private final Object lock = new Object(); // It's quitting time, wait for worker - Called by good boss void quit() throws InterruptedException{ synchronized (lock){ quittingTime = true; join(); } } // Rescind quitting time - Called by evil boss void keepWorking(){ synchronized(lock){ quittingTime = false; } } 另外一种可以修复这个程序的方法是让Worker类实现Runnable而不是扩展Thread,然后在创建每个工人线程的时候都使用Thread(Runnable)构造器。这样可以将每个Worker实例上的锁与其线程上的锁进行解耦。这是一个规模稍大一些的重构。 正如库类对锁的使用会干扰应用程序一样,应用程序中对锁的使用也会干扰库类。例如,在迄今为止发布的所有版本的JDK(包括5.0版本)中,为了创建一个新的Thread实例,系统都会去获取Thread类上的锁。而执行下面的代码就可以阻止任何新线程的创建: synchronized(Thread.class){ Thread.sleep(Long.MAX_VALUE); } 总之,永远不要假设库类会(或者不会)对它的锁做某些事情。为了隔离你自己的程序与库类对锁的使用,除了那些专门设计用来被继承的库类之外,请避免继承其它库类 [EJ Item 15]。为了确保你的锁不会遭受外部的干扰,可以将它们设为私有以阻止其他人对它们的访问。 对于语言设计者来说,需要考虑的是为每个对象都关联一个锁是否是合适的。如果你决定这么做了,就需要考虑限制对这些锁的访问。在Java中,锁实际上是对象的公共属性,或许它们变为私有的会更有意义。同时请记住在Java语言中,一个对象实际上就是一个锁:你在对象本身之上进行同步。如果每个对象都有一个锁,而且你可以通过调用一个访问器方法来获得它,这样或许会更有意义。 谜题78:反射的污染这个谜题举例说明了一个关于反射的简单应用。这个程序会打印出什么呢? import java.util.*; import java.lang.reflect.*; public class Reflector { public static void main(String[] args) throws Exception { Set s = new HashSet(); s.add("foo"); Iterator it = s.iterator(); Method m = it.getClass().getMethod("hasNext"); System.out.println(m.invoke(it)); } } 这个程序首先创建了一个只包含单个元素的集合(set),获得了该集合上的迭代器,然后利用反射调用了迭代器的hasNext方法,最后打印出此该方法调用的结果。由于该迭代器尚未返回该集合中那个唯一的元素,hasNext方法应该返回true。然而,运行这个程序却得到了截然不同的结果: Exception in thread "main" java.lang.IllegalAccessException: Class Reflector can not access a member of class HashMap$HashIterator with modifiers "public" at Reflection.ensureMemberAccess(Reflection.java:65) at Method.invoke(Method.java:578) at Reflector.main(Reflector.java:11) 这是怎么发生的呢?正如这个异常所显示的,hasNext方法当然是公共的,所以它在任何地方都是可以被访问的。那么为什么这个基于反射的方法调用是非法的呢?这里的问题并不在于该方法的访问级别(access level),而在于该方法所在的类型的访问级别。这个类型所扮演的角色和一个普通方法调用中的限定类型(qualifying type)是相同的[JLS 13.1]。在这个程序中,该方法是从某个类中选择出来的,而这个类型是由从it.getClass方法返回的Class对象表示的。这是迭代器的动态类型(dynamic type),它恰好是私有的嵌套类(nested class) java.util.HashMap.KeyIterator。出现 IllegalAccessException 异常的原因就是这个类不是公共的,它来自另外一个包:访问位于其他包中的非公共类型的成员是不合法的[JLS 6.6.1]。无论是一般的访问还是通过反射的访问,上述的禁律都是有效的。下面这段没有使用反射的程序也违反了这条规则。 package library; public class Api{ static class PackagePrivate{} public static PackagePrivate member = new PackagePrivate(); } package client; import library.Api; class Client{ public static void main(String[] args){ System.out.println(Api.member.hashCode()); } } 尝试编译这段程序会得到如下的错误: Client.java:5: Object.hashCode() isn't defined in a public class or interface; can't be accessed from outside package System.out.println(Api.member.hashCode()); ^ 这个错误与前面那个由含有反射的程序所产生的运行期错误具有相同的意义。Object类型和hashCode方法都是公共的。问题在于hashCode方法是通过一个限定类型调用的,但用户访问不到这个类型。该方法调用的限定类型是library.Api.PackagePrivate,这是一个位于其他包的非公共类型。 这并不意味着Client就不能调用Api.member的hashCode方法。要做到这一点,只需要使用一个可访问的限定类型即可,在这里可以将Api.member转型成Object。经过这样的修改之后,Client类就可以顺利地编译和运行了: System.out.println(((Object)Api.member).hashCode()); 实际上,这个问题并不会在普通的非反射的访问中出现,因为API的编写者在他们的公共API中只会使用公共的类型。即使这个问题有可能发生,它也会以编译期错误的形式显现出来,所以比较容易修改。而使用反射的访问就不同了,object.getClass().getMethod(“methodName”) 这种惯用法虽然很常见,但是却有问题的,它不应该被使用。就像我们在前面的程序中看到的那样,这种用法很容易在运行期产生一个 IllegalAccessException。 在使用反射访问某个类型时,请使用表示某种可访问类型的Class对象。回到我们前面的那个程序,hasNext方法是声明在一个公共类型 java.util.Iterator 中的,所以它的类对象应该被用来进行反射访问。经过这样的修改后,这个Reflector程序就会打印出true: Method m = Iterator.class.getMethod("hasNext"); 你完全可以避免这一类的问题,你应该只有在实例化时才使用反射,而方法调用都通过使用接口进行[EJ Item 35]。这种使用反射的用法,可以将那些调用方法的类与那些实现这些方法的类隔离开,并且提供了更高程度的类型安全。这种用法在“服务提供者框架”(Service Provider Frameworks)中很常见。这种模式并不能解决反射访问中的所有问题,但是如果它可以解决你所遇到的问题,请务必使用它。 总之,访问其他包中的非公共类型的成员是不合法的,即使这个成员同时也被声明为某个公共类型的公共成员也是如此。不论这个成员是否是通过反射被访问的,上述规则都是成立的。这个问题很有可能只在反射访问中才会出现。对于平台的设计者来说,这里的教训与谜题67中的一样,应该让错误症状尽可能清晰地显示出来。对于运行期的异常和编译期的提示都还有些东西需要改进。 谜题79:这是狗的生活下面的这个类模拟了一个家庭宠物的生活。main方法创建了一个Pet实例,用它来表示一只名叫Fido的狗,然后让它运行。虽然绝大部分的狗都在后院里奔跑(run),这只狗却是在后台运行(run)。那么,这个程序会打印出什么呢? public class Pet{ public final String name; public final String food; public final String sound; public Pet(String name, String food, String sound){ this.name = name; this.food = food; this.sound = sound; } public void eat(){ System.out.println(name + ": Mmmmm, " + food ); } public void play(){ System.out.println(name + ": " + sound + " " + sound); } public void sleep(){ System.out.println(name + ": Zzzzzzz..."); } public void live(){ new Thread(){ public void run(){ while(true){ eat(); play(); sleep(); } } }.start(); } public static void main(String[] args){ new Pet("Fido", "beef", "Woof").live(); } } main方法创建了一个用来表示Fido的Pet实例,并且调用了它的live方法。然后,live方法创建并且启动了一个线程,该线程反复的调用其外围(enclosing)的Pet实例的eat、play和sleep方法,就这么一直进行下去。这些方法都会打印单独的一行,所以你会想到这个程序会反复的打印以下的3行: Fido: Mmmmm, beef Fido: Woof Woof Fido: Zzzzzzz… 但是如果你尝试运行这个程序,你会发现它甚至不能通过编译。而产生的编译错误信息没有什么用处: Pet.java:28: cannot find symbol symbol: method sleep() sleep(); ^ 为什么编译器找不到那个符号呢?这个符号确实是白纸黑字地写在那里。与谜题74一样,这个问题的源自重载解析过程的细节。编译器会在包含有正确名称的方法的最内层范围内查找需要调用的方法[JLS 15.12.1]。在我们的程序中,对于对sleep方法的调用,这个最内层的范围就是包含有该调用的匿名类(anonymous class),这个类继承了Thread.sleep(long)方法和Thread.sleep(long,int)方法,它们是该范围内唯一的名称为sleep的方法,但是由于它们都带有参数,所以都不适用于这里的调用。由于该方法调用的2个候选方法都不适用,所以编译器就打印出了错误信息。 从Thread那里继承到匿名类中的2个sleep 方法遮蔽(shadow)[JLS 6.3.1]了我们想要调用的sleep方法。正如你在谜题71和谜题73中所看到的那样,你应该避免遮蔽。在这个谜题中的遮蔽是间接地无意识地发生的,这使得它更加“阴险”。 订正这个程序的一个比较显而易见的方法,就是把Pet中的sleep方法的名字改成snooze, doze或者nap。订正该程序的另一个方法,是在方法调用的时候使用受限的(qualified) this结构来显式地为该类命名。此时的调用就变成了 Pet.this.sleep() 。 订正该程序的第三个方法,也是可以被证明是最好的方法,就是采纳谜题77的建议,使用Thread(Runnable)构造器来替代对Thread的继承。如果你这么做了,原有的问题将会消失,因为那个匿名类不会再继承Thread.sleep方法。 程序经过少许的修改,就可以产生我们想要的输出了,当然这里的输出可能有点无聊: public void live(){ new Thread(new Runnable(){ public void run(){ while(true){ eat(); play(); sleep(); } } }).start(); } 总之,要小心无意间产生的遮蔽,并且要学会识别表明存在这种情况的编译器错误信息。对于编译器的编写者来说,你应该尽力去产生那些对程序员来说有意义的错误消息。例如在我们的程序中,编译器应该可以警告程序员,存在着适用于方法调用但却被遮蔽掉的方法。 谜题80:更深层的反射下面这个程序通过打印一个由反射创建的对象来产生输出。那么它会打印出什么呢? public class Outer{ public static void main(String[] args) throws Exception{ new Outer().greetWorld(); } private void greetWorld()throws Exception { System.out.println( Inner.class.newInstance() ); } public class Inner{ public String toString(){ return "Hello world"; } } } 这个程序看起来是最普通的Hello World程序的又一个特殊的变体。Outer中的main方法创建了一个Outer实例,并且调用了它的greetWorld方法,该方法以字符串形式打印了通过反射创建的一个新的Inner实例。Inner的toString方法总是返回标准的问候语,所以程序的输出应该与往常一样,是Hello World。如果你尝试运行这个程序,你会发现实际的输出比较长,而且更加令人迷惑: Exception in thread "main" InstantiationException: Outer$Inner at java.lang.Class.newInstance0(Class.java:335) at java.lang.Class.newInstance(Class.java:303) at Outer.greetWorld(Outer.java:7) at Outer.main(Outer.java:3) 为什么会抛出这个异常呢?从5.0版本开始,关于Class.newInstance的文档叙述道:如果那个Class对象“代表了一个抽象类(abstract class),一个接口(interface),一个数组类(array class),一个原始类型(primitive type),或者是空(void);或者这个类没有任何空的[也就是无参数的]构造器;或者实例化由于某些其他原因而失败,那么它就会抛出异常”[JAVA-API]。这里出现的问题满足上面的哪些条件呢?遗憾的是,异常信息没有提供任何提示。在这些条件中,只有后2个有可能会满足:要么是Outer.Inner没有空的构造器,要么是实例化由于“某些其它原因”而失败了。正如Outer.Inner这种情况,当一个类没有任何显式的构造器时,Java会自动地提供一个不带参数的公共的缺省构造器[JLS 8.8.9],所以它应该是有一个空构造器的。不过,newInstance方法调用失败的原因还是因为Outer.Inner没有空构造器! 一个非静态的嵌套类的构造器,在编译的时候会将一个隐藏的参数作为它的第一个参数,这个参数表示了它的直接外围实例(immediately enclosing instance)[JLS 13.1]。当你在代码中任何可以让编译器找到合适的外围实例的地方去调用构造器的时候,这个参数就会被隐式地传递进去。但是,上述的过程只适用于普通的构造器调用,也就是不使用反射的情况。当你使用反射调用构造器时,这个隐藏的参数就需要被显式地传递,这对于Class.newInstance方法是不可能做到的。要传递这个隐藏参数的唯一办法就是使用java.lang.reflect.Constructor。当对程序进行了这样的修改后,它就可以正常的打印出 Hello World了: private void greetWorld() throws Exception{ Constructor c = Inner.class.getConstructor(Outer.class); System.out.println(c.newInstance(Outer.this)); } 作为其他的选择,你可能观察到了,Inner实例并不需要一个外围的Outer实例,所以可以将Inner类型声明为静态的(static)。除非你确实是需要一个外围实例,否则你应该优先使用静态成员类(static member class)而不是非静态成员类[EJ Item 18]。下面这个简单的修改就可以订正这个程序: public static class Inner{...} Java程序的反射模型和它的语言模型是不同的。反射操作处于虚拟机层次,暴露了很多从Java程序到class文件的翻译细节。这些细节当中的一部分由Java的语言规范来管理,但是其余的部分可能会随着不同的具体实现而有所不同。在Java语言的早期版本中,从Java程序到class文件的映射是很直接的,但是随着一些不能被虚拟机直接支持的高级语言特性的加入,如嵌套类(nested class)、协变返回类型(covariant return types)、泛型(generics)和枚举类型(enums),使得这种映射变得越来越复杂了。 考虑到从Java程序到class文件的映射的复杂度,请避免使用反射来实例化内部类。更一般地讲,当我们在用高级语言特性定义的程序元素之上使用反射的时候,一定要小心,从反射的视角观察程序可能不同与从代码的视角去观察它。请避免依赖那些没有被语言规范所管理的翻译细节。对于平台的实现者来说,这里的教训就是要再次重申,请提供清晰准确的诊断信息。 谜题81:烧焦到无法识别下面这个程序看起来是在用一种特殊的方法做一件普通的事。那么,它会打印出什么呢? public class Greeter{ public static void main(String[] args){ String greeting = "Hello World"; for(int i = 0; i < greeting.length(); i++) System.out.write(greeting.charAt(i)); } } 尽管这个程序有点奇怪,但是我们没有理由怀疑它会产生不正确的行为。它将“Hello World”写入了System.out,每次写一个字符。你可能会意识到write方法只会使用其输入参数的低位字节(lower-order byte)。所以当“Hello World”含有任何外来字符的时候,可能会造成一些麻烦,但这里不会:因为“Hello World”完全是由ASCII字符组成的。无论你是每次打印一个字符,还是一次全部打印,结果都应该是一样的:这个程序应该打印Hello World。然而,如果你运行该程序,就会发现它不会打印任何东西。那句问候语到哪里去了?难道是程序认为它并不令人愉快? 这里的问题在于System.out是带有缓冲的。Hello World中的字符被写入了System.out的缓冲区,但是缓冲区从来都没有被刷新(flush)。大多数的程序员认为,当有输出产生的时候System.out和System.err会自动地进行刷新,这并不完全正确。这2个流都属于PrintStream类型,在5.0版[Java-API]中,有关这个类型的文档叙述道: 一个PrintStream可以被创建为自动刷新的;这意味着当一个字节数组(byte array)被写入,或者某个println方法被调用,或者一个换行字符或字节(‘/n’)被写入之后,PrintStream类型的flush方法就会被自动地调用。 System.out和System.err所引用的流确实是PrintStream的能够自动刷新的变体,但是上面的文档中并没有提及write(int)方法。有关write(int)方法的文档叙述道:将指定的byte写入流。如果这个byte是一个换行字符,并且流可以自动刷新,那么flush方法将被调用[Java-API]。实际上,write(int)是唯一一个在自动刷新(automatic flushing)功能开启的情况下不刷新PrintStream的输出方法(output method)。 令人好奇的是,如果这个程序改用print(char)去替代write(int),它就会刷新System.out并打印出Hello World。这种行为与print(char)的文档是矛盾的,因为其文档叙述道[Java-API]: 打印一个字符:这个字符将根据平台缺省的字符编码方式被翻译成为一个或多个字节,并且这些字节将完全按照write(int)方法的方式被写出。 类似地,如果程序改用print(String),它也会对流进行刷新,虽然文档中是禁止这么做的。相应的文档确实应该被修改为描述该方法的实际行为,而修改方法的行为则会破坏稳定性。 修改这个程序最简单的方法就是在循环之后加上一个对System.out.flush方法的调用。经过这样的修改之后,程序就会正常地打印出Hello World。当然,更好的办法是重写这个程序,使用我们更熟悉的System.out.println方法在控制台上产生输出。 这个谜题的教训与谜题23一样:尽可能使用熟悉的惯用法;如果你不得不使用陌生的API,请一定要参考相关的文档。这里有3条教训给API的设计者们:请让你们的方法的行为能够清晰的反映在方法名上;请清楚而详细地给出这些行为的文档;请正确地实现这些行为。 谜题82:啤酒爆炸这一章的许多谜题都涉及到了多线程,而这个谜题涉及到了多进程。如果你用一行命令行带上参数slave去运行这个程序,它会打印什么呢?如果你使用的命令行不带任何参数,它又会打印什么呢? public class BeerBlast{ static final String COMMAND = "java BeerBlast slave"; public static void main(String[] args) throws Exception{ if(args.length == 1 && args[0].equals("slave")) { for(int i = 99; i > 0; i--){ System.out.println( i + " bottles of beer on the wall" ); System.out.println(i + " bottles of beer"); System.out.println( "You take on down, pass it around,"); System.out.println( (i-1) + " bottles of beer on the wall"); System.out.println(); } }else{ // Master Process process = Runtime.getRuntime().exec(COMMAND); int exitValue = process.waitFor(); System.out.println("exit value = " + exitValue); } } } 如果你使用参数slave来运行该程序,它就会打印出那首激动人心的名为”99 Bottles of Beer on the Wall”的童谣的歌词,这没有什么神秘的。如果你不使用该参数来运行这个程序,它会启动一个slave进程来打印这首歌谣,但是你看不到slave进程的输出。主进程会等待slave进程结束,然后打印出slave进程的退出值(exit value)。根据惯例,0值表示正常结束,所以0就是你可能期望该程序打印的东西。如果你运行了程序,你可能会发现该程序只会悬挂在那里,不会打印任何东西,看起来slave进程好像永远都在运行着。所以你可能会觉得你应该一直都能听到”99 Bottles of Beer on the Wall”这首童谣,即使是这首歌被唱走调了也是如此,但是这首歌只有99句,而且,电脑是很快的,你假设的情况应该是不存在的,那么这个程序出了什么问题呢? 这个秘密的线索可以在Process类的文档中找到,它叙述道:“由于某些本地平台只提供有限大小的缓冲,所以如果未能迅速地读取子进程(subprocess)的输出流,就有可能会导致子进程的阻塞,甚至是死锁” [Java-API]。这恰好就是这里所发生的事情:没有足够的缓冲空间来保存这首冗长的歌谣。为了确保slave进程能够结束,父进程必须排空(drain)它的输出流,而这个输出流从master线程的角度来看是输入流。下面的这个工具方法会在后台线程中完成这项工作: static void drainInBackground(final InputStream is) { new Thread(new Runnable(){ public void run(){ try{ while( is.read() >= 0 ); } catch(IOException e){ // return on IOException } } }).start(); } 如果我们修改原有的程序,在等待slave进程之前调用这个方法,程序就会打印出0: }else{ // Master Process process = Runtime.getRuntime().exec(COMMAND); drainInBackground(process.getInputStream()); int exitValue = process.waitFor(); System.out.println("exit value = " + exitValue); } 这里的教训是:为了确保子进程能够结束,你必须排空它的输出流;对于错误流(error stream)也是一样,而且它可能会更麻烦,因为你无法预测进程什么时候会倾倒(dump)一些输出到这个流中。在5.0版本中,加入了一个名为ProcessBuilder的类用于排空这些流。它的redirectErrorStream方法将各个流合并起来,所以你只需要排空这一个流。如果你决定不合并输出流和错误流,你必须并行地(concurrently)排空它们。试图顺序化地(sequentially)排空它们会导致子进程被挂起。 多年以来,很多程序员都被这个缺陷所刺痛。这里对于API设计者们的教训是,Process类应该避免这个错误,也许应该自动地排空输出流和错误流,除非用户表示要读取它们。更一般的讲,API应该设计得更容易做出正确的事,而很难或不可能做出错误的事。 谜题83:诵读困难者的一神论从前有一个人,他认为世上只有一只不寻常的狗,所以他写出了如下的类,将它作为一个单件(singleton)[Gamma95]: public class Dog extends Exception { public static final Dog INSTANCE = new Dog(); private Dog() {} public String toString(){ return "Woof"; } } 结果证明这个人的做法是错误的。你能够在这个类的外部不使用反射来创建出第2个Dog实例吗? 这个类可能看起来像一个单件,但它并不是。问题在于,Dog扩展了Exception,而Exception实现了java.io.Serializable。这就意味着Dog是可序列化的(serializable),并且解序列(deserialization)会创建一个隐藏的构造器。正如下面的这段程序所演示的,如果你序列化了Dog.INSTANCE,然后对得到的字节序列(byte sequence)进行解序列,最后你就会得到另外一个Dog。该程序打印的是false,表示新的Dog实例和原来的那个实例是不同的,并且它还打印了Woof,说明新的Dog实例也具有相应的功能: import java.io.*; public class CopyDog{ // Not to be confused with copycat public static void main(String[] args){ Dog newDog = (Dog) deepCopy(Dog.INSTANCE); System.out.println(newDog == Dog.INSTANCE); System.out.println(newDog); } // This method is very slow and generally a bad idea! static public Object deepCopy(Object obj){ try{ ByteArrayOutputStream bos = new ByteArrayOutputStream(); new ObjectOutputStream(bos).writeObject(obj); ByteArrayInputStream bin = new ByteArrayInputStream(bos.toByteArray()); return new ObjectInputStream(bin).readObject(); } catch(Exception e) { throw new IllegalArgumentException(e); } } } 要订正这个问题,可在Dog中添加一个readResolve方法,它可以将那个隐藏的构造器转变为一个隐藏的静态工厂(static factory),以返回原来那个的Dog [EJ Items 2,57]。在Dog中添加了这个方法之后,CopyDog将打印true而不是false,表示那个“复本”实际上就是原来的那个实例: private Object readResolve(){ // Accept no substitues! return INSTANCE; } 这个谜题的主要教训就是一个实现了Serializable的单件类,必须有一个readResolve方法,用以返回它的唯一的实例。一个次要的教训就是,有可能由于对一个实现了Serializable的类进行了扩展,或者由于实现了一个扩展自Serializable的接口,使得我们在无意中实现了Serializable。给平台设计者的教训是,隐藏的构造器,例如序列化中产生的那个,会让读者对程序行为的产生错觉。 谜题84:被粗暴地中断在下面这个程序中,一个线程试图中断自己,然后检查中断是否成功。它会打印什么呢? public class SelfInterruption { public static void main(String[ ] args) { Thread.currentThread().interrupt(); if(Thread.interrupted()) { System.out.println("Interrupted: " + Thread.interrupted()); } else{ System.out.println("Not interrupted: " + Thread.interrupted()); } } } 虽然一个线程中断自己不是很常见,但这也不是没有听说过的。当一个方法捕捉到了一个InterruptedException异常,而且没有做好处理这个异常的准备时,那么这个方法通常会将该异常重新抛出(rethrow)。但是由于这是一个“被检查的异常”,所以只有在方法声明允许的情况下该方法才能够将异常重新抛出。如果不能重新抛出,该方法可以通过中断当前线程对异常“再构建”(reraise)。这种方式工作得很好,所以这个程序中的线程中断自己应该是没有任何问题的。所以,该程序应该进入if语句的第一个分支,打印出 Interrupted: true。如果你运行该程序,你会发现并不是这样。但是它也没有打印 Not interrupted: false,它打印的是 Interrupted: false。 看起来该程序好像不能确定线程是否被中断了。当然,这种看法是毫无意义的。实际上发生的事情是,Thread.interrupted方法第一次被调用的时候返回了true,并且清除了线程的中断状态,所以在if-then-else语句的分支中第2次调用该方法的时候,返回的就是false。调用Thread.interrupted方法总是会清除当前线程的中断状态。方法的名称没有为这种行为提供任何线索,而对于5.0版本,在相应的文档中有一句话概要地也同样具有误导性地叙述道:“测试当前的线程是否中断”[Java-API]。所以,可以理解为什么很多程序员都没有意识到Thread.interrupted方法会对线程的中断状态造成影响。 Thread类有2个方法可以查询一个线程的中断状态。另外一个方法是一个名为isInterrupted的实例方法,而它不会清除线程的中断状态。如果使用这个方法重写程序,它就会打印出我们想要的结果true: public class SelfInterruption { public static void main(String[ ] args) { Thread.currentThread().interrupt(); if(Thread.currentThread().isInterrupted()) { System.out.println("Interrupted: " + Thread.currentThread().isInterrupted()); }else{ System.out.println("Not interrupted: " + Thread.currentThread().isInterrupted()); } } } 这个谜题的教训是:不要使用Thread.interrupted方法,除非你想要清除当前线程的中断状态。如果你只是想查询中断状态,请使用isInterrupted方法。这里给API设计者们的教训是方法的名称应该用来描述它们主要功能。根据Thread.interrupted方法的行为,它的名称应该是 clearInterruptStatus,因为相对于它对中断状态的改变,它的返回值是次要的。特别是当一个方法的名称并不完美的时候,文档是否能清楚地描述它的行为就显得非常重要了。 谜题85:惰性初始化下面这个可怜的小类实在是太懒了,甚至于都不愿意用通常的方法进行初始化,所以它求助于后台线程。这个程序会打印什么呢?每次你运行它的时候都会打印出相同的东西吗? public class Lazy { private static boolean initialized = false; static { Thread t = new Thread(new Runnable() { public void run() { initialized = true; } }); t.start(); try{ t.join(); }catch (InterruptedException e){ throw new AssertionError(e); } } public static void main(String[] args){ System.out.println(initialized); } } 虽然有点奇怪,但是这个程序看起来很直观的。静态域initialized初始时被设为false。然后主线程创建了一个后台线程,该线程的run方法将initialized的值设为true。主线程启动了后台线程之后,就调用了join方法等待它的结束。当后台线程完成运行的时候,毫无疑问initialized的值已经被设为了true。当且仅当这个时候,调用了main方法的主线程会打印出initialized的值。如果是这样的话,程序肯定会打印出true吗?如果你运行该程序,你会发现它不会打印任何东西,它只是被挂起了。 为了理解这个程序的行为,我们需要模拟它初始化的细节。当一个线程访问一个类的某个成员的时候,它会去检查这个类是否已经被初始化。在忽略严重错误的情况下,有4种可能的情况[JLS 12.4.2]: 这个类尚未被初始化。 这个类正在被当前线程初始化:这是对初始化的递归请求。 这个类正在被其他线程而不是当前线程初始化。 这个类已经被初始化。 当主线程调用Lazy.main方法时,它会检查Lazy类是否已经被初始化。此时它并没有被初始化(情况1),所以主线程会记录下当前正在进行初始化,并开始对这个类进行初始化。按照我们前面的分析,主线程会将initialized的值设为false,创建并启动一个后台线程,该线程的run方法会将initialized设为true,然后主线程会等待后台线程执行完毕。此时,有趣的事情开始了。 那个后台线程调用了它的run方法。在该线程将Lazy.initialized设为true之前,它也会去检查Lazy类是否已经被初始化。这个时候,这个类正在被另外一个线程进行初始化(情况3)。在这种情况下,当前线程,也就是那个后台线程,会等待Class对象直到初始化完成。遗憾的是,那个正在进行初始化工作的线程,也就是主线程,正在等待着后台线程运行结束。因为这2个线程现在正相互等待着,该程序就死锁了(deadlock)。这就是所有的一切,真是遗憾。有2种方法可以订正这个程序。到目前为止,最好的方法就是不要在类进行初始化的时候启动任何后台线程:有些时候,2个线程并不比1个线程好。更一般的讲,要让类的初始化尽可能地简单。订正这个程序的第2种方法就是让主线程在等待后台线程之前就完成类的初始化: // Bad way to eliminate the deadlock. Complex and error prone public class Lazy { private static boolean initialized = false; private static Thread t = new Thread(new Runnable() { public void run() { initialized = true; } }); static { t.start(); } public static void main(String[] args){ try{ t.join(); }catch (InterruptedException e){ throw new AssertionError(e); } System.out.println(initialized); } } 虽然这么做确实消除了死锁,但是它却是一个非常不好的想法。主线程需要等待后台线程完成工作,但是其他的线程不需要这么做。一旦主线程完成了对Lazy类的初始化,其他线程就可以使用这个类了。这使得在initialized的值还是false的时候,其他线程就可以观察到它。 总之,在类的初始化期间等待某个后台线程很可能会造成死锁。要让类初始化的动作序列尽可能地简单。类的自动初始化被公认为是语言设计上的难题,Java的设计者们在这个方面做得很不错。如果你写了一些复杂的类初始化代码,很多种情况下,你这是在搬起石头砸自己的脚。
本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!