Practical Java中文重点版


Practical Java Programming Language Guide Practical Java 中文版 [美]Peter Haggar 著 侯捷 刘永丹 译 出自第一线 Java 编程专家之手 68个改善代码的重要主题 所有示例和方案均有详尽解说 中国电力出版社 www.infopower.com.cn JavaJavaJavaJava 开 发 大 师 系 列 Addison Wesley 目录 Contents 实践:实际履行,尤指艺术、科学或技术领域;与理论遥相对应。 1111 一般技术 实践1111:参数以 bybybyby value value value value 方式而非 bybybyby reference reference reference reference 方式传递 一个普遍存在的误解是:java 中的参数以 by reference 方式传递。这不是真 的,参数其实是以 by value 方式传递。这个误解源于[所有 java objects都是 objects references]事实(关于 object references 的详细信息,请见实践 8)。 如果你未能准确理解其中奥妙,则可能导致一些料想不到的后果。举个例 子: 这段代码在//2 外建立了一个 point 对象并设初值为(0,0),接着将其值赋 予object reference 变量 p。然后对基本型别 int i赋予数值 10。//3 调用 static modifyPoint(),传入 p和i。modifyPoint()对第一个参数pt调用了 setLocation (),将其坐标改为(5,5)。然后将第二个参数 j赋值为15。当modifyPoint ()返回的时候,main()打印出 p和i的值。这段代码的输出为何?为什么? 程序输出如下: 这显示,modifyPoint()改变了//2 所建立的 Point 对象,却没有改变 int i。 在main()之中,i被赋值为 10。由于参数通过 by value 方式传递,所以 modifyPoint()收到i的一个副本,然后它将这个副本改为15并返回。main() 内的原值 i并没有受到影响。 对比这下,你或许认为//2 建立的 point 对象也没有被 modifyPoint()修 改 。 毕竟 java 是通过 by value 方式传递参数。于是乎,当调用 modifyPoint( ) 并传入//2所建立的 point对象时,就会产生一个复件(copy)配合 modifyPoint ()工作。modifyPoint()之中对于 point 对象所做的修改不会反映到 main ()中,因为它们是两个不同的对象嘛。对不对?错! 事实上 modifyPoint()是与在[point 对象的 reference 的复件]打交道,而不 是与[Point 对象的复件]打交道。记住,p是个object reference,并且 Java 以by value 方式传递参数。更明确地说,Java 以by value 方式传递 object reference。当 p从main()被传入 modifyPoint()时,传递的是 p(也就 是一个 reference)的复件。所以 modifyPoint()是在与同一个对象打交道, 只不过通过别名 pt罢了。在进入 modifyPoint()之后和执行//1 之前,这 个对象看起来是这样: PointPointPointPoint pppp ptptptpt 所以//1 执行过后,这个 point 对象已经改变为(5,5)。如果你不同意在诸 如modifyPoint()这样的函数内改变 Point 对象,该怎么办?有两种解决 办法: � 对modifyPoint()传递一个 point 对象的克隆件(clone)。关于克隆 (cloning)的具体信息,请见实践 64和实践 66。 � 令point 对象成为 immutable(不可变的)。实践 65对于 immutable 对象 技术有所讨论。 实践2222:对不变的 date date date date 和objectobjectobjectobject references references references references 使用finalfinalfinalfinal 许多语言都提供常量数据(constant data)的概念,用来表示那些既不会改 变也不能被改变的数据。Java 关键词 final 用来表示常量数据。例如: 这段代码声明了一个 static 类变量,命名为 someInt 并设其初值为 10。 任何试图修改 someInt 的代码都将无法通过编译。例如: XXXX ==== 0000 yyyy ==== 0000 关键词 final 可防止 classes 内的 instance 数据遭到无意间的修改。如果我们 想要一个常量对象,又该如何呢?例如: 这段代码的输出是: 在上述第一个示例中,当我们企图改变 final 数据值时,编译器会侦测出错 误。在第二个示例中,虽然代码改变了 instance 变量 wheel 的值,编译器还 是爽快地让它通过。我们已经明确声明 wheel 为final,它怎么还能够被改 变呢? 不,我们确实没有改变wheel的值,我们改变的是wheel所指对象的值。wheel 并无变化,仍然指向(代表)同一个对象。变量 wheel 是一个 object reference, 它指向对象所在的 heap 位置(实践有更多关于 object references 的 信 息 ), 有鉴于此,下列代码会发生什么事? 编译这段代码,会像我们原本预期的那样,产生以下错误消息: FinalTest.java:9:Can’t assign a value to final variable:wheel; wheel = new Circle(7.4); 1 error 由于我们企图改变 final 型变量 wheel 的值,所以这个示例将产生编译错误。 换言之,代码企图令wheel指向他物。变量 wheel是final,因此也是 immutable (不可变的)。它必须永远指向同一个对象。然而 wheel 所指对象并不受关 键词 final 的影响,因此是 mutable(可变的)。 关键词 final 只能防止变量值的改变。如果被声明为 final 的变量是个 object reference,那么该 reference 不能被改变,必须永远指向同一个对象。然而 被指的那个对象可以随意改变。immutable 对象是实践 63和实践 65的主题 。 实践3333:缺省情况下所有 non-static non-static non-static non-static 函数都可被覆写 缺省情况下,classes 拥有的任何 non-private、non-static 函数都允许被 subclasses 覆写(overridden)。class 设计者如果希望阻止 subclasses 覆写(修 改)某个函数。则必须采取明确动作,也就是将该函数声明为 final。 关键词 final 在Java 中有多重用途,既可被用于 instance 变量、static 变量 (见实践 2),也可用于classes 或methods,表示不允许客户覆写它们。例 如: 编译上述代码,会导致下面的错误消息: Derived.java:15:The method void bar() decared in class Derived cannot override the final method of the same signature declared in class Base. Final methods cannot be overdiden public void bar() 1 error 由于class Base 中的bar()被声明为 final,因此 subclasses 不能覆写它。 这个特性在两个领域中尤其显得重要: � class 设计 � 运行期性能(Runtime Performance) 你或许希望禁止 subclasses 改变某个函数的行为。例如考虑一个用以表示 [屏幕上特定组件]的class,它具有一个绘制组件用的 draw()。由于应用上 的严格条件,组件外观不容许改变。你有两个选择可以防止 class 用户修改 draw( ): 1. 声明这个 class 为final。 2. 声明这个 draw()为final。 声明某个 class 为final,也就暗暗声明了这个 class 的所有函数皆为 final。 这种做法可以阻止它派生 subclasses,从而禁止任何人覆写此 class 的所有 函数。如果这种设计对你而言过于严苛,也可以考虑只将 draw()声明为 final,这种方式允许派生 subclasses,并允许后者覆写该 class 内的任何 non- final 函数。无论哪一种情形,draw()都不会被某个 subclass 修改,其行为可 确保不变。 设计 classes 时,你可以按此方式将final 当作一种提高性能的工具。在声明 某个 class 为final 之前,请考虑清楚这对 derived classes 带来的隐含意义和 限制。你也必须仔细考虑 final 函数或 non-final 函数对性能的潜在影响。实 践36探讨了相关细节。 实践4444:在arrays arrays arrays arrays 和Vectors Vectors Vectors Vectors 之间慎重选择 几乎你所撰写的任何 Java 程序都会用到某种形式的 array。Java 提供了两个 看起来十分相似的构件(constructs):array 和Vector。事实上两者全然不同 , 在你选择其中某个之前,应该先了解它们的某些实际面。 Java arrays 和其他语言的 arrays 几乎一样,但它们有些额外优势。举个例子 , 当你建立一个 array 之后,添加的元素个数便不能超越 array 大小。在 Java 中,一旦你超出array 大小,运行期便会产生 ArrayIndexOutOfBoundsException 异常。这和其他语言不同,其他语言的执 行环境无法标示出这种错误,并通常因而导致灾难性的后果。 其他语言可运用的[arrays 指针算术运算](译注:例如 C++允许将 array 名 称加上一个索引整数值)在Java 中不再成立。Java arrays 是对象,因此你可 以通过 array 调用java.lang.Object 的所有函数。当想知道 array 的容量时, 不必调用其他函数,直接访问其 public 变量 length 即可。例如下列代码打 印出 array 的长度 ia: int[] ia = new int[N]; System.out.println(“ia length is ” + ia.length); arrays 既可以容纳 primitive types(基本型别)也可以容纳 object references (实践 8将告诉你如何区分这两者)。新建一个array 时,请记住,每一个 元素都将依据其自身型别而被赋予缺省值。下表显示了array 中不同型别元 素的缺少值。 表一:array 之内不同型别的元素的缺省值 型别 缺省值 boolean false char ‘\u0000’ byte 0 short 0 int 0 如果array 的元素是 object references,Java 便不会调用 default 构造函数, 而是将其初值设为 null。下列代码建立一个 int array 和一个 Button objects array。程序首先在array 刚被建立并赋值之前,打印出所有元素值(缺省值 ), 赋值之后再次打印 array 的所有元素值。 long 0 float 0.0 double 0.0 object reference null 这段代码产生下列输出: 对比于 array,当更多元素被加入 Vector 以至于超出其容量时,其体积会动 态增长,这和 array 有着显著的不同。此外,Vector 在删除一些元素之后, 其所有[下标大于被删除元素]元素都依次前移,并获得(比原来小的)新下 标。 和arrays不同的是,你可以对着一个Vector 调用某函数以得知其大小。Vector 实现一个 size(),返回实际元素个数。然而 size()的返回结果或许和你预期 的不同,因为 size()返回的是 Vector 内的实际元素数量;若有元素被移除, 容器的大小便会改变。array 的大小则是固定不变,无论有多少元素被赋予 新值,array 的大小永远不变。 Vector 的内部其实是以 array 实现的。也就是说当你新建一个 Vecotr,其内 就建立一个 array,并以 java.lang.Object 为元素型别,用以管理即将被存储 于Vector 的所有数据项。当 Vector 增长时,其内部整个 array 必须重新分 配并进行拷贝。当一笔数据(一个元素)从 Vector 中被移除,其底层 array 将被凑实(compacted)。如果未能适当运用Vector,上述这些和 array 相关 的实现性质将会导致性能问题。关于 arrays 和Vectors 的性能分析,请见实 践41。 最后需要说明的一点是,Vector 只能容纳 object references,不能容纳基本 型别(primitive types)。而 arrays 则可以容纳两者任一。这个限制是因为 Vector 运用了[以java.lang.object 为元素型别]的array 作为其底部结构。例 如下列代码: 会导致这样的输出: VecArray.java:15:Incompatible type for method.Can’t convert int to java.lang.Object. v.add(i); //error 1 error 是的,所有加入 Vector 的元素,其型别都必须是 java.lang.object 或其子型 别。要让上述代码正常工作,必须将引发错误的那一行替换为: v.add(new Integer(i)); 如果你需要使用基本型别(primitive types),请考虑以 array 代替Vector。 面对基本型别,array 比Vector 拥有更高性能。关于 array、Vector 和基本型 别的性能特性,详见实践 38和实践 41.当你在你的设计和实现中面对 array 和Vector 二择一时,应当评估二者特性。表二总结了 array 和Vectors 的主 机不同点。 表二:array 和Vector 的比较 实践5555:多态(polymorphismpolymorphismpolymorphismpolymorphism)优于 instanceofinstanceofinstanceofinstanceof Java 提供instanceof 操作符,作为在运行期确定[某个对象隶属哪个 class] 的机制。就像其他语言特性一样,instanceof 经常被滥用。你可以通过多态 (polymorphism)来避免许多常见 instanceof 滥用情况。这不仅可以提高程序 的面向对象纯度,还可以使程序拥有较好的扩充性。 假设你为公司写了一个工资系统。考虑以下的 classes 继承体系和代码: 支持基本型别 支持对象 自动改变大小 速度快 array Yes Yes No Yes Vector No Yes Yes No 依据这个设计,calcPayroll()必须使用 instanceof 操作符才能计算出正确 结果。因为calcPayroll()使用了Employee 接口,所以它必须断定 Employee 对象究竟实际属于哪个 class。程序员有红利而经理没有,所以你必须确定 Employee 对象的运行期型别。 这是一个你可以运用多态来代替 instanceof 的鲜明例子。在这里使用 instanceof 毫无道理。万一你想加入另一个 Employee 型别怎么办?假设你 需要增加一个 Executive class,则在当前设计方案下,你必须改变 calcPayroll()的实现方式,增加一个针对 Excutive 的instanceof 检查动作。 这样的设计存在数个问题。首先它缺乏性能,不够优雅,而且不易扩充。 其次它要求程序员撰写代码去做 Java 运行期系统该做的事。是的,程序员 (你)调用 instanceof 判断各个对象所属的 class,以便调用适当的函数。 更好的方式是令 Java 运行期系统为你打理这些事务,这可以导出更清晰、 更优雅的设计,以及更高效、更易于理解的代码。 为了避免上述问题,我们应该按照[不需 instanceof]的方式来组织代码。请 运用多态以避免 instanceof。此外,有了一个适当设计之后,calcPayroll() 就不会因为新增一个 Employee 型别而必须随之修改。合适的设计应该是任 何时刻都能够调用恰当的函数。以下代码修正上述问题: 在这个设计中,我们为 Employee 接口增加了 bonus(),从而消除了 instanceof 的必在性。实现 Employee 接口的两个 classes:Programmer 和Manager,都 必须实现 salary()和bonus()。这些修改显著简化了 calcPayroll()。 然而,有时候,这里所介绍的解决方案行不通,你不得不求助于 instanceof 操作符。再次考量最初的 class 继承体系,假设这次 Employee 接口和 Manager、Programmer 两个 classes 都是你所使用的程序库的一部分,而你 无法修改其源码。你的任务是使用这个设计糟糕的程序库来撰写 calcPayroll(),对不同种类的Employee 计算不同的工资。这种情形下你要不 就运用instanceof 操作符撰写你的 calcPayroll() ,要不就建立两个 calcPayroll(),一个接受 Programmer object references,另一个接受 Manager object reterences。两种做法都不令人满意,但如果源码不可得,我们也只 能权宜采用其中一种做法。 Instanceof 操作符很容易被误用。很多场合都应该以多态来替代 instanceof。 无论何时当你看见 instanceof 出现,都请判断是否可以改进设计以消除它。 以这种方式来改进设计,会产生更合逻辑、更经得起推敲的设计,以及更 容易维护的代码。 实践6666:必要时才使用 instanceofinstanceofinstanceofinstanceof Scott Meyers 在其著作《Effective C++》条款 39中表示,任何时候当你发现 自己写出如此形式的码:[如果对象的型别是 T1,就做某件事,如果对象 的型别是 T2,就做另一件事],请赏自己一个巴掌。大多数情形下 Meyers 是正确的,他的贤明同样适用于 Java。然而有你还是需要撰写这样风格的 代码。在这些极少数的场合里,你不必赏自己一个耳光。 实践5曾经提到一种特殊情形:面对一个设计不当的 class 程序库,用户可 能无法避免使用 instanceof。事实上有更多常见情形,使你除了使用 instanceof 以外别无选择,例如当你必须从一个基础型别(base type)向下 转型为派生型别(derived type)时。幸运的是 Java 提供了安全向下转型机 制,使得不适当的转型动作导致编译期错误,或在运行期抛出异常。 非法转型会产生编译期错误,例如从某人型别转至另一个毫无关系的型别。 如果在同一个 class 继承体系内进行转型而在运行期被确定为非法,就会抛 出异常(exception)。例如: shape1 各shape2 是分别隶属 Shape 型别和 Object 型别的两个 object reference.shape1 指向一个 Circle 对象,shape2 指向一个 Triangle 对象。 无法将 reference to Shape 转型为 reference to Polynomial,因为两个对象之 间没有任何关连。Java 编译器将检测出这一点,并发出编译错误消息。如 果你将 reference to Shape 转型为 reference to Triangle,那么至少是在同一个 对象体系内进行,因此可能合法。但这种向下转型的合法性只在运行期才 被确认。Java 运行期系统会检查 shape1 所把对象是否为一个 Triangle 对象。 由于它不是,遂产生 ClassCastException 异常。最后一个转型动作通过了上 述两项测试,因而合法。 你或许会倾向这样的想法:一旦需要转型,只需谨慎行事就可以消除 instanceof 的必要性。这不能成立。让我们考虑 Vector class。它具有这样的 性质:可容纳以 java.lang.Object(或其派生类)为型别的任何元素。此外, 从Vector 取得的元素都以 java.lang.Object 型别返回。是的,只要对象存储 于Vector 中,不论该对象属于哪个派生型别,都是如此,因为所有 Java 对 象都派生自 java.lang.Object。 为了运用[存储于 Vector 内]的Object 派生对象所带函数,你必须将它从 java.lang.Object 向下转型为该对象原本隶属的 derived class。由于Java 凭借 编译期和运行期两道检查保证向下转型的安全性,所以一旦转型无法正确 执行,JVM 就会于运行期抛出 ClassCastException 异常。为避免这种情况, 你有两种选择: 1. 使用 try/catch 区段,处理 ClassCastException。 2. 使用 instanceof 操作符。 第1种做法采用 try/catch 区段,虽然可以动作,但不可取(详见实践 23和 实践 24)。由于你可以避免发生这种异常,所以更好的解法是采用instanceof 操作符。这种方法保证获得[不至于导致运行期异常]的向下转型。考虑下列 代码: 这段代码表明在这种场合下 instanceof 操作符是必需的。当程序从 Vector 取回对象,它们属于 java.lang.Object。利用 instanceof 确定对象实际属于哪 个class 后,我们便(才)得以正确执行向下转型,不至于在运行期抛出异 常。 另一种选择是,你可以将 Circle 对象和 Triangle 对象存储于不同的 Vectors 内,如此便可避免使用 instanceof。但如果你无法控制诸如 Vector 这样的群 集(collection)的内含物,就非得使用 instanceof 不可。 实践7777:一旦不再需要 objectobjectobjectobject referencesreferencesreferencesreferences,就将它设为 nullnullnullnull Java 垃圾回收机制(garbage collection)负责回收不再被使用的内存。程序 员也由此不再需要明白调用某个函数以释放内存。因此,诸如free 和delete 之类的函数不再必要,Java 也没有提供它们。垃圾回收机制的存在导致某 些程序员忽视内在问题。事实上,[内存问题纯属庸人自扰]的观点是一种误 解,内存在程序中如何被运用,仍然是值得关注的议题。 一旦你的程序不再参考(或说引用)某个对象,垃圾回收器就会回收这个 对象所拥有的内存。当不再需要某对象时,你可以将其 references 设为 null, 以协助垃圾回收器取回内存。如果你的程序执行于一个内存受限环境中, 这可能很有益处。 即使垃圾回收器执行起来,也并非所有[不再被引用](unreferenced)的内存都 可被回收。回收性能取决于你的代码所处之 JVM(Java 虚拟机)所采用的 垃圾回收算法。也许需要多次调用垃圾回收器,才得以收回一个不再被引 用的对象。年长且长寿的对象和新生对象相比,似乎比较不容易摇身变为 [不再被引用],因此常见的垃圾回收算法总把焦点放在新生对象身上,频繁 地分析它们。 通过[查询可用内存、调用 System.gc()、再查询可用内存]的步骤,你可以 推测你的 JVM 所用的垃圾回收器的某些行为: 当程序调用 System.gc()时,绝大多数JVM 实现品的响应都是执行垃圾回收 器。此外,如果内存可用量过低,或 CPU 在一段时间内处于空闲状态时, 大多数 JVM 也会隐式(自动地)调用垃圾回收器。多长久的空闲才会引发 垃圾回收器被调用呢?这取决于 JVM 所采用的垃圾回收算法。 如果对象内含一些 instance 变量并于构造函数中为它们设定了初值,而程 序用了这样的对象以至于消耗大量内存,就会引发问题。如果这样的对象 存在于程序运行期的大部分时间,而它们所引用的内存在这段期间内并不 需要,那么大片内存就被浪费掉了。这对你的代码性能是有害的。 下面这个例子有个 main(),内有一个 Customers 对象,负责为 GUI 提供信 息: 假设此处的 Customers 对象很大(也许存储了 20000 个顾客识别码),又假 设这个 Customers 对象与程序生命等寿,并且在 GUI 建立完成后不再被需 要,而 GUI 的建立距离 main()的结束还有好长一段时间。根据这些,你如 何修改代码才能不使用这么多内存? 一个解决办法是在 GUI 建立完成后将局部变量 cust 高为 null。这么一来便 割断了对 Customers 对象的引用状态,垃圾回收器或许便会在下次运行时 回收这些内存。修改后的 main()看起来像这样: 如果你需要保持 Customers 对象在整个程序生命期间一直可用,但 GUI 建 立后 Customers 对象内的 custIdArray 只是很有限度地被使用,该怎么做才 好呢?未来你或许需要这个 array,但多半情况下不需要,这时候你可以为 Customers class 提供一个函数,只用来将custArray 设为 null,然后在main() 中调用该函数: 这段代码具有[将array object reference 设为 null]的效果。然而这种地有潜在 的负面影响。最初这个 class 的设计是假定存在 array 并具有初值。新增的 unrefCust()改变了设计。现在你的程序不得不处理[需要使用这个 array 而它 却不再可用]的局面。或许你还得增加新函数,用来在 array 被解除引用 (unreferenced)后重新建立它。 这些技术也可以用于class函数身上,它们的局部变量可能拥有短暂的生命 。 别以为[函数结束时其局部对象不再被引用]所以不会影响你的程序。考虑以 下例子: 将一个局部变量的 reference 设为null 有一些好处。本例之中的 bigObj reference 在函数结束前很长一段时间不再被需要,因此,将它设为 null 或 许能使其在垃圾回收器下次运行时被收回。垃圾回收器的确有可能在这个 函数完成之前运行起来。在这种情形下,如果这个函数的剩余部分需要大 量内存,则将此局部变量设为 null 应该是有益的。 这些技术可以解决[大量内存被占用而它们其实不再有用]的问题。为了尽量 降低内存用量,与程序同寿的对象必须尽可能体积小。此外,大块头对象 应该[速生速灭]。 限制Java 程序所占用的内存,或许只对[执行于同一 JVM 上的Java 程序] 的性能有益,未必有益于相同系统上的其他程序。这是因为许多 heap 管理 算法会预先分配一些供你的程序使用。假设你的 JVM 预先分配了 12MB heap,并且不再增长,那么无论你的程序使用其中多少数量,都不会影响 其他系统进程(system processes)的运行。你的程序的 heap 占用率将直接 对自身性能产生影响。 检阅代码时,请注意大块头对象,尤其是那些存在于完整(或大部分)程 序生命中的对象。你应该仔细研究这些对象的建立和运用,以及它们可能 引用多少内存。如果它们引用了大量内存,请确定是否所有那些内存在对 象生命期内都真的被需要。也许某些大块头对象可以解甲归田,从而使其 后执行的代码能够更高效地运行。 任何时刻你都可以通过 system.gc()要求垃圾回收器起身行动。如果想将一 个对象解除引用(unreference),则可以通过调用 System.gc()要求垃圾回收 器立刻运转,在代码继续执行前先回收被解除引用的那块内存。但是你应 当仔细考量这种做法为你的程序性能带来的潜在影响。 许多垃圾回收算法会在它们运转之前先虚悬(suspend)其他所有线程 (threads)这种做法可以确保一旦垃圾回收器开始运转,就能够拥有 heap 的 完整访问权,可以安全完成任务而不受其他线程的威胁。一旦垃圾回收器 完成了任务,便恢复(resume)此前被虚悬的所有线程。 因此,通过 System.gc()显示调用,要求垃圾回收器起而运行,你得冒[因执 行回收工作而带来延迟]的风险,延迟程序取决于 JVM 所采用的垃圾回收 算法。 大多数 JVMs 的垃圾回收器都有充足的运行,因此你实在不必显式地调用 它。然而如果你的代码有些部分期望在继续进行前先释放(回收)所有可 能的内存,则可以考虑调用 System.gc()。 2222 对象与相等性 实践8888:区别 reference reference reference reference 型别和 primitive primitive primitive primitive 型别 译注:本节讨论reference 型别和 primitive 型别,为求术语突出和词性平衡 , 我在本李保留两者的英文术语。如果 primitive type 单独出现,我可能偶而 译为[基本型别]。 Java 提供了截然不同的型别:reference(引用)型别和 primitive(基本)型 别,后者又称为 built-in(内置)型别。每一种 primitive(基本)型别分别 拥有相应的外覆类(wrapper classes)。如果你需要一个变量来表示整数, 你会选用基本型别 int 抑或 Integer 对象?如果需要声明一个布尔型别,你会 选用基本型别 boolean 抑或 Boolean 对象?下表列出了基本型别和其相应的 外覆类。 表三:基本型别(primitive types)及其相应的外覆类(vrapper) references 和primitives 和行为方式全然不同,并且具有不同的语义。举个 例子,某个函数中有两个局部变量,其中之一隶属 primitives 型别阵营中的 int,另一则是个 reference,指向某个 Interger 对象: int i = 5; //Primitive type Integer j = new Integer(10); //Object reference 这两个变量都存储在局部变量表,它们的操作都在 Java 操作数堆栈 (operand stack)中进行,但二者所表述的意义完全不同(请注意,稍后讨 论将以术语 stack 表示[操作数堆栈]或[局部变量表])。不论是基本型别 int 基本型别(Primitive types) 外覆类(Wrapper class) boolean Boolean char Character byte Byte short Short int Integer long Long float Float double Double 或object reference,它们都在 stack 中占据 32 bits空间,但Intege 对象在 stack 中记录的并不是对象本身,而是对象的 reference。 所有 Java 对象都是通过 Object references 来访问的,那是某种形式的指针, 该指针指向 heap中的某块区域,heap则为对象的生存提供了真实存储场所 。 当你声明了一个基本型别后,你就为它声明了一份存储空间。前述两行代 码可以这样表示: StackStackStackStack HeapHeapHeapHeap reference 型别和 primitive 型别具有不同的特性和用法。这些不同点覆盖大 小及速度相关问题(详见实践 38)、型别所存储的数据结构形式(详见实 践4),以 及 [被当作某个 class 的instance 数据]时的缺省值。object reference instantce变量的缺省值是 null,primitive type instantce变量的缺少值则取于型 别,请参考 P.8 的表所列 Java 各型别之 instance 变量的缺省值(该表同时 也展示了该种型别所构成之 arrays 的缺省值)。 许多程序员既使用 primitive 型别,又使用其外覆类(wrappers),因而测验相 等性(equality)时你必须费一番周折才能同时使用这些型别并对它们如何 互相作用和共存有一番认识(参见实践 9)。程序员必须理解这些型别如何 运作和相互作用,只有这样才能避免写出漏洞百出的程序。 举个例子,你不能对一个 primitive 型别调用函数,但是对一个对象调用其 所供应的函数,再自然不过了: 5//i’s value j Integer 10 int j = 5; j,hashCode(); //ERROR //… Integer I = new integer(5); i.hashCode() //Ok 如果你使用 primitive 型别,便免除了[调用 new 以创建对象]的需要。这可 以节省时间和空间(参见实践 38)。混合使用 primitive 型别和对象,也可 能在赋值(assignment)时产生意想不到的结果。看起来完全无害的代码, 所作所为或许与你预料的并不一致,例如: 这段代码产生以下输出: 对整数 a和b的修改结果没什么值得大惊小怪的。变量 a被赋予 b的 值 , 然后a加1。输出结果与我们的预期并无二致。但是在赋值 x并调用 setLocation()之后,x和y的输出或许令我们感到诧异。经过了 x = y的赋值 动作后,我们又明确地对x调用了 setLocation(),x 和y怎么还是具有相同的 值呢?毕竟我们将 y值赋给了 x,又改变了 x值,就像我们对整数 a和b 所做的那样。 令我们困惑的根源在于[primitive 型别]和[对象]的使用方法。赋值动作对二 者而言并没有什么不同。也许有,但表面上看不出来。赋值使得等号(=) 左侧值等于右侧值。对于诸如上述所提的int a 和b那样的 primitive 型别而 言,这是显而易见的。但对于 non-primitive 型别,例如上述的 Point 对 象 , 赋值动作所修改的是 object reference 而不是 object 本身。因此,执行完以 下语句之后: x = y; x等于 y。从另一个角度说,由于 x和y都是 object references,它们现在均 指向(代表)同一个对象(object)。于是对 x的任何改变也同时改变了 y。 下面是//1 代码执行后的情形: a = 1 xxxx yyyy b = 2 Point(0,0)Point(0,0)Point(0,0)Point(0,0) Point(1,1)Point(1,1)Point(1,1)Point(1,1) 经过//2 的赋值动作后,情形如下: a = 3 xxxx yyyy b = 2 Point(0,0)Point(0,0)Point(0,0)Point(0,0) Point(1,1)Point(1,1)Point(1,1)Point(1,1) 当//3 调用 setLocation()时,函数作用于 x所指的对象。由于 x和y指向同 一个对象,故而形成: a = 3 xxxx yyyy b = 2 Point(0,0)Point(0,0)Point(0,0)Point(0,0) Point(5,5Point(5,5Point(5,5Point(5,5) 由于 x和y指向同一个对象,所有执行于 x身上的函数,就执行于 y身上 一样。 弄清楚 reference 型别和 primitive 型别之间的差异,以及理解 references 的 语义至关重要,否则会导致代码的行为和预想的不同。 实践9999: 区分========和equas()equas()equas()equas() 每当讨论 Java 的相等性(equality)时,疑惑便如影随形的接踵而来。==操作 符和 equals()函数的区别是什么?如何取舍?究竟 equals()是何方神圣?难 道有了==还不够吗? 如果不弄明白这些问题,你的代码就有可能臭虫丛生。考虑下面这个例子: 执行后会产生以下输出: 如果你不确知为什么产生这样的结果,把代码修改成如下这样或许会清楚 一些: 再次执行后,输出如下: 如果你还是不清楚为什么这段代码产生这样的输出,让我们在仔细研究一 番。变量 a和b的类型是 int,是一种 primitive 类别(参见实践8和实践 38), 和任何对象都没有瓜葛。相反的,变量ia和ib是object references,指向 Java 的Integer 对象。你或许会说[喔,可真了不起啊!它们的值相同,都是 10]。 唔,你可以说它们相等,也可以说它们不相等。 iaiaiaia ibibibib a=10 b=10 Integer Integer 10 10 Primitive 类型(基本类型)a和b的确具有数值 10.而作为 object references 的ia和ib,实际上是指向两个[其值为 10的Java Integer 对象]。因此,ia 和ib的值不是 10;它们的值并不相同,分别代表不同的对象。操作符== 只是[浅层地]测试相等性。看看操作符左侧的东西是不是和右侧的东西一 样。由于 ia和ib指向不同对象,这表示它们具有不同的值,所以不相等。 如何测试[ia 和ib所指的值](而非 ia和ib本身)是否相等呢?既然你已经 看到==操作符不能给与我们想要的结果,因而在这种情况下你应该使用 equals()。因为它是个函数,所以你只能通过对象使用它,不能对诸如 int、 float、boolean 之类的基本型别使用之。欲测试 ia和ib是否“equals”,代 码应该这么写: 这段代码产生如下输出: [不同类别之间的比较]是另一个问题来源。考虑下面的代码: 这段代码输出的结果是: 这表示,不同基本型别的数值彼此可能相等,但不同型别的对象则不然。 当程序比较基本型别 int a和基本型别 float b时,会将 int a晋升为一个 float, 其值从 10变化为 10.0,并认为两值可被视为相等。然而,两个隶属不同 classes 的对象绝不会被认为彼此相等,除非你提供一个你自己定制(编写) 的equals(),但我并不推荐你这么做。关于 equals()实现法的更多信息,请 见实践 11~实践 15。 这里的要点是:请使用==测试两个基本型别是否完全相同(identical),或 测试两个 object references 是否指向同一个对象;请使用 equals()比较两个 对象是否一致(same)——基准点是其属性(attributes。译注:此处是指 对象的实值内容,也就是数据值域,field)。我 们 把 [根据属性来比较两个对 象是否相等]称为[等值测试](testing for value),或 称 为 [语义上的相等测试] (testing for semantic equality)。在你开始使用 equals()之前,请先看看实践 10,那里解释了一些不易察觉的问题。 实践10101010:不要依赖 equals()equals()equals()equals()的缺省实现 实践 9阐释了何时使用==操作符以及何时使用 equals()。如果你对后者的实 现方式不闻不问,则在调用它时或许无法获得你想要的结果。 举个例子,假设你正在为某个高尔夫器材批发店撰写软件,其中一个任务 是计算库存中的同类球数量。你可能已经为高尔夫球撰写了如下的 class: 每一个 Golfball 对象都具有品牌(brand)、型 号( make)和弹 性(compression) 三个属性。两个 Golfball 对象相等意味所有三个属性都必须相同。进一步 假设,每一个 Golfball 对象都被存储于某数据库中。日后当你访问数据库 时,必须确定哪些 Golfball 对象是同类型的,才能产生正确的计算。下面 是一段用以比较 Golfball 对象的代码: 你已经知道,如果要按照设计好的方式来计算高尔夫球的数量,就必须使 用equals()而不是==(参见实践 9)。然而一旦你执行上一段代码,你会惊 奇的发现任何 Golfball 对象都不想等。上个例子的输出如下: 换句话说,这份实现代码的结果就是:任何高尔夫球都不相等。[等一下], 你说,[我刚读完实践 9,那是你告诉我这一种比较要使用 equals(),现在它 却不管用]。是的,==只进行 object references 的比较,所以这里你应该使 用equals()。然而你也知道,每个 Golfball 对象都互相独立,因此所有的object references 都不相同。这里的问题在于:到底你调用了什么样的 equals()? 也许你已经猜到,一定不是你想要的那个。 你或许觉得奇怪,实践 9的代码产生了正确的结果,上一段代码却没有。 实践 9的代码对一个 Integer 对象调用 equals(),恰好 Integer class 提供了它 自己的 equals()实现代码,于是就被用上了。本例中的 Golfball 对象并没有 实现自己的 equals(),因此调用的是 java.lang.Object 的equals()。 请注意,所有 Java classes 都隐含的继承了 java.lang.Object,那是 Java 的最 基础类。Golfball class 当然也不例外,而且由于它没有实现自己的 equals(), 所以你不知不觉使用了 java.lang.Object 提供的 equals()。这个函数实现如下 : 上页的 Warehouse class 内便是对每一个 Glofball 对象执行 java.lang.Object 的equals()。这一份 equals()缺省实现代码并非按照你设想的去做:它只是 检查 object references 是否值相同一个对象。java.lang.Object 版本的 equals() 行为类似以下动作: 你知道的,这一条语句在我们的实例中绝不可能为 true。你想知道的其实 是高尔夫球的品牌(brand)、型号(make)和弹性(compression)是否相 同。与改善这个问题,Golfball class 必须实现自己的 equals()函数,而不能 寄望于 java.lang.Object 提供的缺省实现代码。 只要在 Golgball class 中按上述方式实现 equals(),再执行你的代码,就会得 到你所期望的正确结果: 请记住,你不一定要检查对象内的所有值域(fields),只需检查必要值域即 可。根据原本的设计,检验品牌、型号和弹性就足以确定两个对象是否相 等。如果你认定只要品牌和型号相同就视两个 Golfball 对象为相等,而无 需计较他们的弹性,那么就应当去除 equals()函数之中对 compression 的检 查。 也请注意 equals 实现代码的一些细微处。函数值中对于隶属基本型别的 compression 用的是==操作符,对 String object reference 用的则是 equals()。 这样才能确保货的正确的结果(参见实践 9)。 到目前为止你的设计很出色,高尔夫器材批发店的程序运转的也很好。可 是有一天,某位程序员(在阅读了《Practical Java》实践 31之后)打算修 改Golfball 对象的 brand 和make 值域,以 StringBuffer 替换 String。 现 在 , Golfball class 变成了这样子: 看起来这是对 class 无伤大雅的修改。你并没有改变 equals(),只是改动 Golfball class 的其他部分。Golfball class 仅仅从使用 Strinng 转为使用 StringBuffer。但在完成了修改之后,你会惊讶的看到程序又不能正常运作 了。它所产生的输出如下: 这里发生了什么事?早先为了使之正常运转,你曾经加上 equals()。现在他 又出问题了,但你对 equals()甚至碰都没有碰一下。你所做的一切就只是将 String 变为 StringBuffer,这个问题当然不会是它引起的,是吧?噢,其实 就是它造成的。 再看一遍 equals()。请注意其中调用了另一个 equals(),这是个不妙的信号。 它调用了什么样的 equals()呢?它调用的是 brand 和make 的equals()。原例 中的这些对象隶属 String 型别,因而调用的是 String 的equals()。现在它们 改而隶属 StringBuffer 型别了。问题就在这里。 Java 的String class 正确实现了一个 equals(),但 StringBuffer class 根本就没 有实现它。由于这个原因,你一定猜到了,你所调用的是 java.lang.Object 的equals()。这是因为 StringBuffer 的父类是 java.lang.Object,这就说得通 了。我们先前已经讨论过,java.lang.Object 的equals()在此情况下不能用。 这也正是你先前自行撰写 equals()的原因。 这个问题有数个解决方案: 1. 回到使用 string 对象的老路子。 2. 修改 Golfball 的equals(),使之先将 StringBuffer 对象转换为 String 对象, 然后再调用 equals()。 3. 撰写一个你自己的 StringBuffer class,其中包含 equals()。 4. 放弃equals(),撰写你自己的 compare()函数,用它来比较 StringBuffer 对象的相等性。 选项 1是最简单的办法:回头使用 String class。你知道它可以胜任,并且 只牵涉最少工作。但是出于某些实现方面的考虑(参见实践 31),你或许 还是宁愿为 Golfball 对象的 brand 和make 使用 StringBuffer。 选项 2仍然使用 StringBuffer,修改 Golfball class 的equals(),在其中先将 StringBuffer 转换成 String 对象。这种做法保证了在比较动作发生时调用的 是String 的equals(),从而确保结果正确。修改后的 Golfball class 采用如下 技术: 这个方案确实有效,再一次测试,你的程序的却可以产生正确结果。但是 它的实现代价太高了。是的,每次对 StringBuffer 对象调用 toString(),都伴 随产生一个新的 String 对象,这代价太高了(见实践 32)。然而它确实解决 了由于使用 StringBuffer 而引发的问题。 选项 3建立你自己的 StringBuffer class,并为这个 class 实现 equsla()。在实 现你自己的 equals()之前,请先阅读实践11~实践 15.这个方法需要多做一些 事情,但是你能充分利用 StringBuffer 的优点。 下面是一个名为 MyStringBuffer 的class,它利用与StringBuffer 的has-a(又 名[包含、复合])关系。这种设计时必需的,因为 StringBuffer 是一个 final class,而 final classes 不能被扩展(继承)。因此 MyStringBuffer 对象内含一 个reference 指向一个 StringBuffer 对象。MyStringBuffer 的实现如下: 现在我们修改 Golfball class,令它使用 MyStringBufferclass: 最后,我们再修改 Warehouse class,令它使用 MyStringBuffer class: 这段代码产生的输出一如我们期待: 选项 4需要撰写一个 static compare()函数,用以比较两个 StringBuffer 对象 的相等性。Golfball 的equals()必须调用这个 compare()函数,而不再调用 equals()。这个 compare()函数的实现类似 MyStringBuffer 的equals()。 这个方案的好处是:你不必建立另一个 class 来比较两个 StringBuffer 对象。 缺点则是你必须修改 Golfball 的equals(),使之调用 compare()函数,代替原 本调用的 equals()。这意味一旦你日后有打算转而使用 String 对象时, Golfball 的equals()也必须同时修改。static compare()函数和 Golfball 的 equals()看起来像这样: 以上的 equals()故事教给了我们什么样的[行事准则]呢? � 若要比较对象是否相等,其 class 有责任提供一个正确的 equals()。 � 在[想当然的调用 equals()]之前,应先检查并确保你所使用的 class 的确 实现了 equals()。 � 如果你所使用的 class 并未实现 equals(),请判断 java.lang.Object 的缺省 函数是否可胜任。 � 如果无法胜任,就应该在某个外覆类(wrapper class)或subclass 中撰 写你自己的 equals()。 撰写自己的 equals()之前,请先阅读实践 11~实践 15。 实践11111111:实现 equals()equals()equals()equals()时必须深思熟虑 当你为某个 class 设计和实现 equals()时,应该考虑几个问题。首先,你必 须仔细考量 class 究竟何时需要提供 equals()。 当[class object 相等与否]的检验工作超过了[object reference 之间的单纯比 较]时,该 class 就应当提供一个 equals()。从另外一个角度说,如果占用不 同内存空间的两个对象被视为逻辑上相同,那么这个 class 就应当提供 equals()。 实践 10展示了好几种 Golfball class 的equals()实现方式。这些equals()实现 法尽管对当时的设计和实现而言是恰当的,但并不代表全部做法。 在你撰写 equals()之前,必须做几项设计方面的重要决定。你想要让哪些 classes 的对象与你的 class 对象进行比较?你只打算让相同 class 的对象之 间进行比较吗?或者你允许 derived class 对象和其 base class 对象比较?接 下来你必须决定如何实现 equals(),使它得意提供并执行上述语义。 你对上述问题的回答,将对 equals()的实现方式和 class 相等性语义产生直 接影响。实践 12~实践 15讨论了各种选择、各种分支后果,以及各种实现 方式。 实践12121212:实现 equals()equals()equals()equals()时优先考虑使用 getClass()getClass()getClass()getClass() 强型别参数(strong argument)可以做到[唯有相同 class 所产生的对象才得 被视为相等]。进一步的推论就是:如果两个对象隶属不同的classes 或types, 则它们必不相等。对于实现 equals()而言,[唯有相同 class 所产生的对象才 得被视为相等]是一个既轻松又简单的方案。为了达成这一点,必须在 equals()实现代码中使用 getClass()。事实上,实践 10的equals()实现代码中 就已经用上了 getClass()。 下面是个例子: getClass()会返回某个对象的运行期类(runtime class)。因此,如果上述两 个正在比较的对象并非都隶属于 Base,equals()便会返回 false。以下代码对 于Base 对象和 Derived 对象会产生程序批注中所说的结果: 由于b1和d1隶属不同的 classes,所以用 getClass()完成的比较总是返回 false。如果参与比较的对象隶属同一个 class,那么一定可以通过 getClass() 测试,这时的 equals()是否返回 true,就取决于这些对象的属性(attributes, 以及将被拿来比较的值域,fields)是否相同。因此,一旦使用 getClass() 就表示不再允许 derived class 对象与 base class 对象被视为相等——也就是 说这样的比较结果总是 false。 为确保 equals()正确而直观的[相等语义],实现时除了使用 getClass(),还需 遵循其他准则。针对下面问题,不同的回答会导致不同的equals()实现方式 : 1. [相等](equality)的内涵是什么?例如,两个对象究竟是全部或部分属性 (attributes)一致,才被视为相等?这个问题稍后马上就要讨论。 2. 如果你正在为某个 class 实现 equals(),这个 class 有java.lang.Object 以 外的任何 base classes 吗?如果有,那些 base classes 曾经实现 equals() 吗?这个问题将在实践 13讨论。 第一个问题的回答,可由实践 10的Golfball 来说明。在那个例子中,为了 比较该种类型的对象是否相等,Golfball 提供了一个 equals()。如果两个 Golfball 对象相等,他们的三个属性:brand、make、和 compression 则必须 两两一致。Golfball class 和其 equals()采用如下的实现方式: equals()首先查看参与比较的两个 object references 是否指向相同一个对象, 也就是以下代码: 测试 this 和obj 是否值相同一个对象。如果是,你可以返回 true,不必继续 执行余下的部分。第二个测试: 可以确保你不会对一个 null 对象调用 getClass(),并确保参与比较的两个对 象隶属同一个 class。后一个条件是你可以将此[型别为 Object]的对象向下 转型(downcast)为你所定义的型别,然后再开始对相应的属性执行相等 性检验。 Golfball 的equals()以其设计者对[相等]的定义为依据,对两个Golfball 对象 进行精确的相等性检验。请注意,这个 class 比较每一个属性,决定对象是 否相等。假如你认为 compression 不该是这个 class[相等语义]的一部分,那 么,尽管 compression 是Golfball 的一个有用属性,你还是可以从 equals() 中去除对 compression 的比较。这么做仅仅改变了通过 equals()实现代码而 呈现出来的[相等语义](equality semantics)。 总体而言,equals()的最佳实现方式就是搭配 getClass(),后者可确保只有相 同class 所产生的对象才有机会被视为相等。此外,equals()应当查看参与比 较的对象是否为同一个对象。equals()不必对每一个属性(attributes)进行 比较,只有那些攸关[相等性]的属性才需要比较。 实践13131313:调用 super.equals()super.equals()super.equals()super.equals()以唤起 basebasebasebase class class class class 的相关行为 为了让 equals()产生正确结果,我们经常需要调用 base class 的equals()。让 我举个例子,假设实践 12中的 Golfball class 是某程序库的一部分,你正利 用它对一个高尔夫器材批发店统计高尔夫球的库存量。你的程序先从数据 库中读取数据,然后放置到 Golfball 对象中,再比较这些对象以得到一个 计数。这些代码运转良好,但后来你想根据另一个属性来区分高尔夫球: construction(构造)。由于 Golfball class 是你所使用的程序库的一部分,你 无法直接修改它,因此你建立一个 subclass,为之增加新属性,并提供 equals()来比较这个 class 所产生的对象。代码看起来像这样: 你的程序这样统计 Golfball 对象: 这段代码执行起来,会告诉你两个 Golfball 对象相同。这是正确的结果, 但如果你令 Golfball 对象包含不同属性,情况如何?像这样: 在这里,两个对象的弹性(compression)不同,但是对它们调用 equals(), 返回的结果却是: 这显然是不正确的。根据原先设计,这两个对象并不相等。出了什么问题 呢? 在Warehouse class 的main()中,以下代码: 调用 MyGolfball class 的equals(),这是因为 gb1 是个 MyGolfball 对象。这 个equals()(只)比较两个 MyGolfball 对象的 ballConstruction 属性,由于 它们相等,于是返回 true。base class Golfball 的equals()并未参与执行。是 的,brand、make 和compression 三个属性都存储于 base class 中,没有参与 比较。 要想比较 base class 的属性,base class 的equals()必须也被调用。毕竟,一 个MyGolfball 对象是由 MyGolfball 和Golfball 两部分共同构成的,为求准 确无误地比较这些对象,你必须确保 base class 及其derived classes 的 equals()都获得执行。这样可以确保也[相等性]有关的两部分(base 成分和 derived 成分)都确实进行了比较。我们可以用下图表达整个概念: equal of Golfball class Golfball Brand Make Compression MyGolfball ballConstruction Golfball Brand Make Compression MyGolfball ballConstruction equal of Golfball class compare these attributes compare these attributes gb1 gb2 因此,derived class MyGolfball 的equals()应当修改,应该在其中调用其base classGolfball 的equals(): MyGolfball 的equals()在//3 处作了修改,调用base class Golfball 的equals(), 用以比较 Golfball 的属性。如果 base class 的属性(brand、make 和 compression )相等,代码接下来才继续比较 derived class 的属性 ballConstruction。现在,不论你以相等或不等的对象来执行这段代码,都可 以产生正确结果。 你或许可以在 derived class 的equals()实现代码中采用一种简化方式。上个 例子中的//1 和//2 可以删掉。由于 super.equals()调用的是 base class 的 equals(),而后者进行了相同的检验,因此两处相同的检查显得多余。简化 后的 equals()看起来像这样: 但是,如果想采用这种简化方式,你必须首先获得 base class 的源码,确保 在那里做了适当检查工作。而且这种简化方式你不得不承担某些风险—— 你的程序有可能因为未来 classes 继承体系被修改而受到损坏。例如,一旦 [使用这种简化方式]的某个 derived class 摇身一变为 base class,其 equals() 必须更新,重新添加那些检验码。所以,只有当[删除那些检验码将会带来 充分的性能提升,而且绝对没有后续风险]的情况下,才可以使用这种简化 方式。 如果在 Golfball 和MyGolfball 之间存在另一个 class,会怎么样?例如: 由于 MyGolfball 的equals()调用了 super.equals(),所以这段代码运作正常并 产生正确结果。当你为一个 derived class 撰写equals()时,你必须检查 java.lang.Object 之外的所有 base class,看看他们是否都实现有 equals()。如 果有,那么一定要调用 super.equals()。 你得检查 java.lang.Object 之外的所有 base classes——这是java.lang.Object 的equals()实现方式使然。这个函数仅仅比较两个 object references 的相同 性,而你进行的却是比较 classes 的某些特定属性。因此,如果要从 class B 派生 class D,则一定要在 class D的equal()中调用 class B的equals(),用以 比较 B的属性。换句话说,一旦 java.lang.Object 之外的 base class 实现由 equals(),你就一定要调用 super.equals()。 实践14141414:在equals()equals()equals()equals()函数中谨慎使用 instanceofinstanceofinstanceofinstanceof 实践 12为我们展示了如何在 equals()函数中使用 getClass(),这种做法使得 只有隶属同一个 class 的对象才能被视为相等。如果你希望 derived class 对 象与其 base calss 对象也可被视为相等,该怎么做呢? 例如,考虑一个 class 程序库,它提供了一个代表汽车的 class。这个 class 提供了一些汽车属性和汽车操纵函数,看起来像这样: 假设你产生了一些 car 对象,在应用程序中使用它们,而且经常需要比较两 个car 对象是否相等。你的定义是:若两个 car 对象的 make(款式)和 year (年份)相同,他们就被视为相等。后来,你想修改某些汽车的驾驶方式。 假设你无法修改 car class 源码,于是你派生一个自己的 class,MyCar: 注意,并没有新属性(值域)加到 MyCar class 中,只不过是增加了一个函 数 ,它覆 写(override)了drive()。产生 Car 对象时,你可以产生Car 和MyCar 两种对象。记住,你只是修改某些汽车的驾驶方式,因此还是可以比较 MyCar 对象和 Car 对象是否相等。由于参与比较的相关属性都只存在于base class Car 之中,而非 derived class MyCar 之中,所以你会认为,原先的相等 性比较(equality compareison)还是可以正常运作。 这种情况下,你可以作出结论:[比较derimve class 对象和 base class 对象 是否相等]是合情合理的。执行这种比较必须满足 两个条件: 1. base class 采用 instanceof 来实现 equals(),而不是采用 getClass()。 2. derived class 并没有实现 equals()。 理由如下: � 如果你希望在 derived class 对象和 base class 对象之间进行相等性比 较,则 base class 的equals()一定不能使用 getClass()。因为如果你使 用getClass(),所有从 derived class 产生的对象便都不能视为与它们 的base class 对象相等。但如果你用的是 instanceof,则 cerived class 对象可被视为与其 base class 对象相等(译注:以就是说,instanceof 呈现出[是一种(Is-a)]语义,而非[绝对是]语义。所以[MyCar 是一 种Car]这句话成立,但[MyCar 就是 Car]这句话不成立)。 � 如果 class 没有实现 equals(),那么你可以假设他没有新增任何[与相 等性有关]的属性。因此你完全可以依赖base class 的equals()函 数(它 只比较 base class 所含属性)。 � 如果一个 derived class 实现了 equals(),我们可以假设它新增了某些 [与相等性有关]的属性。这些属性将会被拿来与这个 derived class 的 实体(instance)进行比较,或被拿来与[从这个 derived class 在派生 而得的 class 对象]进行比较,但无法与 superclass 对象进行比较。 举个例子,考虑一下 classes 的定义和声明: 这种情形克以下图表示: Base equals Base equals Derived d b Compare attributes of Base’ 由于 Base 采用 instanceof 实现 equals()而Derived 没有实现 equals(), 所 以 , 将Derived 对象与 Base 对象进行比较,就有可能返回 true,而且这种比较 是对称的(symmerecal)。例如: 如果 Base 中的 equals()采用 getClass()实现,则总是返回 false。针对b和d, getClass()的返回值并不相同。关于 getClass()详见实践 12。 如果 base class 和derived class 都实现了 equals(),情形又当如何?正如前面 讨论过的,你希望这种比较返回false,要知 道 ,derived class 实现出 equals(), 表示它具有 base class 并不包含的属性,考虑下面的例子: 代码 d.equals(b)总是得到 false,原因是Derived 的equals()包含了一下代码: 本例中的 obj 隶属型别 Base,而非隶属 Derived,于是导致 false 结果,这 正是我们想要看到的结果。有些程序库太过马虎,简化了[instanceof 检验], 在derived class 的equals()中仅调用 super.equals(obj),而让[instanceof 校验] 在base class 中完成。简化后的代码类似这样: 如果采用这种设计,先前所说的 d.equals(b)就有可能为 true,并就次失去了 [对称性](译注:亦即 d.equals(b)结果为 true,b.equals(b)结果却为 false)。 之所以会这样,是因为我们将一个Base对象传递给 derived class的equals(), 而由于[instanceof 校验]已被替换为一个 super.equals()调用动作于是代码调 用Base 的equals(),又因为 Base 对象的确是 Base 的实体,所以///1 测试结 果为 true。这样的代码如今竟[容忍]了不合法的比较。一定要记住,如果一 个derived class 实现出 equals(),当其对象与 base class 对象进行比较时,不 得返回 true。只要避免上述所说的简化形式,就可轻松消除这一类问题。 如果java.lang.Object 以外的 base class 具有equals(),你还是需要调用 super.equals()。这一点已在实践 13详细讨论过。你应该记住的重点是:仍 然写出[instanceof 检验],并与必要时调用 super.equals()。 当我们拿 derived class 对象与 base class 对象进行比较时,存在另一个问题: 如果两个 class 都采用 instanceof 实现出 equals(),就无法保持比较动作的[对 称性](译注:请见上页倒数第二段的译注)。就像上页所说情形,base class 和derived class 都使用 equals()那就意味它们都具有[攸关相等性比较]的属 性。你已经采用[instanceof 检验]来实现 equals(),确保 derived 对象和 base 对象之间的比较总是返回 false,像这样(译注:同 p54): 那么,执行下一行代码: 总是会返回 false。原因在于调用的是 derived class 的equals(),传递过去的 则是 Base 型别。Base 对象并非 Derived 实体,因此 instanceof 检查结果为 false。 然而,如果你进行以下比较,又将如何? 这一次调用的是 Base 的equals(),并传递给它一个 Derived 型别。由于 Derived 对象也是(译注:是一种,is-a)Base 实体,所以 instanceof 检查结 果为 true。因此,如果 b和d这两个对象的 base 成分(属性)相等,就将 得到以下结果: 这种行为并不直观,没有道理。你的 classes 当然不希望这样,然而对此问 题我们并无万全之策。要想允许 derived class 对象和 base class 对象进行比 较,你就不得不承受这个问题的潜在危险。你或许想你可以小心翼翼,并 为大部分 derived class 撰写测试码,进而避开这个问题。这种想法带有以下 一些问题: � 你必须记的撰写相关测试码。 � 如果你将 classes 提供给他人使用,他们也必须遵守这些规则。 � 你市场需要通过 base class references 访问对象(译注:这是多态的 表 现 )。 例如从一个 vector 取回一个 derived class 对象,得到的将是一个 java.lang.Object,它是个 object reference。如果不执行[会带来额外开 销]的检查动作,你就无法知道它到底隶属哪个 derived class。 于是,实现 equals()时你便有了两种可选方案。你可以在 equals()中采用 getClass(),在任何情况下保持[对称的相等性],像这样: 但需记住,当 derived classes 对象与 base classes 对象进行比较的时候,这 种做法总是会得到 false。另一个选择是,你可以在 equals()中使用 instanceof, 允许 derived classes 对象与他们的 base classes 对象(有机会)被视为相等。 如果你选择使用 instanceof,下面总结了三种[在derived classes 的equals() 中采用 instanceof 做法]的情形: 1. base class 实现了 equals(),而 derived class 没有。 假设 base class 的equals()使用 instanceof,那么你可以将 derived class 对 象与base class 对象进行比较,这种比较是对称的(译注:亦即如果 b 相等于 d,则 d必然相等于 b)。 2. base class 和derived class 都实现了 equals()。 如果两个 classes 都在 equals()中使用 instanceof,当你将 derived class 对 象和 base class 对象进行比较时,你希望返回 false。由于 Base 对象并非 Derived 实体,因此调用 derived class equals()时的确会返回 false。然而 当调用 base class equals()时,返回的确是 true。究竟哪一个 equals()被调 用,取决于 equals()调用句中的 object reference 的位置。(译注:让我多 做一点说明,假设b是base class对 象 ,d是derived class 对 象 ,d.equals(b) 和b.equals(d)分别调用的是 derived class 和base class 的equals(),只是 因为 b和d位置不同而造成的。) 3. base class 并未实现 equals(),但 derived class 实现了它。 由于 Base 对象并非 Derived 实体,因此调用derived class equals()会返回 false。调用 base class equals()也会返回 false,但两者原因并不相同。由 于base class并未实现equals(),所以调用的是 java.lang.Object的equals(), 他所比较的是两个 object referencees 相等与否(参见实践 10)。 真是不幸,[base 对象与 derived 对象之间的比较]竟给 equals()的实现方式增 添了如此的复杂度,并引入了一些你或许难以接受的问题。 如果快速浏览 Java 标准程序库源码,你会发现各个 classes 的equals()函数 实现代码采用 instanceof 的情况很普遍,但也有些采用 getClass()。Java 标 准程序库的 classes 并未采取一致方式来实现它们的 equals(),此又恰恰[相 等性比较]的一致化增添了难度。 这又引发[在equals()中使用 instanceof]的另一个问题。如果你正使用某个程 序库,例如 Java 标准程序库或某个第三方程序库(third-party library),那 么你就有必要知道你所使用的 classes 如何实现其 equals():,他们用的是 getClass()还是 instanceof?如果用的是 instanceof,那么他们是否在 derived class 中不恰当地调用了 super.equals ()?它们是否视 base class 的实现决定要 不要进行[instanceof 检验]? 如果无法检验你所使用的 classes 的源码,则必须撰写测试程序,以便确定 那些 equals()如何被实现。知道其实现手法,可以对你以及你的客户发出预 警,让他们知道可能遇上的潜在问题。 此外,如果你正在为某个derived class 实现 equals(),而其base class 已经有 了一个运用 instanceof 完成的 equals(),你应当如何实现这个 derived class 的 equals()呢?最好的方法是运用 getClass()。 通过运用 getClass(),你就可以确信,只有你的各个 derived class 对象才可 被视为相等。你仍然需要调用super.equals()来比较 base class 的属性,但base class 中的[instanceof 检验]并不会导致这些比较结果呈现 false。由于 base class 使用了 instanceof,这个 class 仍会呈现[不对称的相等性]。例如: 真实世界中确实存在一些合情合理的考量,希望程序能够支持base class 对 象与 derived class 对象之间的相等性比较。然而考虑到使用 instanceof 所引 发的诸多问题,我还是推荐在 equals()实现代码中使用 getClass()(参见实 践12)。这样做就只允许同一个class 所属的对象可被视为相等,进而消除 了instanceof 做法所带来的一切后续问题。 实践15151515: 实现equalsequalsequalsequals ()()()() 时需要遵循某些规则 前面探讨过的 4个实践内容显示, 撰写一个 equals()并不像你想象的那么 直接而易懂。只有理解各种可能问题,你的 equals () 实现代码才能产生正 确结果。 无论你选择使用 getClass()或instanceof 来实现 equals(),下面的规则适用于 所有的 equals()函数: � 如果某个 class 的两个对象即使占据不同的内存空间, 也可被视为「逻 辑上相等」的话,那么你得为这个 class 提供一个 equals()。 � 请检查是否等于 this(参见实践 12 )。 � 比较这个 class 中的相关属性(值域,fields),以判断两个对象是否相等 (参见实践 12)。 � 如果有 java.lang.Object 以外的任何 base class 实现了 equals(), 那么就 应该调用 super.equals()(参见实践 13)。 在equals()函数中面对 getClass()和instanceof 进行取舍时,你要仔细斟酌以 下问题: � 如果只允许同一个 class 所产生的对象被视为相等,则通常使用 getClass()(参见实践 12)。 � 只有在不得不「对 derived classes 对象与 base classes 对象进行比较」的 场合中,才使用 instanceof(参见实践 14),而且你应该明白这样做带来 的可能问题和复杂性。 � 如果使用 instanseof,而且 derived class 和base class 都实现有 equals(), 则一定要知道,这种比较不会展现出所谓的「对称相等性」(symmetric equality)。 3333 异常处理 实践16161616:认识「异常控制流」(exceptionexceptionexceptionexception controlcontrolcontrolcontrol flowflowflowflow)机制 � 欲撰写能够正确处理错误状态的强固软件,你必须深刻理解其中涉及的 机制。异常(exception)之所以难以应付,一个因素是:它们的行为类 似goto 语句。Java 并没有提供 goto 语句(译注:其实是保留了这个关 键词并束之高阁),但异常处理采用了类似技术。一旦某个异常诞生, 控制流(control)立刻转移到下面三者之一: � catch block(捕获区段) � finally block(终结区段) � calling method(调用端) 这就是异常所表现出的 goto 行为。由于某些原因,搞清楚这一点很重要。 最重要的原因将在实践 27讨论,其中探讨了 object state(对象状态)的关 键性。另一个原因是你的代码可能从某个地点一下子跳转到另一个地点, 这可能导致复杂的逻辑(它们将视代码结构而有不同的影响程度),以至于 代码变得难以调试(参见实践 18 )。考虑以下代码: � 这段代码的输出是: � Entering main() � Calling m1() � Entering m1() � Calling m2() � Entering m2() � In finally for m2() � Exiting m2() � Returning form call to m2() � Calling m3() � Entering m3() � In finally for m3() � Caught IOException inm1()...rethrow � In finally for m1() � Caught IOException in main() � Enterint main() 这里展现了代码的详细执行轨迹。 一旦执行这段代码,首先打印一条消息 , 然后进入 try 区段,再打印一条消息,然后调用 m1()。进入 m1()之后,打 印一条消息,再进入 try 区段打印另一条消息,然后调用 m2()。 进入 m2()之后,先打印一条消息,然后进入 try 区段,其中并未抛出异常。 于是跳过 catch 区段,控制流转移到 m2()的finally 区段。在那里打印一条 消息,并在函数退出(exit)之前打印另一条消息。m2()返回 m1()内的调用 点,又打印出两条消息,然后调用 m3()。 进入 m3()之后,打印一条消息后进入 try 区段,其中抛出一个异常。离开 这个函数之前会先执行 m3()的finally 区段,打印一条消息。而后控制流转 移到身为调用端的 m1()的catch 区段中。这个 catch 区段先打印一条消息, 并再次抛出异常。在即将离开 m1()之前,先执行 finally 区段,打印一条消 息,然后返回至调用端 main()。 此时进入 main()的catch 区段,打印一条消息。由于异常在此处获得了处理 , 未被再次抛出,所以这个 catch 区段结束后,控制流由此继续向前,并在 main()末尾处打印一条消息。而后程序结束。 这段执行轨迹显示,一旦程序抛出异常,相应的代码便立即停止执行,控 制流转移到他处。请注意,本例的 //1、// 2、//4、//5、//6并没有 出现在输出中。 由于程序在 //1和 //2之前产生了异常,控制流直接转移到函数的 catch 区段,因此这两处的代码没来得及执行。 //4和 //6之所以未被执行,原 因是此前抛出的异常没有被捕获(译注: //4之前的异常虽然被捕获了, 但再次被抛出,相当于没有捕获),因此在执行完 finally 区段后控制流立刻 跳离函数。(如果 //3没有再次抛出异常,//4就会被执行)。//5也没有执 行,因为 //5之前的 try 区段并没有抛出任何异常,所以不可能调用 //5 所在的那个 catch 区段。 这说明了当程序在 try 区段内抛出异常时将会发生的事情: � 如果同时存在 catch 区段和 finally 区段,则控制流会先转移到 catch 区段,然后再跳转到 finally 区段。 � 如果没有 catch 区段,控制流便转移到 finally 区段。 由于 Java 支持垃圾回收机制(garbage collection),所以你不必为清理对 象内存而费心。这也适用于前例中建立的Button 和Vector 对象。如果函数 正常结束,或因异常而被迫退出(exit),该函数创建的所有对象都会自动 被解除引用(unreferenced)。因此你不需要手工对这些 object references 解 除引用(参见实践 7)。然而你必须明确 non-memory 资源(参见实践 21和 实践 67)。 异常处理的另一个概念是:你不能心安理得地对异常视而不见。这也许是 好消息,也许是坏消息,取决于看待问题的态度。如果你认为忽视异常是 好主意,或如果你不清楚这样做的后果如何,请看实践 17。 理解「异常处理的控制流机制」(the mechanics of exception handing control flow),对撰写强固(robust)的程序而言是绝对必要的。如果你对异常发 生时导致的事情懵懵懂懂,就会导致程序行为错误,而且难以扩展和维护。 实践17171717:绝对不可轻忽异常(Never(Never(Never(Never ignoreignoreignoreignore anananan exception)exception)exception)exception) 当你在异常(exception)诞生时不去捕捉(catch)它,会发生什么事呢? 在Java 中,如果异常产生了却未被捕获,发生异常的那个线程(thread) 将因而中断。所以你必须对代码产生的异常有所作为。 当Java 程序产生异常,你能做些什么呢? 1. 捕捉并处理它,防止它进一步传播(propagate)。 2. 捕捉并再次抛出它,这么一来它会被传播给调用端。 3. 捕捉它,然后抛出一个新异常给调用端。 4. 不捕捉这个异常,听任它传播给调用端。 对于 checked(可控式)异常,上述选项 2、3、4要求你将异常添加到函 数的 throws(抛掷)子句(见实践19和实践20)。选项 1则遏止异常的进 一步传播。在使用选项 3时,必须确保新抛出的异常包含原异常的相关信 息,这样才可以保证不丢失重要信息(参见实践18)。 常见的情形是,尽管采取了选项 1的做法,却一种草率的方式对异常听任 不管。 例如,代码虽然捕获异常,却对它什么事情都不做,像下面这样: 这段代码不理会异常──当异常发生时,只有捕获它而没有再次抛出。请 注意,异常没有得到处理,因此 catch 区段之后的代码将接下去执行,好 像什么都没有发生过似的。也就是说,这段代码没有对异常做任何处置, 只是将它给「吞」了。吞噬异常会比忽略返回码(return code)引起更多 麻烦,因为你不得不采用 try/catch 区段将「可能抛出异常」的代码包裹 起来。然而,某种情形下,你的确很可能刻意忽略异常,实践54展示了这 种情形。 如果你像本例这样撰写代码,或者和这样的人共事,请一定要意识到,这 样做是不明智的,因为这将没有留下「异常曾经发生过」的任何记录。于 是你的程序尝试继续执行,就像什么都没有发生似的。这样做的危险在于, 你的程序可能于稍后失败,出现更难调试的情形。如果你在开发初期不知 道如何应付异常,至少要像下面这样做: 这种做法至少提供了一些输出,以及一份日志文件(log file),用来记 录程序曾经发生过异常,也让你知道程序存在一些问题。这种技术也可以 用来提醒你,异常已经出现而且暂时没有适当的回复机制。日后你可以依 据日志文件内的记录找到相应代码,插入合适的异常处理句。注意,你必 须提供 LogException(), 它只需将异常信息写入文件就行了。 这种做法的缺点是:日志文件仅仅包括测试时发生的异常,无法记录未发 生的异常。所以当某次测试过后,即使对日志文件的所有内容都处理完毕, 也并不就意味你已经完全清理好了你的程序。日志文件仅仅只用来显示你 在测试过程中获得的异常而已。 如果想要发现测试过程中未曾发生的异常,则必须查找整个程序源码, LodException()或其他作为注释的标记(tag)。这是确保你不需要使用简 陋的 println( )语句的惟一办法;那个语句所在之处应该代之一适当的异 常处理代码。 另一个有效做法是使用 printStaticTrace( )。这个函数提供被抛异常的 消息,以及该异常的起源(以 stack trace 方式呈现),并将它们输出到 标准错误串流(standard error stream。译注:通常是屏幕)。以此做法 修改代码,获得的结果是: 在使用这项技术时,别忘了将标准错误串流输出设备(standard error stream output)复位位(redirect)至某个文件。如此一来,该文件就可 以用来分析是否发生异常。在 Windows NT 和UNIX 环境中,下列命令可以 将「标准错误输出设备」和「标准输出设备」复位位至同一个文件中: 当然,最好的做法还是不要推诿异常处理和错误处理,尽量就地解决它们。 实践18181818:千万不要遮掩异常(NeverNeverNeverNever hidehidehidehide anananan exceptionexceptionexceptionexception) 在处理先前抛出之异常时,如果 catch 区段或 finally 区段又抛出异常, 某些异常会因此被遮掩,只剩最后生成的那个异常才会传播给调用端。如 果你恰好只对「有个函数失败了,起因是一个或多个异常」这件事感兴趣, 那么你或许不在乎是否遮掩先前抛出的异常。但如果你想知道造成函数失 败的「罪魁祸首」,则最好不要遮掩异常。 常常,在 catch 区段或 finally 区段执行过程中,你还会调用一些有可能 抛出异常的函数。要想不遮掩先前抛出的异常,该怎样做呢?喔,你需要 一种机制,将所有「由某个函数生成的异常」通通传给调用端。下面是关 于这个问题的一个示例: 这段代码一旦执行,回产生如下结果: main()之内调用了 foo(),然后打印 foo()抛出的异常,输出结果显示,这 个从 foo()抛出的异常就是 //3 的finally 区段生成的那个。那么//1 的 try 区段和 //2 的catch 区段产生的异常哪儿去了? 答案是://1 抛出的异常被 //2 抛出的异常掩盖了, //2 抛出的异常则被 //3 抛出的异常掩盖了。由于 //3 抛出的异常是这个函数以发生时间而言 最后抛出的异常,所以它就成为这个函数最终生成的异常,其他异常都被 掩盖了(丢弃了)。 你或许认为,这种情形在常规的 Java 代码中不会发生。事实上它很普遍。 以下例子证明这一点: 这个例子有个 readFile(),用来从文件读取数据。它可能抛出两种异常: 一种是 FileNotFoundException,可能在 //1 和 //3 发出,另一种是 IOException,可能在 //2、//4、//5 和 //6 产生。这个函数也拥有一 个finally 区段,负责在函数退出时关闭 BufferedReader 对象。这些调用 动作安置于 finally 区段中,从而确保他们一定回被执行(参见实践16和 实践21)。然而 finally 区段中的 close()也可能产生 IOException,这就 是问题的根源。 这段代码在某种情形下会掩盖异常。当//3 产生了一个 FileNotFoundException 时,控制流(control flow)转移到 finally 区 段,其中负责关闭 br1。如果关闭 br1 的动作导致 IOException,会发生什 么事?果真如此,返回给调用端的将会是 IOException 而不是 FileNotFoundException。调用端将无法知道其所调用的函数的最初失败的 原因是「未能找到某个文件」(FileNotFoundException)。 如果发生异常,控制流(control flow)转移到某个 catch 区段或 finally 区段,而那里又产生了新异常,我们该怎么办?记住,任何地方都有可能 产生异常,而且这些异常在 catch 区段和 finally 区段中仍旧有活性。此 外,try/catch/finally 区段之间可以相互任意嵌套(nest)。 对此问题的一个解决方法是,保存一个 list,用以包含所有异常。然后抛 出一个异常,其中持有一个 reference 指向上述 list。采用这个办法,新 异常的接受者就拥有了异常的全部信息,并且不会丢失关键错误信息。 先前的代码修改如下,包含一个新 class 用来容纳 readFile()产生的所有 异常。readFile()将它产生的所有异常加入到一个 Vector 中,并在 ReadFileExceptions 对象中保存一个 reference 指向该 Vector。修改后的 代码是: 在这段代码中,readFile()建立了一个 Vector,用以保存异常。函数所产 生的任何异常都会被加入这个 Vector 中。函数退出之前会检查这个Vector 是否有什么东西被加进来了。如果有,就建立了一个 ReadFileException 对象,并将那个「用以保存函数所生异常」的 Vector 一并传递给它。另一 种做法是,你可以只在发生异常的时候才建立 Vector,这可以避免由于「建 立Vector 却未使用」而造成的无谓开销。「建立对象」所带来的开销和「未 用上的对象」(unused objects)所花费的代价,在实践32和实践33中分 别有详细讨论。 如果对这个问题考虑不周,就会带来一些负面后果,主要是丢失原始异常 (original exception),而且处理「最终被抛出之异常」的代码对此前抛 出的所有异常毫不知情。如果代码试图根据异常进行回复,却对发生过的 其他异常一无所知,则是极为不利的。 这段代码显示,try、catch 和finally 区段可以彼此任意嵌套(nested)。 此外,尽管从它们之中抛出了多个异常,但只有一个异常可被传播到外界。 记住,最后被抛出的异常是惟一被调用端接受到的异常,其他异常都被掩 盖而后遗失了。如果调用端需要知道造成失败的初始原因,程序之中就绝 不能掩盖任何异常。 实践19191919:明察 throws throws throws throws 子句的缺点 throws(抛掷)子句是一种语言机制,用来列出「可从某个函数传至外界」 的所有可能异常。编译器强迫你必须在函数中捕捉这些被列出的异常,否 则就得在该函数的 throws 子句中声明它们。throws 子句用来向函数调用 者发出预警,告知将会产生哪些异常。这是一项非常有用的语言特性,为 函数用户提供非常有价值的信息(参见实践20)。然而这个特性也有副作 用,你必须心理有数。 当你对一个大项目的编码成果感到满意时,如果又给一个低级的所谓「工 蜂型函数」(译注:原文为 worker method,取其义)增加一个可能抛出的 异常,情况会怎样?为方便讨论,这里所谓「工蜂型函数」(worker nethod) 指的是那些为其他众多函数服务的函数,它们完成共通性任务,在代码的 许多地方被调用。典型的系统拥有许多这样的函数。如果上述情形发生, 你面临两种选择: 1. 在「工蜂型函数」中捕获异常,并且就地处理。 2. 从「工蜂型函数」抛出异常,让调用者处理它。 由于「工蜂型函数」或许没有能力独立处理这个异常,上述第一选项可能 行不通(当然啦,一切取决于系统的设计)。除了采用第二选项,抛出这个 异常,你别无选择。但这或许不像看起来那么容易。 将一个异常加入函数的 throws 子句,会影响该函数的每一个调用者──所 有调用这个「工蜂型函数」的函数统统需要修改。他们将面临相同的两个 选择,一如上页所列。如果它们决定处理异常,异常就不会进一步传播。 然而如果其中某些函数无法处理异常,该怎么办?它们必须指望第二条出 路,也就是在他们的 throws 子句中增加这个异常。做完这些修改后,重新 编译代码。当编译器发出错误消息时你或许明白你将又一次面临窘境。现 在,所有「调用了你刚刚修改过的那个函数」的函数(s),又得在相同的两 个选项(一如上页所列)之间做出抉择。如果一直没有某个函数挺身而出 处理该异常,这个过程会一路延续,直到上溯至 main()才终了。 如果你希望尽量降低遇到这个问题的机率,就请不要在开发周期的最后才 添加异常的处理。请在一开始就设计好你的错误处理策略。当你在精心计 划后仍然遇到这种情形时,你就会明白,throws 子句是一种使用而有益的 语言特性,但如果你不够细心,它会令你头疼不已。 实践20202020:细致而全面地理解 throws throws throws throws 子句 提供 throws 子句的用意在于,提醒函数调用者,告知可能发生的异常。明 白了这一点之后,让我们考虑以下代码,其中声明了三种异常,以及一个 「可能抛出所有这三种异常」的函数: Foo()声明了一个 throws 子句,其中只列出了一种异常型别。可是这个函 数却抛出了三个不同的异常。这段代码能顺利通过编译吗?我们可以这么 写吗? 这段代码完全合法,可顺利通过编译,不会发出任何警告。不过,以这种 方式编译代码带有一些缺点。Foo()抛出的三个异常其实都隶属于型别 Exception1。Exception2 和Exception3 派生自 Exception1。由于符合 throws 子句的要求,才得以顺利通过编译。这个 throws 子句向调用者发 出预警说:函数可能产生 Exception1 型别的异常。延续此例,为 foo()撰 写throws 子句的更好写法是像下面这样: 这个 throws 子句明确列出了函数可能产生的所有异常。回头看看原先的 foo(): 上述throws 子句通知 foo()调用者说:函数可能产生的异常隶属于 Exception1 型别。这当然不能够算错,但事实上这个函数还产生了 Exception2 和Exception3 两种型别的异常。这个函数的调用者仅仅知道 有Exception1 这一回事。调用这如果想进一步了解这个函数所产生的派生 异常(derived exception),惟一办法就是查看源码。这是不切实际的, 通常无法办到。于是,调用 foo()的代码往往看起来是这样: 当函数抛出异常,导致上述的 catch 区段起而执行的时候,你或许需要处 理Exception1、Exception2 或Exception3 异常。如果你能取得源码,则 可以通过 instanceof 检查被抛出的真正型别。然而,使用 instanceof 笨 拙累赘且麻烦。而且就算你能够拿到源码,你知道如何检查吗?如果得不 到源码,你就只能处理「距离最近的」派生异常(least derived exception class),并祈祷他封装了「实际的」派生异常的相关信息。(译注:所谓「距 离最近的」派生异常,系指最先派生出来的类,例如上例的 Exception1 就 是可通过 throws 子句的最上层型别。) 当你给出 throws 子句的时候,要填写的完整无缺。虽然编译器不强求这样 , 但将函数可能抛出的所有异常统统列出是良好的编程习惯。不要对「这些 异常都派生自另一个异常」的事实恋恋不舍,过于詈碍,因为出现这种情 形往往意味它们代表了大相径庭的错误条件.throws 子句即使再罗嗦一点, 也只不过是要你多打几个字.却可以节省函数调用者的时间,还可以将满头 大汗的他从「社土精确推断这个函数可能产生哪种异常」的窘境中解脱出 来。 关于throws 子句的所有这些讨论,带出了一个有趣的问题。在覆写 (override)某个函数时,你可以从覆写函数中抛出哪些异常呢?哪些异常 应该被放进函数的 throws 子句呢?假设你有这样的代码: 编译它将得到下面结果: 这个错误显示,如果某函数抛出了一个异常,而该函数又覆写了其 superclass 函数,那么它必须受到 superclass 函数所抛异常的型别的约 束。覆写函数所抛出的异常,或者与 superclass 的对应函数具有相同型 别,或者是这些型别的特化(specializations;译注:即派生型别)。因此 对于 class OverrideTest 的foo(),你可以: � 不抛出任何异常。 � 抛出 FileNotFoundException。 � 抛出 FileNotFoundException 的派生异常(类)。 如此一来,你就被限制在被覆写的那个函数的「活动范围」内了。举个例 子,如果你的覆写对象(某个 superclass 函数)没有抛出任何异常,但你 的覆写函数因为增加了代码而可能引发异常,那么你必须在新函数中捕捉 异常并就地处理。你不能将新函数所引发的异常传播给外界。 实践 21:使用 finally 避免资源泄露(resource leaks) Java 异常处理模型与其语言相比,关键词 finally 是最出色的新增特性 了。finally 构件使得某些代码总是得以执行,而无论是否发生异常(参 见实践 16)。在维护对象内部状态和清理 non-memory 资源方面,finally 尤其适用。如果没有 finally,代码会变得错综复杂,纠缠不清。例如, 下面的代码就是在没有 finally 的帮助下欲释放 non-memory 资源而迫不 得已采用的写法: 这段代码建立了一个 socket ,并调用其 accept()。为了避免资源泄露,必 须在退出这个函数前关闭 socket。于是你在函数的最后一条语句调用 close()。然而万一 try 区段发生异常情况又会怎么样呢?在这种情况下, 程序流程不会去执行 close(),我们必须捕捉异常,并在再次抛出异常之 前放置 close()的第二个调用句,这便可以确保在退出函数之前关闭 socket. 用这种方式编码不仅麻烦而且还容易出错,但如果没有 finally 机制的支 持你也别无选择。不幸的是,在缺乏 finally 机制的语言中,程序员也许 会忘记以上述方式组织代码,以致资源泄漏。java 的finally 子句解决了 这个问题,现在该代码可以重写如下: 无论 try 区段是否抛出异常,finally 区段都能保证 close()被执行。于是 你可以相信资源会被关闭不会有被泄漏之虞,上诉函数不再需要 catch 区 段。上一页的第一个例子的 catch 区段主要用来关闭 socket,现在又 finally 就足够了。如果你提供了一个 catch 区段,finally 区段的代码会 在catch 区段执行后被执行。finally 区段必须要配合 try 区段或 try/catch 区段来使用,只要 finally 区段存在它就一定被执行,绝不可 能在未执行 finally 区段的情况下就退出 try 区段。 实践22222222:不要从 try try try try 区段中返回 try/finally 区段的执行机制十分简单明了,然而一些不恰当的方式会骗 过一些程序员老手的眼睛。是的,只要 finally 区段存在,他就一定会被 执行;try 区段代码一离开 try 区段就会进入 finally 区段,造成[代码离 开try 区段]的情况包括: � 抛出异常 � Try 区段正常结束 � 在try 区段执行了 return,break,或continue 语句,从而引发执行 权(execution)离开 try 区段。 现在请你考虑下面的代码,看看他所引发的后果: 执行上面代码会有两种结果一个显而易见,一个则未必。这段代码调用两 个函数,method1() 和method2() ,然后打印出他们的返回值。调用 method1()得到结果为 2,并打印出来。由于 method1()并未抛出异常,所 以其 catch 区段永远不会执行。调用 method2()得到 4。输出结果为: method1 returns 2 method2 returns 4 之所以如此,原因在于:无论 try 区段发生了什么事情,finally 区段都 要被执行。本例原本要在 method2()返回 3,但在执行 return 句之后,控 制权转移到了 finally 区段,引发 return 4,因此 method2()最终返回整 数4。 程序员传统上总是以为,当他们执行 return 语句的时候,会立刻离开执 行中的函数,但在 java 语言中,一旦 finally 出现,这种观点便不再是金 科 玉 律 。try 区段中的 break 或continue 语句也可能是控制权进入 finally 区段。finally 的这种独特性质有可能令人困惑,陷入漫长的调试困境而 无法自拔。 为了绕离这个陷阱,请不要在try 语段中发出对 return,break 或continue 语句的呼唤。万一无法避免,一定要确保 finally 的出现不会改变函数的 返回值。这类问题最容易出现在代码维护时期,即使仔细的设计和实现也 不可能完全避免。优秀的注释和细心的代码复审(code reviews)可以使你 避开这类问题。 实践 23:将 try/catch 区段置于循环之外 异常(exceptions)可能对代码性能(performance)产生负面影响。至于是否真的产生这种影响,与你的 代码组织大有关系,也与 JVM 是否在运行期使用 JIT 编译器进行代码优化有关。关于异常和性能, 我们需要从以下两个方面加以考虑: � 异常被抛出后所带来的影响。 � 代码中 try/catch 区段的影响。 抛出异常并不是一种无需代价的动作。 [Java 异常]说到底是个什么东西呢?它们是对象,而对象需 要被创建。创建对象所花费的成本并不使宜(参见实践 32),于是抛出异常也就需要一些代价。欲抛 出一个异常,你得撰写这样的语句: throw new MyException(); 这便创建了一个新对象,然后控制权转移到 catch 区段或 finally 区段,甚至转移到调用端。由于抛 出异常需要承担一些代价,所以你应当只对出错情况使用异常。如果特异常用于流程控制,代码无 法像常规流程结构那样高效和清晰(参见实践 24)。请将异常的使用限制在[出错]场合和[失败]场 合 。 是的,你会希望代码运行时候步如飞,但你通常不在意它失败时要花掉多长时间。 当我们评量 Java 性能的时候,必须将 JVM 及其所在的操作系统一并考虑进去。人们已经注意到, 即使执行同一份代码,不同的 JVMs 之间的执行时间也会有所不同。因此你得对你的系统做某种程 度的评测(profiling),判定性能上的差异。实践 23 所产生的性能数据,是在本书 p98 详细描述之硬件 和软件环境下得出的。 将try /catch 区段放在循环之内,会减慢代码的执行速度,以下代码可以证明这一点: Method1()和method2()内含几乎一样的代码。methodl()在循环之外有个 try/catch 区段,methed2() 则将 try/catch 区段置于循环内。请注意,两个函数都没有抛出异常。它们只是使用。Try/catch 区 段特一些代码包围起来。 在[启用 JTM 编译器]的情形下执行上述代码,两个函数的执行时间并没有什么不同。可是一旦你将 JVM 的JIT 关闭再执行之,method2()大约比 methodl()慢21%。这或许会令你以为,两个函数产生 的bytecode 一定大不相同。事实上,除了 method2 内含少量[用于循环内部所含之 try/catch 区段] 的操作码(opcodes)外,二者几乎完全相同。当 JVM 编译器启动,代码被优化,消除了二者运行期的 差异。然而如果没有 JTM,每次循环迭代(iteration)都得花费一些额外代价。下面就是两个函数历产 生的 bytecode: Method void method1(int) 0 iload_1 //Push the value stored at index l of the //1ocal variable table(size) on the stack. 1 newarray int //Pop the size parameter and create a new //int array with size elements. Push the //newly created array reference(ia). 3 astore_2 //Pop the array reference(ia) and store it //at index 2 of the local variable table. 4 iconst_0 //Beginning of try block. Push 0 for the //initial value of the loop counter(i). 5 istore_3 //Pop 0(i) and store it at index 3 of the //local variable table. 6 goto 16 //Jump to location 16. 9 aload_2 //Push the object reference(ia) at index 2 //of the 1ocal varable table. 10 iload_3 //Push the value at index 3(i). 11 iload_3 // Push the value at index 3(i). 12 iastore //Pop the top three values. Store the value, //of i at index i in the array(ia). 13 iinc 3 1 //Increment the loop counter(i) stored at //index 3 of the local variable table by 1. 16 iload_3 //Push the value at index 3(i). 17 iload_1 //Push the value at index 1(size). 18 if_icmplt 9 //Pop both the looD counter[L) and B 2ze. //jump to location 9 if i is less than size 21 goto 25 //end of try block. Jump to location 25. 24 pop //Beginning of catch block. 25 return //Return from method. Exception table: // If a java.lang.Exception occurs between From to target type //location 4(inclusive) and location 4 21 24 // 21(exclusive) jump to location 24. Method void method2(int) 0 iload_1 //Push the value stored at index 1 of the //local variable table (size) on the stack 1 newarray int //Pop the size parameter and create a new //int array with size elements . Push the //newly created array reference (ia). 3 actore_2 //Pop the array reference (ia) and store it //at index 2 of the local variable table. 4 iconst_0 //Push 0 for the initial value of the loop //counter(i) . 5 istore_3 //Pop 0(i) and store it at index 3 of the //local variable table . 6 goto 23 //Jump to location 23 . 9 aload_2 //Beginning of try block . Push the object //reference(ia) at index 2 . 10 iload_3 //Push the value at index 3(i) . 11 iload_3 //Push the value at index 3(i) . 12 iastore //Pop the top three values . Store the value //of I at index I in the array (ia) . 13 goto 20 //End of try block . Jump to location 20 . 16 pop //Beginning of catch block . 17 goto 20 //Jump to location 20 . 20 iinc 31 //Increment the loop counter (i) stored at //index 3 of the local variable table by 1 . 23 iload_3 //Push the value at index 3 (i) . 24 iload_1 //Push the value at index 3 (size) . 25 if_icmplt 9 //Pop both the loop counter (i) and size . //Jump to location 9 if I is less than size . 28 return //Return from method . Exception table: //If a java.lang.Exception occurs between from to target type //location 9(inclusive) and location 9 13 16 //13(exclusive) jump to location 16. 箭头显示两个函数的循环结构。基于这些差异,良好的编程习惯是将 try/catch 区段置于循环之外。 你的客户或许使用无 JIT 能力的 JVM,或是由于内存的考虑而在运行期将 JIT 关闭,因此你不可以 假设循环内的 try/catch 区段不会对程序性能产生负面影响。 如果你想在不启动 JIT 的情况下执行名为 Test.class 的文件,请下这个命令 Java - Djava.compiler=NONE Test 这会使得在执行程序 Test 时,JVM 的JIT 能力被抑制。 实践 24:不要将异常用于流程控制 使用异常来控制流程是个糟糕的注意。运用java 标准语言构件表达流程会更加清晰。考虑下面代码 : 这段代码用异常(exception handling)来控制流程。他没有使用正常的循环终止条件,而是使用异常来 中断循环。这个代码可以运作,但性能低下,含义模糊,而且难以维护。如果你看到这样的代码, 请当机立断重新写过。请记住,[异常处理](exception handling)只用于异常情况,不能把它拿来流程 控制,贯穿你的程序。 实践 25:不要每逢出错就使用异常(exceptions) 异常处理机制(exception handling)的设计初衷,是作为传统的错误处理技术(error handling techmiques) 的一份更强固的替代品。于是有些程序员认为,异常处理应当用在所有的出错环境当中,并且因该 极力的避免使用传统的错误处理程序。 人们可能会滥用异常处理。事实上它不应被用于流程控制(参见初中 24),或是被揭发[非错误]情况。 [异常处理]应当和[传统错误处理技术]两相配合,共同建立高效、易于理解的代码。 下面是两种风格的对比,第一种使用传统错误处理技术,第二种对所有错误情形都使用异常。首先 使用传统的返回码(return code)进行错误检查: 注意,//1 检测 getDate()返回值是否不为 0。如果是 0,就可以假设你位于 stream 尾部,于是便可中 断循环。这样做不但有效而且直观。接下来,改用异常处理的眼力姿态修改这个代码。这时候不再 像前面那样依赖返回值,而是改动异常。以下代码在原先的基础上增加了异常处理: 注 意 ,//1 的try/catch 区段将内含 getData()的循环包围起来。这一次 getData()于stream 为空的时候 不 在返回 0,而是抛出 NoMoreDataException。尽管这些代码比原先的看起来丑陋些,有些程序员还 是认为代码就应该这样写。他们主张,如果你能使用异常,就不该再使用传统的错误处理技术。 但是请你注意,未使用异常的那一段代码,尽管用的是旧式方法来处理[错误]或 [预期外的结果], 却更直观浅白。异常可能被滥用,就像上述第二个例子那样。 请你只在面对[出乎程序可预料]的行为时,才使用异常。先前的例子中,你已经预料到会到达 stream 尾部,因此 getData()返回一个简单的 0是合适面且自然的。在此处抛出异常并不明智,因为这不是 一个异常(exceptional)情形,而是可以预料到的。然而你不会预料到 stream 被破坏,在那种情况下你 就必须产生一个异常。重点是,不要针对所有情形使用异常,应该特异常用于符合其意义的地方, 也就是出现了[非寻常情况]的地方。 一味地、独占性地使用异常(就像上述第二个例子那样),或许会使你的程序在处理异常方面显得[纯 洁],但却违背了[不要每逢出错就使用异常]的原则。 [简单地返回一个 0]比起[生一个异常对象, 并要求调用者实现 catch 区段以便处理那个异常]更快捷,更直观。 实践 26:在构造函数(constructors)中抛出异常 由于构造函数中没有返回值,要想从构造函数获得传统的错误报告(error reporting)很成问题。一旦 构造函数失败,你便无法返回一个错误码。的确,构造函数不是函数,但这并能阻止你在构造函数 中抛出异常。 要想从构造函数中得到错误报告,有数种技术可以办到。技术之一就是双阶段建构(two-stage construction),将有可能产生错误的代码移出构造函数,各自组成函数,这些函数可以返回错误码。 这个技术的缺点是 class 用户必须先调用构造函数,然后调用那些可能错误的代码,最后在检查返回 码或捕捉异常。 双阶构的另一种形式,是对[待建之物]使用一个内部标号(internal flag)。这个标记代表此对象被建构 后的有效性。如果构造函数毫无差错的顺利完成任务,他就在对象内置一个标记,表示这个对象一 切就绪,可以安心使用。如果一个函数因为某种原因失败,就设置标记表示这个对象处于无效状态 , 不能被安全的使用。 你可以采用以下方法的任意一种来使用这个内部标号(internal flag),第一种方法是,对象用户在调 用对象的任何函数之前,必须调用其 class 的某个函数,检查此一标记是否有效。第二种方法是,对 象的每个函数一开始都先调用上诉函数以确认对象的有效性。如果对象处于无效状态,就返回一个 错误或抛出一个异常。这么做可以确保使用者不会对着一个无效的对象执行函数。 然而这些技术缺少所谓的强固性(健壮性,robustness)。这些技术要求 class 的使用者不得忘记执行上 述的[双阶段建构],不得忘记调用某个特别函数来测试对象的有效性——每个函数都必须先调用该 特别函数,于是因为[检查内部状态标记]而带来了额外的开销(overhead)。 通过[在构造函数中抛出异常],你可以避免这些所有问题。考虑以下代码 class Foo 构造函数接收到了一个 String 参数,这个 String 代表一个文件名。然后它尝试打开这个文 件。如果名称无效,构造函数就会引发一个 FileNotFoundException 异常。如果名称有效,构造函数 便试图读取它。但读取这个文件另有可能旨发一个 IOException 异常。由于这个构造函数不处理这 些异常,他得在 throws 子句中列出它们(参见实践 9和实践 20)。 在main()函数中,try/catch 区段将 Foo 构造函数包围起来,以便处理任何可能的失败。这个方法 具有一个额外的好处:倘若构造函数抛出异常,并且调用端忽略处理这个异常,局部变量 somefoo 就会被设置成 null。这么安排是因为他既没有得到适当的建构,也就不可能被安全运用。如果这种 情况你试图访问变量 somefoo,将会导致 java 运行期产生一个 NullPointerException 异常。 尽管构造函数不是一般的函数,但他们仍然可以引发异常,并支持 throws 语句。以这方式来构建失 败,是最强固、最高效的选择,从 class 用户角度来看他要求的手工干预最小。 实践 27:抛出异常之前先将对象恢复为有效状态(valid state) 抛出异常很容易,困难的是如何将抛出异常所导致的损害减至最小。常见的情况是,程序员被误导 , 以为关键词 try、throw、catch、throws 和finally 就是错误处理的起点和终点。其实它们只是正确处 理错误的起点而己。 问问你自己,抛出异常是为了什么?明显的目的是将已发生的问题通知系统的其他部分。隐含的目的 则是让软件捕获异常,使系统有可能从问题中抽身回复(recover)而维持正常运行状态。如果异常被 引发后,系统回复并继续运转,引发异常的那一段代码可以被[复入](reentered)。那些回复至[异常 抛出前之状态]的对象,在系统继续运行期间可以再被使用。令人迷惑的是,当抛出异常后,这些对 象的状态究竟如何?系统还可以正确运转吗? 如果你的对象处于一种无效(invalid)状态或未定义(undefined)状态,抛出异常又有什么用呢?如果你 的对象处于不良(bad)状态,那么异常回复后的代码还是很可能失败。因此在抛出异常之前,你必须 考虑对象当时处于什么状态。如果它们的状态会使得[即使异常回复,代码仍然失败],你就得在抛 出异常之前,考虑如何让它们处于有效状态。 通常,程序目会假设函数中的代码将毫无错误地完成工作。一旦错误发生,原先假设的部分甚至全部 便都靠不住了。考虑下面的代码: 这段代码包含了一个 add(),用以将一个对象加入到 list 中。这个函数首先在//l 将一个计数器值累 加1,该值记录了 list 中的对象数量。然后它有条件地重新分配 List,并在//2 为list 添加一个对象。 这个代码有严重缺陷,是的,谓注意//l 和//2 之间可能抛出异常。如果//1 身后抛出异常,那么 Foo 对象就处于无效状态,因为计数器 numElements 的值不对。如果函数调用者从抛出的异常中回复过 来,并再次调用这个函数,由于 Foo 对象处于无效状态,很可能会发生其他问题。 修正这个问题很是容易: 为了修正错误,只要修改//l 的测试行为,并将 numElements 的累加动作移到函数层部的//2 处,这样 就确保计数器 numElements 永远准确,因为你在成功地将对象添加到 list 之后才增加计数器值。这 是一个简单范例,但它揭示了一个潜在的严重问题。 你所放心不下的,肯定不仅仅是[执行中的那个对象],还包括[被我们关注的那个函数值改过的对象]。 举个例子,当异常发生时,函数内或许已经完全建立或部分建立了一个文件,打开了一个 socket, 或是对另一台计算器发出了远程调用 (remote method calls)。如果你希望你的代码被复入(reentered) 后还能正常运转,则需要了解所有受到影响的对象的状态,以及系统本身的状态。考虑以下代码: 在这个实例中,invest()将客户(customer)的资金投资到客户的各个互惠基金的账户中。Customer class 内含一个 funds(),返回客户持有之全部基金(funds)所组成的一个则:。invest()通过 buyNore5hares()对客户的每个基金买进相同金额,然后更新数据库内的 MutualFund 对象,井修改 Customer 对象的有价证券信息。对有价证券的修改动作会建立起一笔帐目活动记录(account activity),让客户得知事务情况。循环中的最后两个函数有可能失败并抛出异常。由于 invesr() 并不处理异常,为避免编译错误,将这些异常列入 throws 子句中。 某些程序员写出了这样的代码,测试后确定运转正常,于是以为万事大吉。测试这段程序时, 他们发现,如果其中某个函数失败,会抛出一个相应异常,然后如预期般地退出函数。如果他 们的测试不够彻底,很可能不会发现潜伏的问题。 每当你撰写可能引发异常的代码时,都必须问自己:(1)异常发生之时(2)异常处理过后(3)复入 (reentered)这段代码时,会发生什么事情?代码运转还能够正常吗?在这个例子中,代码确实存在 一些问题。 假设某位客户在三个互惠基金中都拥有股票(shares),并打算为每个基金各自再买进$1,000//l 取得了所有基金构成的 array,/ / 2为array 的第一笔基金购买$1,000, 的股票,并通过 updateMutualFund()成功地将更新后的基金写入数据库。然后调用 writePortfolioChange(),将某 些信息写入文件。这时候这个函数失败了,因为磁盘空间不足,无法建立文件。于是在对第一 个MutualFund 对象完成[完整的三个步骤]之前,抛出一个异常,意外而鲁莽地退出了 invest()。 假设 invest()调用者通过释放磁盘空间等于法,顺利处理了这个异常,而后再次调用 invest()。 当invest()再次执行时,//l取得基金 array,而后进入循环,为每支基金购买股票。但是不要 忘了,先前第一次调用 invest()的时候,你已经为第一支基金购买了$1,000 的股票。此时又再做 一遍。如果这次 invest()顺利为客户的三笔基金完成了操作,你就是为第一笔基金购买了$2,000, 而其他两支基金各购买$1,000,这是不正确的。显然不是你期望的结果。 就[首先释放磁盘空间,而后再次调用这个函数]的做法而言,你正确地处理了异常。你没有做的 是,在抛出异常之后、退出 invest()之前,关注对象的状态。 此问题的一个修正办法是,在 MutualFund 和Customer 两个 classes 中添加函数,并在 invest() 处理异常时调用它们。这些函数用来撤销未竞全功的一些事件。Invest()必须增加 catch 区 段 , 处理任何被引发的异常。这些 catch 区段应该调用相应函数*重置(重新设定)对象状态,这么一 来如果这个函数下次再被调用,就可以正确执行了。修改后的代码看起来是这样: 现在,即使 invest()抛出异常,而后复入(reentered)invest(),上述代码也能一如期望地正确执行了。 为了让这段代码正常运转,MutualFund 和Customer Classes 各增加一个函数,用以在发生异常时取 消(回 复 ,undo)己对对象进行的操作 invest()内的循环也有了修改,捕捉可能发生的异常,并重置(reset) 对象状在对象成为有效状态、确保复入(reentered)时可正确运转后,重新抛出异常,这样调用端便可 尝试回复(recovery)。 这段代码与原先版本相比,较不易撰写和遵循,但对于强固的、可从异常状态中回复的代码而言, 这些步骤必个可少。 另一个需要考虑的问题是,万一[回复函数] undoMutualFundUpdate()和sellShares()也失败了,情况会 变得如何?如果要保持系统顺利运转,你也需要对付这些失败。的确,正如你所看到,在抛出异常的时 候保持对象处于有效状态,可能非常困难。 如果希望调用者从你所引发的异常中问复,则必须查看所有可能发生异常的地方。如果函数在异常 发生处不顾一切地退出(exit),那么就有必要检查这种行为是否使你的对象处于以下状态: [复入 这个函数时可产生正确结果]。如果没有令对象处于有效状态,你就必须采取必要措施,确保重新进 入函数时得以按我们所期望的方式正常运转。 本实践内容所表述的过程和所谓的[事务](transaction)十分类似。在完成与事务相关的工作时,你必 须有一个[提交+回滚](commit and rollback)计划。如果事务所牵涉的部分无法全部正确完成,就必 须取消整笔事务。你也许会考虑以一个全局事务(global transaction)或一个[提交+回滚]策略来实现解 决方案。 不幸的是,解决这类问题十分困难和耗时。欲正确实现这些解决方案,不仅得花费大量编程时间和 测试时间,还需要大量的艰苦思考。但如果你在编程之后才开始忙于这些问题,那可比在设计和编 程初始就将这些问题铭记于心要棘手得多——你将因此花费更多的工作和努力! 实践 28:先把焦点放在设计、数据结构和算法身上 经常可见的一种情况是,由于被要求提高程序执行速度,程序员放弃了良好、可靠的设计,转而追 求海市愿楼般的性能改良。他们一开始就将劲头放在如何产生员快、最小的代码上。这是在审情度 事时做了错误的抉择。尽管这种做法或许会产生快捷代码,但最有可能的后果是造成程序性能低下 , 造成设计上的强固性(robustness)、扩展性(EXTENSIBILITY)和可维护性(MAINTAINABILITY)都出 现破洞。更何况,这样的做法要求程序员在设计时提前猜测性能问题的症结所在.而此时既没有可 执行的代码,也没有测试数据。 如果你从良好的设计起步,一旦到了需要修改程序使之运行更快的时候,必要的改动就很容易进行 , 代价也小。修改拙劣设计的代码十分费时,也比较容易出错。因此,将精力集中于建立良好可靠的 设计〔必要时易于修改),就可以达到你的性能目标。 本章包含各式各样使 Java 执行更快的技术。你在设计阶段就应当使用或协同运用其中的某些技术, 另一些信息则应留传代码执行起来之后,为更加满足性能目标才使用。 某些建议具有共通性,适用于所有实现,不需设计上的折衷权衡。某些建议则非常专属(specific), 只适用于某些场合。举个例子,如果 StrlngBuffer class 足以胜任,你的设计就不应该在字符串连接 (concatenations)时采用 strlng cIass(参见实践 31)。类似情况,并不是每个循环(100p)都应当不假思索 地铺展开来(unroll)(参见实践 44)。如果你认为这么做可带来显著的性能提升,即使需要风险、努力 和额外的代码也在所不惜,这时候才是展开循环的时机。 你也应当考虑你建立的是应用程序(application)还是类库(class library)。如是前者,你可以对程序执 行性能评测器(performance profiler)而获得评测数据。这使你得以在完成应用程序之后再优化代码。 它也允许你十拿九稳地确定应用程序的哪些部位需要特殊关注。 如果你建立的是个类库〔class library〕,则通常无法悉数写出(测试)应用程序可能对它的使用方式。 这使你难以确定该进行什么样的优化,以及在什么地方进行优化。固然你可以使用性能评测器 (pofller),但它产生的数据和体对程序员如何使用程序库的猜测息息相关。想要产生[能够表现出他 人使用类库的方式]的评测数据,当然是困难的。在这样情况下,你或许得采取有别于应用程序的做 法,事先对类库并用多种优化技术。 产生快捷代码的一个规则是,只优化必要的部分。花费时间将代码优化,却未能给程序带来实质性 的性能影响,就是在浪费时间。典型情况下 80%~90%的程序执行时间花费在 10%~20%的代码上面 (译注:80-20 法则)。你最好通过性能评测器(performance profilers)找出这需要改善的 10%~20%代码 。 请记住,高效代码与(1)良好的设计(2)明智地选择数据结构(data structures)和(3)明智地选择算法 (algorithms)三者的密切程度,远大于与实现语言(implementation language)的关系。在这里我不打算 重复这些共通技术,因为已经有许多这方面的优秀书籍,例如你可以从 Design Patterns :Elements of Reusable Object-Oriented Software 或the Design Patterns CD开始起步。另一本著名的书是 The Practice of Programming,其中第 2章和第 7章与性能的关系尤为密切。 总的来说,对 Java 虽有效的性能提升办法,并非使用特定的 Java 性能技巧〔稍后的实践条款介绍 了一些),而是使用久经考验、独立于语言之外的设计技术和算法。只有在你利用适当的数据结构和 算法进行了良好的设计,并使用性能评测器获得了可靠的数据之后,才应当使用本章提出的一些性 能相关信息。彼时,一旦有充足的测试数据证实你的程序需要进一步修改,许多个别的实践条款便 可用来微调你的代码。 实践 29;不要倚赖译期(compile-time)优化技术 大多数程序员对其他语之[编译器优化能力]已经习以为常,通常,开发过程中必需在编译代码时关 闭优化选项。这样才能使[源码级调试器](source-leveldebugger)正常工作,一旦调试完毕,再打开 编译器优代选项。让编译器产生尽可能快捷的代码,优化后的代码通常与未优化的代码有很大出入 , 这是因为现代编译器的优化技术非常先进,重新编排(rearranges)了代码。使之执行得更快。 这导致有些程序员草率倚赖编译器优化特性来清理马马虎虎的代码,这从来就不是个好主意——尽 管优良的编译吕的确可发掩盖某些低效率的编码成果,举个例子,考虑下面马马虎虎撰写出的的 C++ 循环: Int a =10 Int b=20 Int*arr=new[10] For(i=0;i<10 i++) arr[i] =a+ b 由于 a和b不会改变,在循环内也不会变化,所以它们的加法不需要在每一次循环迭代(Ioop ireration) 中都执行,几乎所有优良编译器都能优化这种代码,做法之一是将 a和b的加法移到循环外部,使 循环更富性能。这样所得的代码和下面的 C++ 代码有相同构造。 Int a = 10; Int b =20 Int *att =new int [10] Int c =a+b For(i=0;i<10 i++) arr[i] =c 这是一个常见而简单的例子,优化技术也可能十分复杂,但或许足以令你奇怪的是,java 编译器在 优化方面着墨甚少,sun 公司的 java 编译器 javac,以及大部分其他 java 编译器,只支持极少量优化 技术,包括简单的常量合并(constant folding)和无用码删除(deaad code elimination). [常量合并]涉及编译器对常量表达式的预告计算,(preacaleulating).考虑以下代码 上述乘法将在编译期(而非运行期)进行,它将被转换为以下形式的 bytecode; int vaue=50 至于[无用删除]技术,可使编译器不为[绝不会被执行]的区段产生 bytecode。[无用码删除]并不影响 代码的执行,却可以减少生成之.class 文件的体中积,例如,以下 foo()函数内的两条语句不会被 转为 bytecode: 当然,[运行期才被核定为 false]的表达式,(expression),还是会生成 bytecode.不生成 byrecode 的惟 一情形是[表达式在编译期即被核定为 false] Java 2 SDK 的javac 相关文件宣称,-O 选项能够为运行期产生出优化代码。如果你以为这个选项带 给你的是类似的 FORTRAN、C及C++语言的所有优化好处,请三思。为了更清楚地阐述其中细节, 请看看先前[循环内带有不变量]那一段 java 代码以 javac-o 编译所得的结果。Java 源码看起来像这样 : 以javac-o 编译这段代码,生成一个 class 文件,将这个文件权 javap-c 反汇编(disassembled),产生 下面这些为 main()生成的 bytecode .按理它们已被优化过了。 Method void main (java,lang,string []) 0 bipush 10 //push 10 on the stack 2 istore_1 // pop 10 and store it ai index 1 of the //local variable table (a ) 3 biopush // push 20 on the stack 5 idtore_2 // pop 20 and store it at index 2 of the //local varidble rable (b) 6 bipush 10 // push 10 for the size of the int array 8 newarray int // pop 10 and create a new int array with 10 //Elements .push the newly creted array // reference(arr ) 10 asrore_3 //pop the array referengce (arr) and store it //at index 23 kf the lical variable table 11 iconst _0 //push 0 for the initial value kf the loop //ciunter(i) 12 istore 4 // push 0(i)and store it at index 4 of the // local variable table 14 goto 27 //jump to location 27 17aload_3 // push the object reference (arr)at index 3 // of the local variable table 18 iload 4 // push the value at index 4(i) 20 iload _1 // Push the value at index 1(a= =10) 21 iload_2 //Push the value at index2(b = =20) 22 iadd // Pop the top ewo values (10 and 20.)add //them ,and oush the result (30) 23 iastore //Pop the top three values .store 30 at //index I in the array (arr) 24 iinc 4 1 //Inceement the loop counter(istored )at //imdex 4 of the local cariable taboe by 1 27 iload 4 //Push the valuie at index 4(i) 29 bupush 10 // Push 10(the loop terminator value ) 31 if _icmpIt 17 // Pop bith I and 10 Jump to location 17 if I is less than 10 34 rerurn // Return from method 注意,[a 和b 相加]的bytecode 仍在循环内部(上图箭头标示出循环结构)。打开优化先项并未如期 地将不变量代码移到循环之外,事实上,对此段代码而言,这个,class 文件未进行优化的 byrecode 相比毫无差异,换句话讲,打开优化选项对生成的代码没有带来任何效果。 那么,-o 选项对 jacac 来说到底有什么作用呢,它的作用因编译器而异,在 sun Java 2 SDK 编译器 中,它对生成的 bytecode 没有丝毫影响。不管文档上怎么说,使用-o 毫无意义。 好几个消息来源显示,Java 编译器提供 method inlining(函数内联化)。如果某个函数本体小巧而且可 以由编译器静态决议(statically resolve),它就可以被视为 inllning 候选者。所谓[可被静态决议]的函 数,就是[不能被覆写(overridden)]的函数。不能被覆写的函数是 private、static 或final 函数。考虑以 javac 编译如下代码; 大概浏览一下这段代码的 bytecode,你会发现,没有一个 inlinning 候选函数被真正 inline,它们都 以独立函数之姿被调用。不过这些函数的确在运行期由 JIT 进行了 inline 动作。由于 Sun Java 2SDK 的—0编译器选项对生成的 bytecode 毫无作用,如今已没有多大必要使用这个选项。是的,由此生 成的(号称优化的)代码并不会比你自己撰写的更好(参见实践 44,其中介绍了各种手工优化 Java 代 码的办法,可产生又好又快的 bytecode)。此外,不要臆测其他供货商的 Java 编译器所执行的优化会 有多大起色。 程序员必须明白,常见的 Java 编译器几乎做不了什么优化工作。面对编译器撤手不管的这些事,程 序员有三个选择: 1. 手工优化 Java 源码,以求获得更好的性能(参见实践 44)。 2. 使用某个第三方优化编译器(third-party optimizing compiler),将源码编译为优化的 bytecode(参 见实践 45)。 3. 依靠诸如 JIT 或Hotspot 这样的运行期优化策略。 随看第三方编译器的改进和市场的接受度,其他主流编译器供贷商或许会跟紧脚步改进你目前正在 使用的编译器。到目前为止,你只有这些选择! 实践 30:理解运行期(runtime)代码优化技术 实践 29 探讨了[大部分编译器无法产生优化 bytecode]的事实,但是一些 JITs 却也着实进行了各式各 样的优化工作。JIT 的目的在于将 bytecode 于运行期转换为本机二进制码(native binary code)。某些 JITs 在将 bytecode 转换为二进制码之前,甚至先分析并进行了某些优化。缺省情况下,即使不是全 部,也有大部分桌面系统和企业系统的 JVMs 伴随有 JIT。例如实贱 29 的例子,那些函数会被 inlined, 循环内的不变量会被移到循环之外。 通过[将bytecode 的解译执行方式(interpretive execution)替换为 binary code 的本机执行方式(native execution)],JITs 得以提高程序性能。本机执行方式通常比解译执行方式的速度快很多。因此,如果 编译后的代码被执行的次数够多,本机代码的生成代价就很合算。不过[JIT 应该执行多少优化]的尺 度非常难把握。它必须确保用于[收集数据和执行优化]的时间,不能超过优化所节省的时间。 大多数时候,使用优秀的 JIT 执行你的代码都会令程序执行得更快接些。程序员容易忽视的是,JIT 必须运转起来——这也需要消耗执行时间。此外,JIT 是针对相对较少的运行时间而设计,因为它 们的存在是为了加速代码,而非使代码馒下来。为了收集[充份的、为执行优化技术而必要的]数据, 必须花费额外时间;考虑到这些,JITs 不得不忽略一些颇具价值的优化技术。 有些程序员认为,Java 编译器没有必要花费时间去优化 bytecode,因为已有一些 JITs 进行了 Java 编 译器未能做到的优化。不过你也应该想到,JIT 做的工作众多,它运行的时间也就愈长。而它运行 的时间愈长,你的程序的运行时间当然也就愈长。如果 Java 编译器连最简单的情况都不进行优化, 更多的工作就会留给 JIT 去做。这样会给你的程序带来负面影响。和 JIT 不同的是,Java 编译器承 担得起高级优化工作所花费的额外时间,不会将代价转嫁给代码运行性能。 和[Java 性能增强]有关的大量努力,都以[运行期优化]为开展中心。这很令人遗憾,因为 Java 编译 器有很多机会可以优化 Java 代码并生成优化的 bytecode。Java 编译器本来可以完成一些类似其他语 言所拥有的优化技术。 单纯倚赖运行期优化的另一个问题是:程序的大小。由于 Java 在嵌入式和实时编程领域(embedded and real-time programming spaces)的发展持续壮大,因而必须使用另外一些优化方案。许多嵌入式系 统或许并没有足够的内存用于 JIT 或Hotspop 执行层。对于需要 Java 快速运转的实时(real-time)编程 而言,JIT 或Hotspot 执行层会带来问题,这是因为它们增加了不确定因素。 一个吸引入的性能组合是将优化后的 bytecode 和一个优秀的 JIT 或Hotspot 执行层结合起来。这使 得桌面编程、嵌入式编程,或实时编程的程序员可以在优化时刻选择静态或动态办法。这个选择可 以根据[撰写中之特定系统的需求和性质]而定。 实践 31:如欲进行字符吕接合,stringbuffer 优于 string Java 程序库同时提供了 string 和stringbuffer 两个 classes。String 用来表示那些建立后就 不再改变的字符序列(character strings),换句话说,其对象是惟读且不可变的 (immutable)。Stringbuffer 用来表示内容可变的字符序列,提供有一些函数用来修改底 部(underlying)字符序列的内容。 这两个 classes 之间的关键差异在于,执行单纯的字符串接合(concatenation)时, stringBuffer 比String 快很多。人们很容易习惯性地使用 String 而忽略 stringBuffer—— 即使在 stringBuffer 更胜一筹的场合。 在许多操控字符串的场合中,字符序列的接合承受处可见。使用 String 进行字符串接合 的动作就像下面这样: Sting str = new String(“Practical”); str + = “Java”; 如果改用 stringBuffer 进行字符串接合,代码看起来像这样: StringBuffer str = new StringBuffer (“Practical”); str.append( “Java”); 你的第一个反应或许是:使用 Sting 性能较性。你可能会推测,由于 StringBuffer 对每 个接合动作调用 append(),这个操作一定比单纯使用‘+’操作符慢。是呀,‘+’操作 符看起来很[单纯],不过实际上并非如此。 事实上,这段代码如果使用 stringBuffer 进行字符串接合,会比使用 String 快数百倍。 为了理解其中玄妙,我们必须研究其所生成的 bytecode。上述第一个例子使用 String, 产生出来的 bytecode 看起来是这样: 0 new #7 3 dup 4 ldc #2 < String “practical”> 6 invokespecial #12 9 astore_1 10 new #8 13 dup 14 dload_1 15 invokestatic #23 18 invokespecial #13 , 21 ldc #1 23 invokevirtual #15 26 invokevirtual #22 29 astore_1 首先,位置 0-9 的bytecode,代表第一行代码: String str = new String (“Practical”); 而后,位置 10-29 的bytecode,执行字符串接合: str + = “Java”; 请注意,为了实现字符串接合,生成的 bytecode 在位置 10 建立了一个 StringBuffer 对 象,并在位置 23 调用其 append()函数。这是因为 Stringclass 是不可变的,接合操作不 得不使用 StringBuffer。 对StringBuffer 对象进行接合之后,必须将其转换回 String。这个动作由位置 26 调用 tostring()函数完成。此函数根据[stringBuffer 临时对象]建立起一个新的 String 对象。注 意,StringBuffer 临时对象的建立,以及将它转型为 String,代价不菲(参见实贱 32)。 这两行代码造成了五个对象的建立;位置 0和4的两个 string 对象,位置 10 的一个 StringBuffer 对象,以及位置 21 和26 的两个 String 对象。 现在,让我们比较一下,上述第二个例子使用 StringBUffer 所产生的 Bytecode; 0 new #8 3 dup 4 ldc #2 6 invokespecial #13 9 astore_1 10 aload_1 11 ldc #1 13 invokevirtual #15 16 pop 首先,位置 0~9之间的 bytecode,代表第一行代码: StringBuffer str = new StringBUffer(“Practical”); 而后,位置 10~16 的bytecode 执行字符串接合: str.append(“Java”); 注意,与第一个例子相同的是,这段代码也调用 StringBuffer 的append()函数。和第一 个例子不同的是,这次不再需要[建立 StringBuffer 临时对象而后将它转为 String 对象]。 这段代码仅仅产生三个对象,分别是位置 0的一个 StringBuffer 对象,以及位置 4、11 的两个 String 对象。 就像先前提到的那样,StringBuffer 的接合操作比 String 快数百倍。所以你应该尽可能 使用 StringBuffer 进行这类操作。如果你需要 String 提供的功能,请考虑使用 StringBuffer 完成字符串接合,再转换为 String 将[StringBuffer 转为 String]的数量减至最少,可以降 低对象的创建数,从而大大提升程序性能。 实践 32:将对象的创建成本(cre ation cost)降至最小 运用诸如 Java 这类而向对象语言时,程序员常低估创建(create)对象所带来的成本,而这些成本往往 比程序员意识到的还高得多。对象的建构(construction)不仅仅是[分配内存+初始化一些值域(fields)] 那么简单,它可能涉及非常多个步骤。所以将[待建对象]的数量和体积减到最小,实为明智之举, 在[性能至关重要]的程序中更是如此。使用优秀坚实的设计,是达此目的的方法之一(参见实践 28)。 为了理解其所涉及的开销,让我们先看看在创建一个对象的过程中都发生了些什么事。 在对象构建过程中,为确保其正确性,以下事件一定会以固定顺序放生: 1. 从heap 之中分配内存,用以存放全部的 instance 变量以及这个对象连同其 superclasses 的实现专 届数据(implementation-specific data)。所谓[实现专属数据]包括指向“class and method data”的指 针。 2. 对象的 instance 变量被初始化为其相应的缺省值。 3. 调用 most derived class(最深层派生类)的构造函数(constructor)。构造函数做的第一件事就是调用 superclass 的构造函数。这个程序一直反复持续到 java.1ang.object 构造函数被调用为止。一定要 记住,java.1ang.object 是一切 Java 对象的 base class。 4. 在构造函数本体执行之前,所有 instance 变量的初始值设定式(initializers)和初始化区段 (initialization blocks)先获得执行,然后才执行构造函数本体。于是 base class 的构造函数最先执 行,most derived class 的构造函数最后执行。这使得任何 class 的构造函数都能放心大胆地使用 其任何 superclasses 的instance 变量。 既然创建一个对象要发生这么多事情,建立一个轻型(lightweight)对象就比建立一个重型 (heavyweight)对象快很多。所谓轻型对象是指:既不具有长继承链(long inheritance chain),也不内含 许多其他对象。重型对象恰恰相反。如果一个对象内含多个轻型对象,也可被视为重型对象。考虑 下面这个 class: 这个class 并未明显地扩展(继承)任何其他 class( 不过别忘了,所有对象都暗中继承 java.1ang.object).其继承链(inheritance chain)长度很短。而且这个 class 不包含任何对象成员。不过 它倒是包含了两个隶属基本型别(primitive type,参见实践 8和实践 38)的数据成员。进行如下动作 之后: Light 1gt = new Light(5); Light 对象的建立步骤如下: 1. 从heap 分配内存,用来存放 Light class 的instance 变量,以及一份[实现专属数据]。 2. 此class 的instance 变量 val 和hasDate,被初始化为相应缺省值。Val 被赋值为 0,这是 Int 的缺 省值,hasDate 被韧始化为缺省值 fa1se。 3. 调用 Light 构造函数,传入数值 5。 4. 调用 Light 构造函数调用其 superclass(本例为 j ava.1ang.object)的构造函数。 5. 一旦 java.1ang.object 构造函数返回,Light 构造函数执行其 instance 变量的初值设定工作。此时 将hasData 赋值为 true。 6. 将val 赋值为 5,而后 Light 构造函数结束。 7. object reference lgt 指向 head 中刚刚建立完成的 Light 对象。 让我们象这些步骤和重型对象的建构过程做个比较。考虑下面的 cIass 声明: 刨建一个 Heavy 对象的过程将牵扯较多事务,花费较多时间。进行如下动作之后: Heavy hvy = new Heavy(4,true); Heavy 对象的建立步骤如下: 1. 从heavy 分配内存,用来存放 Heavy 的instance 变量(pt 和tf)、Der2 的instance 变量(state 和 hasData),以及 Base 的instance 变量(va1),和一份[实现专属数据。 2. instance 变量被扔始化为其相应缺省值。更明确地说,object references pt 和tf 被赋值为 null。 Boolean 变量 state 和hasData 被赋值为 false,基本型别 Int val 被赋值为 0。 3 调用 Heavy 构造函数,传入数值 4和true。 4 Heavy 构造函数立即调用其 superc1ass Def2 的构造函数。 5 Der2 构造函数立即调用其 superc1ass Derl 的构造函数。 6 Derl 构造函数立即调用其 superc1ass Base 的构造函数。 7.Base 构造函数立即调用其 superclass java.1ang.object 的构造函数。 8 j ava.1ang.object 构造函数返回后,Base 构造函数将 instance 变量 val 赋值为 4,然后返回。 9 Derl 构造函数完成,返问。 10 Der2 构造函数进行其 instance 变量的初始化工作,将 hadData 腻值为 t rue。 11 Der2 构造函数本体开始执行,将 state 赋值为 true,然后返回。 12.Heavy 构造函数开始执行,试图建立一个 Point 对象和一个 TextField 对象。 此时,针对这两个对象,又从步骤 1开始重复全部过程。 13 object reference hvy 指向 heap 之中最后创建完成的 Heavy 对象。 正如你看到的,建立重型对象比建立轻型对象的性能相差很多。步骤 12 代价最高因为它不得不对 两个 aggregate objects(聚合对象)重复整个过程。Light 对象的建构比 Heavy 对象的建构快 5倍 以 上 。 如果通过性能评测(profiling),确定性能问题是因重型对象的创建而造成,你将有数个选择: � 使用缓式评估(延迟求值,1azy evaluation)技术(参见实践 43) � 重新设计这个 class,使之[瘦身 J(lighter)。 � 如果引发性能问题的那些代码只使用了重型对象的某些部分,请将这个 class 分解为多个轻型 class,并令对性能需求最高(最具关键性)的代码只使用轻型 class 对象。 由于重型对象涉及性能问题,你可能从此决定不再设计重型 classes。这并非明智之举,并非所有对 象都可以是轻型的。重型对象有其合理用外,将它纳入设计之中并没有什么不对。本条款的目的不 是要你回避重型对象,而是使你在进行性能提升的时候,理解重型对象所带来的性能影响。 在着手性能提升的时候,请记住,以下数个 class 特征会增加对象的创建成本(creation cost): � 构造函数中有大量代码。 � 内含数量众多或体积庞大的对象-它们的初始化将是构造函数的一部分。 � 太深的继承层次(inheritance hierarchy)。例如上例的 Heavy class,其 base class 并非其直接父类 (immediate superclass) 记住,每一个被创建出来的对象,也是垃圾回收器跟踪和可能释放的目标。所以不仅仅[建立对象] 需要付出代价,垃圾回收器正确管理它们的存储空间也需要付出代价。一种替换办法是:复用(reuse) 既有对象(参见实践 42)。 实践 33:慎防未用上的对象(unused objects) 实践 32 讨论了创建对象所付出的高昂代价。这意味你应当只在需要对象时才创建 它们。马马虎虎的编码行为会导致产出一些可有可无的对象,因而降低了代码的执 行速度。举个例子,考虑这样一个函数:以两个 arrays 作为输入,相加其内容,并 将结果存储至另一个 array,然后返回新建的 array。代码看起来是这样: 这段代码性能不高,因为它总是会创建一个用不到的对象。最初它建立一个 array 对 象和一个异常对象,但只有其中之一在此函数执行过程中会被用到。由于这个函数 的写作风格,两个对象不可能同时被用上。这种写法意味你将为创建两个对象付出 代价,因为无论执行函数中的哪一条路线,总有一个对象没被用上。 更好的写法是: 这个函数性能比较高,因为它只在需要的时候才创建对象,所以当他被调用的时候, 只会有一个对象被创建出来。如果传入的 arrays 有效,这个改良后的函数会比原先 的版本快 5倍以上。 对象的创建成本是非常昂贵的,绝对不要创建非必要的对象。 实践 34:将同步化(synchronization)降至最低 在撰写多线程(multithreaded)程序时,你会碰到许多这样的情况:以关键词 Synchronized 限制对 共享资源的访问(参见实践 46 和实践 47)。关键词 Synchronized 的确是有用和必要的,不过设计 classes 的时候你必须了解随之而来的代价。举个例子,考虑下面这个 statck class: 目前还 没有函数被我设为 synchronized。现在,假设 stack 对象需要被多个线程(threads)访问。于是你将 每一个函数改为 synchronized,这是正确的做法。这种改变会带来什么样的性能影响呢? synchronized 函数相对于 Non-synchronized 函数有什么不一样的运作方式? Push()、pop()和top()的synchronized 版本比其 non-synchronized 版本大约慢 5~6 倍。可是 synchronized contains()只比其 non-synchronized 版本慢大约 10%。这引发了两个问题: 1为什么 push()、pop 和top()的 synchronized 版本和 non-synchronized 版本之间的执行速度存在如此明显的差距? 2 为什么 synchronized contains()和其 non-synchronized 版本之间的速度差异如此微小? 让我把焦点集中在函数 top(),查看其所生成的 bytecode,以此为线索研究为什么它的 synchronized 版本比 non-synchronized 版本慢这么多。讽刺的是,这两个版本生成的 bytecode 竟然完全相同: Method int top() 0 aload_0 //Push the object reference(this) at index //0 of the local variable table 1 getfield #6 //Pop the object reference(this) and push //the object reference for intArr accessed //from the constant pool. 4 iconst_0 //Push 0. 5 iaload //Pop the top two values and push the value //at index 0 of intArr. 6 ireturn //Pop the top value and push it onto the //operand jstack of the invoker. 如果它们的 bytecode 相同,又怎么会一个比另一个快 5~6 倍呢? 当我们提供了 synchronized 修饰符后(就像函数 top()的 synchronized 版本那样)lock 的获取 (acquisition)和随后的释放(release)就不再通过 monitorenter 和monitorexit 操作码(opcodes)来 进行。取而代之的是,当JVM 调用一个函数时,会检验 ACC——SYNCHRONIZED 属性标记(property flag)。如果存在这个标记,当前线程就取得一个 lock 并调用该函数,并在函数返回时释放 lock。如 果synchronized 函数抛出异常,则在异常离开这个函数之前,lock 会被自动释放。 正是由于获取/释放 lock 的代价,synchronized top()比其 non-synchronized 版本慢得如此悬殊。对于 一个小型函数,这个代价占据函数总体执行的比例相当大。 与此呈现对比的是,synchronized contains()只比 non-synchronized 版本慢大约 10%。当然,它仍然必 须获取/释放 lock,不过由于它比 push()、pop()及top()函数的体积大些,所以获取/释放 lock 的代价 分摊下来,其比例就显得小了。对一个少大型的函数,获取/释放 lock 的代价只占函数总执行量的 一小部分而已。 关键词 synchronized 不仅降低了代码的执行速度,使其被使用方式的不同,它还可能增加代码的体 积。例如下面是功能相当的两个函数: 这些函数的功能相同,但性能和大小都不一样,这不太容易看出来。此处的 top1()大约比 top2()快 13%,而且体积更小。让我们检查它们生成的 bytecode,看看有何玄机: Method int top1() 0 aload_0 //Push the object reference(this) at index //0 of the local variable table 1 getfield #6 //Pop the object reference(this) and push //the object reference for intArr accessed //from the constant pool 4 iconst_0 //Push 0 5 ialosd //Popthe top two values and push the value //at index 0 of intArr 6 ireturn //Pop top value and push it on the operand //stack of the invoking method.Exit method. Method int top2() 0 aload //Push the object reference(this) at index //0 of the local variable table 1 astore_2 //Pop the object reference(this) and store //it at index 2 of the local variable table. 2 aload_2 //Push the object reference(this) 3 montorenter //Pop the object reference(this) and acquire //the object’s monitor 4 aload_0 //Beginning of the synchronized block.Push //the object reference(this) at index 0 of //the local variable table. 5 getfield #6 //Pop the object reference(this) and push //the object reference for intArr accessed //from the constant pool 8 iconst_0 //Push 0 9 iaload //Pop the top two values and push the value 10 istore_1 //Pop the top two values and push the value //the local variable table 11 jsr 19 //Push the address of the next opcode(14) //and jump to location 19 14 iload_1 //Push the value at index 1 of the local //variable table 15 ireturn //Pop top value and push it on the operand //stack of the invoking method. Exit method. 16 aload_2 //End of the synchronized block.Push the //local variable table. 17 monitorexit //Pop the object reference(this) and exit //the monitor. 18 athrow //Pop the objext reference(this) and throw //an exception. 19 astore_3 //Pop the return address(14) and store it at //index 3 of the local variable table. 20 aload_2 //Push the objext reference(this) at index 2 //of the local variable table 21 monitorexit //Pop the objext reference(this) and exit //the monitor 22 ret 3 //Return to the location indicated by index //3 of the local variable table(14) Exception table: //If any exception occurs between From to target type //location 4 (inclusive) and location 4 16 16 any //16 (exclusive) jump to location 16. 同步(synchronization)和异常(exceptions)的处理,导致 top2()与top1()相比既大又慢。记住,top1 ()带有synchronized 修饰符,这并不会生成额外的代码。Top2()则是在函数本体中使用 synchronized 语句。 如果你在函数本体中使用 synchronized,就会产生操作代码 monitorenter 和Moniterexit 的bytecode, 以及为了处理异常而附加的代码。这么一来,一旦 Synchronized 区段前先释放 lock。top1()比top2() 性能略高一些,从而导致微小的性能改善。 所以,限制同步机制(synchronization)的使用,对性能有正面效益(实践 49 告诉你如何避免无谓 的同步)。不幸的是,同步动作并非总能轻易消除。有时候即使你不需要同步化,也会用到由 class library 提供的 synchronized 函数。例如,你撰写了一个单线程(single-threaded)应用程序,却调用 了为多线程(multithreaded)访问而设计的程序库中的 synchronized 函数。在这种情况下,你仍然得 为了那些函数获取/释放你其实并不需要的 lock,因而付出高昂代价。要想消除这种无谓的同步,有 两个选择: 1 提供一个 subclass,内含那些函数的 unsynchronized 版本。 2 使用另一个 class,提供 unsynchronized 函数。 当你得以接触[你想要替换之 synchronized 函数]的源码时,上述选项 1才有作用。举个例子,考虑 下面的 classes: 这段代码仅仅提供了一个 subclass,内含你想使用的函数的 synchronized 版本。这个技术的缺点在于 , 增加了代码数量,以及因为[使用更多重型对象]而带来的性能降低(事件 32 探讨重型对象)。而且 由于你复制了代码,以此需要更多地维护工作,并增大了.class 文件的体积以及程序所需的内存用量 。 如果你无法接触源码,或是无法承受额外的体积或内存开销,那就只能采用选项 2。选项 2的一个 使用情况是当你使用 vector class 时。vector class 很擅长保存和操纵对象,它在很多方面都和 array 不一样(参见实践 34)。但是它绝大多数的函数都是 synchronized。所以即使你不需要 synchronized 语义,还是得忍受由此带来的性能损失。这使得这种情况下运用 vector 远远不能让人满意。 在java 2 sdk 之前,程序员总是采用选项 1的做法来实现一个 unsynchronizedvector。他们建立一个 java.util.vector subclass,提供[不带 synchronized 关键词]的版本。这项技术现在不需要了,因为java 2 的collection class (群集类)引入了 Arraylist class,本质上那是个 unsynchronized vector ,从而执 行速度会快一些。举个例子,考虑下面对 vector 的使用: 这段程序可以很方便的改用 arraylist,像这样: 以上代码采用 arraylist,比采用 array 快2倍,执行结构相同。请记住,arraylist 应当只用于[不需 要同步化]的时候。 声明 synchronized 函数,或声明[含有 synchronized 区段]的函数,会大大降低执行性能。无论你 使用 synchronized 作为修饰符,还是在函数中使用 synchronized 区段,都意味着体积增大。只有当 你得代码要求同步化,而且你理解那么做的代价之后,才使用 synchronized 函数。如果整个函数都 需要被同步化,则为了产生体积较小且执行速度较快的代码,请优先使用函数修饰符,而不是在函 数内使用 synchronized 区段。 实践 35:尽可能使用 stack 变量 如果需要频繁地访问变量,你就得仔细考虑它们从那里被访问。变量是 stack 并置于 stack 之上?抑 或是某个 class 的instance 变量?[变量被存储于何处]对其访问性能有显著影响。考虑以下代码: 这段代码的每一个函数都执行相同的循环,迭代次数也完全一样。它们的不同处仅仅在于:每个循 环内累加的是不同型别的变量。函数 tackaccess()累加的对象是个 local stack 变量,instanceaccess ()累加的对象是个 class intance 变量,Stackaccess()累加的对象则是 class intance 变量。 instanceaccess()和 Stackaccess()花费大约相同的执行时间。可是 stackaccess()却快上 2~3 倍。 访问 stack 变量之所以能够如此之快,是因为 JVM 所作的相应工作远少于访问 stack 变量或 class intance 变量所作的工作。让我们检查一下这三个函数生成的 bytecode: Method void stackaccess(int) 0 iconst_0 //Push 0 onto the stack. 1 istore_2 //Pop 0 and store it at index 2 of the local //variable table(j). 2 iconst_0 //Push 0. 3 istore_3 //Pop 0 and store it at index 3 of the local //variable table(i) 4 goto 13 //Jump to location 13. 7 iinc 2 1 //Increment j stored at index 2 by 1. 10 iinc 3 1 //Increment I stored at index 3 by 1. 13 iload_3 //Push the value at index 3(i). 14 iload_1 //Push the value at index 1(val). 15 if_icmplt 7 //Pop I and val. Jump to location 7 if i is //less than val. 18 return //Return to calling method Method void instanceaccess(int) 0 iconst_0 //Push 0 onto the stack. 1 istore_2 //Pop 0 and store it at index 2 of the local //variable table(i). 2 goto 18 //jump to location 18 5 aload_0 //push index 0(this) 6 dup //duplicate the top stack value and push it 7 getfield #19 //pop the object reference for this and push //the value for instvar 10 iconst_1 //Push 1 11 iadd //pop the top two values,push their sum. 12 putfield #19 //pop the top two values and store the sum //in instvar 15 iinc 2 1 //increment I stored at index 2 by 1 18 iload_2 //push the value at index 2(i) 19 iload_1 //push the value at index 1(val) 20 if_icmplt 5 //pop I and val jump to location 5 if I is //less than val 23 return //return to calling method Method void staticaccess(int) 0 iconst_0 //push 0 onto the stack 1 istore_2 //pop 0 and store it at index 2 of the local //variable table(i) 2 goto 16 //jump to location 16 5 getstatic #25 //push the value from the constant pool for //staticvar 8 iconst_1 //push 1 9 iadd //pop the top two values,push their sum 10 putstatic #25 //pop the value from the constant pool for //staticvar 13 iinc 2 1 //increment I stored at index 2 by 1 16 iload_2 //push the value at index 2(i) 17 iload_1 //push the value at index 1(val) 18 if_icmplt 5 //pop I and val jump to location 5 if I is //less than val 21 return //return to calling method 察看 bytecode 便可以明白为什么 stack 变量的性能比较高。JVM是一种 stack-based虚拟机 , 因此在访问和操纵 stack data 是性能最佳。程序中的所有 local 变量都会被置放于一个 local 变 量 中 , 并于 java operand stack 中操纵,如此便可获得很好的性能。访问 stack 变量 intance 变量的代价则高 出许多,因为 JVM 不得不使用更耗时的操作码(opcode),从 constant pool(常量池)中访问它们。 典型情况下,第一次从 constant pool 取得某个 stack 变量或 intance 变量之后,JVM 会动态修改 bytecode,以便运用更高的操作码。但无论是否采行这种优化,stack 变量的访问永远要快一些。 既然如此,让我们重新架构先前的代码,以 stack 变量来替换 intance 变量或 static 变量,这样的操 作性能会比较高。下面是修改后的代码: 函数 instanceaccess()和 stackaccess()被修改了,其中将他们的 intance 变量或 static 变量复制到 一个 local stack 变量中。对变量的所有工作都完成之后,其只有被复制回 intance 变量或 static 变量。 这个简单的修改显著改善了 instanceaccess()和 stackaccess()的执行效果仅仅比 stackaccess( ) 慢大约 4%。 这并非意味你应该避免使用 static 变量或 intance 变量。什么样的存储机制(storage mechanism)符 合你的设计,就应当使用什么样类型的变量。如果你再循环中访问 static 变量或 intance 变量,则可 以将他们暂时存储于一个 local stack 变量中,如此便能大幅提升程序性能,因为这个动作可为 JVM 提供更高效的 Bytecode 指令序列(sequence of bytecode instructions)。 实践 36:使用 static、final 和private 函数以促成 inlining 为了让函数成为 inlining 候选者,必须将它们声明为 static、final 和private。此类函数可以在编译期 被静态决议(statically resolved),而不需要动态决议(dynamicResolution)。 以函数本体(method body)替换函数调用(methodcall)会使代码执行速度更快。由于大型函数的 inlining 会造成代码体积膨胀,所以通常只有小型函数才考虑 inlining。通常 inlined 函数包括 class 中常见而小巧的取值函数(getter)和设置函数(setter),这些函数往往只有一两行代码,例如: 假设你的 JAVA 编译器或 jit 拥有 inlining 能力,如果某个函数被声明为 static、final 和private,则其 函数本体将被[inline化],也就是被放进每个调用点。这样便可在不明显增加体积的情况下改善执行 性能。例如先前的代码可以修改如下,使 inlining 得以成功: 这些函数不会被目前市面上大部分 Java 编译器[inline 化],不过他们将被目前市面上大部分的 jits 于 运行期[inline 化],并导致显著的性能提升,例如 length()和setlength()经过 inlining 之后,比non-inlined 版本快 3倍以上。如果你令函数成为 static 和private,也会看到类似的性能提升,因为他们也会使 inlining 成为可能。 将函数声明为 static、final 和private 会带来一些缺点:这样的函数无法通过 Subclassing(子类化)进行 扩展。这就束缚了 derivedclass 通过 class 函数做事情的机会。 inlined 函数只有在[被多次调用]的情况下,才会获得令人侧目的性能提升。这是因为当一个函数被 inline 后,就不再需要负担函数调用的额外开销。因此,函数被调用愈多次,节省就愈多。不过inlining 也可能使你的代码体积变大。如果这个函数有许多调用点,.class 文件的体积便会膨胀,这是因为原 本只需存储一份的函数码,由于 inline 而在所有调用点被复制了一份。 [将某个函数 inlining]的决定如果不是由编译期作出,就是由 JIT 在运行期做出。不同的 java 编译 器和 jits 实现品可能使用不同的规则来决定是否 inline 一个函数。某些编译器可能甚至提供 inline 关 闭(disable)选项。 以编译器而非 jit 运行层来执行 inlining 利弊兼有。如果编译器 inlines 一个函数,jit 需要作的工作也 就降低,你的代码执行性能也就提高。可是一旦以编译器进行 Inlining,该函数的任何变化就不会被 其他.class 文件内的代码所用,除非那些代码被重新编译。对比的是,如果你通过 jit 于运行期才对 函数 inlining,这个函数的任何新版实现,都会被其他 classes 于后继调用中用上。 实践 37:instance 变量的初始化一次就好 对面向对象系统而言,程序执行时间又很大比例都花在创建(creating)和销毁(destroying)对象身 上。实践 32 讨论了创建对象的高昂代价。由于创建过程中调用了 class 构造函数,所以让构造函数 尽可能高校对程序十分有益。 你可以通过让构造函数做更少工作使其性能得到更高。为了安全的做到这一点,你必须准确知道当 程序创建一个对象时都发生了什么事。实践 32 详细描述了创建和初始化一个对象所必需的步骤, 本时间则是告诉你如何使这个过程更加高效。 考虑下面的 class 定义及其构造函数: 请注意,count 和done 值域在构造函数函数体内被初始化。再看看下面这个 class 及其构造函数: 此处两个 Class 的实现方式性能都不高。实践 32 告诉我们,当我们创建某个 class 对象时,该 class 的instance 变量会根据其型别被初始化为相应的缺省值。因此在构造函数本题执行之前,instance 变 量的初始化行为已经完毕。最后才轮到构造函数本体的执行。上述两种做法都将 count 和done 重复 (两次)初始化为相同内容。 在进入 foo 构造函数本体时,instance 变量 count、done、pt 和vec 已经分别被赋予缺省值 0、false、 null、null。后来构造函数内又再次初始化 count、done 为0 和 false,实乃画蛇添足之举。 至于 foo2,四个 instance 变量已被设定缺省值。而后再根据构造函数执行前进行 instance 变量的初始化,再次将 count 和done 设为缺省值,也是画蛇添足。 请注意,在分配对象所需内存是,jvm 已经将上述两个 instance 变量初始化为其相应的缺省值,所 也在构造函数本体内或在 instance 变量初始化过程中,均不需要重新设置他们的缺省值。这些classes 的更高校做法应该像这样: 我必须承认,这么做仅仅节省了寥寥可数的 bytecode 指令。不过这些节省可以因对象的重复创建而 积少成多。而且这种做法所生成的代码比较少。.class 文件内不会再有赘余的(可有可无的)代码, 例如将 count 赋值为 0或将 done 赋值为 false 等等。count 和done 相应缺省值由 jvm 设定,不时有 构造函数或 instance 变量的初始化语句完成。这个版本是三个版本中最小并且最快的。具有优化能 力的编译器理应消除前述多余的赋值操作,不幸的是许多编译器没有这种能力(参见实践 29)。 许多熟悉其他编成语言的程序员,对于未曾明确将全部 instance 变量初始化感到惴惴不安。某些语 言出于性能调整因素,并不为变量(数据)提供缺省值初始值。程序员通常在访问变量(数据)前 初始化它们。对这些人而言,上述的 class 实现方式乍见之下可能有些不习惯。 Java 对于 instance 变量、static 变量和 arrays 都进行了缺省的初始化动作(dfaultInitialization),我们 不应该因为[重复将这些变量和 arrays 初始化为缺省值]而非必要的低效性能上的好处。记住,local 变量没有缺省值,因此你必须将它明确的初始化。只有 class 的static 变量和 instance 变量才有缺省 值,这些缺省值在 p8 的表一列出。这些缺省值也发生在相应的 arrays 中。 实践 38:使用基本型别(primitive types)是代码更快更小 Java 提供了数种基本型别(primitive types),以提供对应之基本外覆类(primitive Warpper class)。[基 本型变量]和[对象]的存储特性及行为特性都不相同,这些在实践 8已经描述过。和对象相比,基本 型别不仅行为方式和存储特性殊异,还展现了不同的性能特征和体积特征。各个基本型别及其对应 之外覆类(WarpperClass)已经在 p26 的表三列出。 [表述某一基本型别所需的(存储)空间,通常不会超过[保存其及值区间]所需的空间。在 32-bit 机器上这个空间一般是 4或8bytes(以下假设使用 32-bit 机器),所以各种基本型别最多占用 4bytes, 只有 double 和long 例外,它们每个最多占用 8 bytes。小型基本型别(short、byte、char 和boolean) 用于对象或 classes 值域或作为 array 元素时,经常采用更紧凑的表现方式(译注:而非为了配合 32-bit 机器占用 4 bytes)。不过当它们存储于 stack 或用于局部变量时,通常被提升为 int,以 4 bytes 存储。 除了[小巧],基本型别也比较方便快捷。基本型别并没有关联至任何对象,所以它们的数值直接置 放于 stack。它们的创建速度很快,因为 JVM 只需在 stack 内为它们保留 4 bytes(double 和 long 则 是8 bytes)空间即可。访问它们也很快,因为时一特定的操作码(opcode instructions)直接访问。 对象就不是这样。它比基本型别庞大,而且需要更多时间才能访问。在 JVM 中,对象于 heap 身上 的精神表达式(exact instructions)取决于 jvm 实际产品。但无论其精确表述形式如何,为了访问 class 专署数据,它都需要额外的存储空间。例如,对象或许保存有一个指针指向其 instance 数据,另保 存一个指针指向 class 专署数据。这就以下子导致了 8 bytes 的开销(每个指针占用 4bytes)。 对象除了在体积方面有较大的开销,其访问时所花费的时间代价也比基本型别高一些,这是因为间 接 性(indirection)所致。考虑以下函数,stack之中有一个基本型别int变量,和一个 reference 指向integer 对象: 下图显示此代码所产生之 stack、heap 和object 布局,只是一种可能表述形式。 5//i’s value 555//i’svaluvalueiobj Stack Instance data Integer Class data and Method table Heap 根据这种实现方式,基本型别 int 从stack 中直接访问,速度很快。但如果想访问 Object,必须先间 接取其所在之 heap,而后才可以直接访问 instance 数据;当需要调用函数时,则需另一个间接指针。 由于存在这些特性,如果你使用基本型别替换其外覆类(wrapper),内存用量会少一些,代码执行 速度会快一些。考虑下面两个函数: 上述两个函数都简单地相加两个数,然后返回结果。Useprimitive()使用两个基本型别 int,useobject ()使用一个基本型别 int 和一个 integer 外覆类。 你或许认为,不会仅仅因为该用一个基本外覆类就影响了代码的性能。但结果却令你大感惊奇。 Useprimitive()大约比 useobject()快30%。为了弄清楚为什么使用 integer class 会使函数速度变慢,让 我们检阅这两个函数所生成的 bytecode。Useprimitive()生成的 bytecode 如下: Method int useprimitive(int) 0 iconst_5 //push 5 on the stack 1 istore_2 //pop 5 and storeit at index 2 of the local //variable table(i) 2 iload_2 //push the value from index 2 of the local //variable table(i) 3 iload_1 //push the value from index 1 of the local //variable table(increment) 4 iadd //pop the top two values,push their sum 5 istore_2 //local variable table(i) 6 iload_2 //push the value from index 2 of the local //variable table(i) 7 ireturn //pop the top value and push it on the operand //stack of the invoking method 假设你传入数值 10,代码仅仅将数值 5和10 推入 stack 并相加它们,而后返回结果。现在比较一下 useobject()生成的 bytecode: Method int useobject(java.lang.integer) 0 iconst_5 //push 5 on the stack 1 istore_2 //pop 5 and store it at index 2 of the local //variable table(i) 2 iload_2 //push the value from index 2 of the local //variable table(i) 3 aload_1 //push the object reference from index 1 of the //local variable table(increment) 4 invokevirtual #16 //pop the object reference and invoke its //intvalue method push the result 7 iadd //pop the two top values,push their sum 8 istore_2 //pop the sum and store it at index 2 of the //local variable table(i) 9 iload_2 //push the value from index 2(i) 10 ireturn //pop the top value and push it on the operand //stack of the invoking method 由于这个函数传入了一个 Integer 对象,所以需要调用 Integer 的intValue()来获得其中存储的数值。 你不仅引发了一个函数调用,还为了访问对象(而非基本型别)付出额外代价。这些差异正是两函数 消耗时间不同的症结所在。 你还应当了解另一种情况:函数建构——个primitive wrapper object(基本型外覆对象)当作返回值。 考虑下面这个经过修改的 useObject(),我们传给它数值 10,它返回的是一个 I nteger 对象而非一个int: 这段代码比先前两个例子都耍缓慢和庞大。如此明显地降速,是因为每坎调用这个函数都得创建一个 对象。创建对象是—件很花费时间的事,你应当尽可能减少对象数量以增强性能(参见实践 32)。 由于需要领外的 bytecode 来创建对象,所以上述代码体积也比较庞大。该函数生成的 bytecode 如下 : Method java.lang.Integer useObject(int) 0 iconst_5 //Push 5 on the stack. 1 istore_2 //Pop 5 and store it at index 2 of the local //variable table(i). 2 iload_2 //Push the value from index 2 of the local //variable table(i). 3 iload_1 //Push the value from index 1 of the local //variable table(increment). 4 iadd //Pop the two top values and push their sum. 5 istore_2 //Pop the sum and store it at index 2 of the //local variable table(i). 6 new #4 //Create an object of the Integer class and //push a reference to it. 9 dup //Duplicate the top stack value and push it. 10 iload_2 //Push the value from index 2 of the local //variable table (i). 11 invokespecial #11 //Pop the top values and invoke the //Pop the top two values and invoke the //constructor for the Integer class passing i. 14 areturn //Pop the object reference for the Integer //object and push it on the operand stack of //the invoking method 如果要让这个函数更富性能一些,应该将其返回值改为 Int,以避免创建对象。 如此一来,你或许认为绝对不该使用 primitive wrapper classes(基本型外覆类)。由于它们与其相应之 基本型别无论在件能上和体积上皆有距离,你可能不愿意使用它们。但有时你非使用它们不可—— 所有 Java collection classes(群集类)都(只)管理 java.1ang.object 对象。这意味如果你试图加入一个基 本型别数值到一个 collection 中,会发生编译错误。为了解决这个问题,不得不使用 wrapper classes(外 覆类)将其包装起来,橡这样: 是的,在使用 Java collections 时,你除了以对象(而非一般数值)作为元素,别无他法。另一种可能 让你决定完全使用 primitive wrapper classes 的情况是,你需要它带来的额外功能(行为),并情愿因此 损失一些性能,以技得更好的设计。使用 primitive wrapper classes 并无不要——只要你理解它们带 来的体积和速度上的不过我必须告诉你,使用这些 wrapper classes 很少能够产生既小又快的代码。 有些面向对象拥护者并不向意 Java 设计者将基本型别包含进来。他们说,如果 Java 是真正的面向对象,它就该是对象的天下,不能有基本型别。这些主张或许有其道 理,但存在的事实是:Java 语言同时接纳了二者。一旦系统有所需求,Java 程序员 可以充份运用基本型别的速度特性和体积特性。 实践 39:不要使用 Enumeration 或Ite rato r来遍历 Vector JaW 提供遍历 vector 元素的数种办法: 1. Iterator(选代器) 2. ListIerator(list 迭代器) 3. Enumeration(枚举器) 4. get()函数(取值函数,getter) 合并返回。这个函数有四种写法,如下所示; 传入同一个 vector,以上四个函数完成相同的任务.返回一样的结呆。其中三个 函数有不同的性能:Iterator 版和 ListIterator 版的性能大致相同,Enumeration 版大约快 l 2%。至于 采用 Vector get()的标准循环版本,比其他三个函数快 29%—34%。 forVec()之所以比其他三个函数快,因为它做的事情最少。注意,每通过一次循环,forVec()便进行 两次调用:get()和intValue ()。其他三个函数每次迭代都得做三次调用,例如enumVec()必须调用 hasMoreElements ()、nextE1ement()和intValue()。 [每次通过循环都调用额外的函数],是的,正是这个原因,使这些 enumerations(枚举器)iterators (迭代器)比起[get()+for 循环]有着较慢的执行速度。在时间至为关键(time-critical)的代码中,请 考虑将这些费时的结构替换为更廉价(更快速)的结构。 实践 40:使用 System.arraycopy()来复制 arrays 本章其他实践条款讨论了函数调用的高昂代价,因此我们需要尽可能地消除它们。但是就像其他事 物一样,凡事皆有例外。举个例子,程序员可能想要将一个 array 复制到另一个 array,并采用如下的 标准 for 循环: int [] src; int[] dest; //Assumes src and dest have same length… int size = src.length; For(int I = 0; i //Pop the top five values and call the //arraycopy method. 11 return //Return to calling method. 快速一下 bytecode 就能解释我们心中的疑惑。copyAray2()比copyArray1()体积小,因为它毕竟是使 用函数调用来完成工作,而非使用 inline 方式。由于 copyArray2()调用的 System.arraycopy()是以本 机函数(native method)实现的,因此它的执行速度更快。由于它是本机函数,所以它可以直接、 高效地移动[原始 array]的内在内容到[目标 array]。这个动作之快速足以消减本机函数的调用代价, 明显比一般 Java 代码快得多。但也由于它是一个本机函数(native method),所以这个调用动作的执 行速度在不同的平台上有所不同。 实践 41:优先使用 array,然后才考虑 Vector 和ArrayList Java2SDK 提供各式各样的 cllection classes(群集类)来存储对象,Vector 就是其中之一。许多程序 员倾向使用 Vector 作为简单容器,因为它非常方便使用,也很容易使用。Array 在很多方面都与 Vector 不同(参见实践)。其中一个主机差别是性能。考虑下面代码,它们分别使用 array 和Vector: 注意,这段代码仅仅只是对接收到的 array 或Vector 进行迭代动作(iteration),并不对二者所存储 的数据做任何操作。这只是个简单练习,用以比较 arrays 和Vector 的访问速度。你可能已经意识到 , 上述的 array 版本比较快,但你可能 没有意识究竟有多快。结果显示,ineratreArray()比iterateVector() 快40 倍以上。如果采用 Enumeration 或Iterator 来进行遍历,iterateVecor()甚至还会更慢(参见实践 39)。 Array 与Vector 之间存在如此鲜明的性能差异,原因在于: 1. Vector 的get()是synchronized(参见实践 34)。 2. 使用 Vector 时无论如何必须采用函数调用(参见实践 38)。 上述的 array 版本不需忍受这些。而且正如实践 4所讨论的,Java Vector 底部其实是以 array 实现而 成,只要你创建一个 Vector,也就创建一个 array 来存储和管理所有元素。 你可能打算用以替代 Vector 的另一个选择是 ArrayList。这个 class 基本上是一个 unsynchronized Vector,因而比 Vector 快得多。先前代码如果改用 ArrayList,看起来像这样: 这个函数比前述的 Vector 版本几乎快 4倍。但 array 版本仍然比 ArrayList 版本快上 11 倍。 既然如此,你是不是除了 array 不再用其他容器了吗?Vector 和ArrayList 还是非常有用的,一旦添 加的元素数量超过了当前容纳能力,它们会自动调整本身体积。不过你得为这个特征付出高昂的代 价。Vector 和ArrayList 都采用 array 作为底部设施,所以当这些 classes 调整自身大小的时候,会创 建一个新的 array,然后将所有元素从原先的 array 复制到新的 array 中,而后便可加入原本放不下的 新元素了。 Vector 和ArrayList 还有另一个不太起眼的副作用。无论什么时候,如果要从它们之中移除(removed) 一个元素,所有[下标大于被删除元素]者都需要向前(向着起始处)移动一格。这样可以使得底部 的array 不至于留下[空洞](holes)。不幸的是这项操作可能很费时(译注:视被移除的元素位置而定 )。 由于 Vector 和ArrayList 的这些性质,在其起始部位移除元素将会很费时间。被移除元素愈靠近尾 部,这项操作就愈快速。当客户将一个元素加入已无多余空间的 Vector 或ArrayList 时,整个底部 array 都得被复制一份。Vector 或ArrayList 愈大,复制时间就愈长。使用这些 classes 时,你可以通过[禁 止从头部移除]或[禁止在无额外空间的情况下增加元素]等等措施来优化它们的性能。 [添加元素却无多余空间]或[移除元素]时都需要重新整理 array(参见实践 40),幸运的是上述些些 classes 的实现代码都使用 System.arraycopy()来重新整理底部 array。System.arraycopy()可以令这些动 作尽可能高效。 得知 Vector 和ArrayList 的性能意义之后,当你面对性能至为关键(performance-criticl)的程序任务 , 请考虑使用 array。如果你需要 Vector 的功能,却又不需要它的同步特性(synchronization),请使用 ArrayList。只有在你既需要 Vector 的功能又挂面 要它固有的同步特性时,才使用 Vector。 不要仅仅因为手上有个数不定的数据需要存储,就毫无选择地使用 Vector 或ArrayList。如果你无法 确定需要保存的元素数量,可以考虑创建一个足以保存最大量数据的 array。通常这可能浪费很多内 存,但性能上的收益可能超过内存方面的代价。办有通过细致的性能评测(profiling)和对系统的 详尽分析,你才能做出正确的选择。 实践 42:尽可能复用(reuse)对象 创建对象,代价不菲(参见实践 32),因此你应该尽量减少创建次数。创建的愈少,意味代码执行 愈快。你或许经常需要创建大量对象,或重复创建相同对象。实践 32 和实践 43 略述了许多提升性 能的技术,其中包括对象的创建。另一项很方便使用的技术是:复用既有对象,而不重新创建一个 。 考虑下面这相例子,Employee 用以表示员工性质: 这个 class 有若干函数,用来设置各个值域(fields),还有一个函数用来返回某个 Employee 对象的 工资。 现在让我们假设一下这个 class 的用法。假设我们有一个函数,计算所有员工的工资总额。它接受三 个arrays 作为参数,第一个 array 存储员工姓名,第二个 array 存储员工职位,第三个 array 存储员 工识别码。这个函数根据 arrays 所存储的数据创建出 Employee 对象并查询员工工资,然后计算总 额。代码看起来像这样: 这段代码性能低落。症结在于,完全不需要在每一次循环循环迭代中创建一个新 Employee 对 象 。 这样做不但费时,需且降低了代码的执行速度。对这个函数而言,只需创建一个 Employee 对象然 后一再复用它即可。因此更高效的做法是这样: 上述函数只创建一个 Employee 对象,然后在需要时复用它。它的执行速度比原先版本快 5倍。为 了使主个技术发挥作用,class 必须提供一些函数,用来设置对象的各值域。例如 Employee class 必 须为姓名、职称和员工编号等值域提供设值函数(setter)。这么一来你就可以只创建一个对象而后 为每一次不同的运用设置相应值域值。 如果 class 没有提供这引动函数,但你可以接触源码,那也可以使用一种更高效的技术:我们不再调 用多个函数来设置对象的各值域,而是提供一个[再初始代函数](reinitialize method),以一次调用 代替多次调用。例如我们可以将 Employee class 和computePayroll()修改为如下形式: reinitialize(0比先前做法大约快 5%。虽然改进幅度有限,但如果经常使用这项技术,效果就会显 著。 另一个考量是:[复用]是否可行?如果你调用的函数保存的是 object reference,就不能使用这项技术 。 举个例子,你创建 Employee 对象,然后将它们存储在一个 Vector 中并传递给某函数,这时候你就 无法使用这项技术,因为 Vecotr 保存的是 object reference,并不创建对象的复件(copy)。如果想要 保存对象复件,则必须先克隆(clone)它。关于[克隆]及其应用于 V ectors 身上的范例和细节,请 参考实践 64 和实践 66. 如果函数在存储动作之前先针对 object reference 创建了对象复件(copy)或克隆件(clone),你 就 可以复用这个对象。这种情况下你可以有效运用这项技术来产生高效代码。 将对象收回复用还有更多益处。如果对象是被拿回复用而不是被解除费用(unreferenced),回收器 (garbage collector)需要做的工作就少一些。垃圾回收器所做的工作愈少,战胜的时间就愈少,你的 程序也就执行得愈快。 实践 43:使用缓式评估(延迟求值,lazy evaluation) 缓式评估(lazy evaluation,延迟求值)是一项与语言无关的技术,它会延缓计算,直到不能再拖为止 。 工作的推迟当然不会带来性能的提升,但如果被推迟的计算后来不再需要,从而不需执行的话,性 能就提升了。也就是说免除了非必要的工作。 考虑一个用来代表员工(employee)的大型对象实例。这个对象内含许多值域(fields),其中一些 又是对象,内含与员工相关的各项数据。在创建这个对象时,需要指定独一无二的识别码作为数据 库主键(key)。数据库内含了创建这个对象所需的一切信息。现在请你看看这个 Employee class 及 其相关的辅助类(support classes): 正如你所看到的,在创建 Employee 对象之前,需先读取数据为库,并先创建数个聚合对象(aggregate objects)。实践 32 曾经讨论过创建对象(尤其是重型对象)的高昂代价。Employee class 被视为重型 的,因为其中包含许多其他对象,而后者又可能包含另外一些对象。这使得创建 Employee 对象成 为一项费时的操作。 假设当我们创建 Employee 对象时并不需要用到相关所有数据。例如当我们写下这段代码: 这段代码支付了创建 Employee 对象的全部开销,但是在超出函数作用域(method scope)之前,它 只使用了一个值域(译注:就是 homeAddress)。啊呀,Employee 构造函数老老实实地创建了完整 的对象,而上要函数仅仅用到其中一小部分。我们的目标是降低对象创建成本,那么,削减这一类 开销的途径之一就是利用缓式评估(延迟求值)技术,令 Employee 对象的创建动作更廉价。 通过缓式评估(延迟求值)技术,让我们将各种计算推迟至最后必要关头。对 Employee 而言,意 味不在其构造函数中建构完整对象,而是先将所有值域(译注:都是object references)初始化为 null。 当你对一个 Employee 对象调用其成员函数时,这时候才建构出对象的必在部分以满足函数调用。 运用这样的思路,Employee 及其构造函修改如下: 在创建 Employee 对象时,构造函数的惟一功能就是在 class 的instance 数据中存储 employeeID 值 。 这使得 Employee 对象的创建速度提升不少。其他的 instance 数据都被设置成 null——这不需要构造 函数亲自动手,因为 null 是这些值域的缺少值。是的,在初始化某个 class 时,JVM 会在调用构造 函数本体之前先将所有 instance 变量设置为相应缺少值(详见实践 37) 对构造函数做了这样的修改之后,Employee 的函数也需要改动。例如 homeAddr()做了如下修改: 现在让我们研究一下在调用 prinHomeAddress()时发生了什么事情。首先创建一个 Employee 对 象 , 不含任何数据。为了日后使用,构造函数仅仅存储作为主键的识别码 employeeID。然后调用 homeAddr()。后都首先检查身为 instance 数据的 homeAddress 是否为 null。如果是,根据先前保存 下来的 employeeID 值域查询数据库,并从数据库取回(仅仅是)用来创建 AddressInfo 对象的必要 数据,而在创建后返回之。娄 printHomeAddress()返回,Employee 对象逾越其生存空间(作用域, scope)。 采用缓式评估(延迟求值)技术,你不再需要收集用不上的数据,从而得以节省大量时间。于是 Employee 对象的创建工作得以从非常昂贵变得非常低廉。 这项技术同样可以运用于数字计算。某些计算格外花费时间,通过缓式评估(延迟求值)的巧妙运 用,往往可以显著加速程序的执行速度。考虑下面这个为矩阵(matrix)设计的 class,其中含有矩 阵相加函数和相乘函数: 下面是这个 class 的一些用法: //… Matrix2D matrix1 = new Matrix2D(500,500); Matrix2D matrix1 = new Matrix2D(500,500); Matrix1.multiplyMatrix(matrix2); Matrix1.eleementAt(275,314); //… 这段代码创建出两个矩阵,将二者相乘,然后取回 250 000 个元素中的一个。如果其他 249 999 个 元素在[结果矩阵]超出生存空间(作用域,scope)之前没有被有到,两个矩阵的相乘工作就几乎白 费了。即便有四分之三的矩阵元素被访问,仍然浪费了 62 500 个相乘动作。这意味 CPU 资源的巨 大浪费。 较好的解决方案是采用缓式评估(延迟求值)技术。在 nultiplyMatrix()或addMatrix()执行的时候, 不对矩阵进行完全的相加或相乘。不,什么都不做,只保存[施加于矩阵身上之动作形式]的相关信 息。然后,当程序以 elementAt()取出矩阵某些区域的时候,才去计算(和返回)必要数据。这种方 式只做必要工作,略去无谓劳动。下面是实现这项技术的一个范例: 为了节省篇幅,上述代码省略了一些错误检查。注意,这段代码在一个时刻只保留一个动作轨迹。 例如,假设你创建了两个 Matrix2D 对象。第一个对象调用 addMatrix()并传入第二个矩阵对象。在 这次调用中,addMatrix()仅仅只是存储[矩阵相加]信息。如果在调用 elementAt()之前调用了 addMatrix()或multiplyMatrix(),则先执行[未决动作](pending action),而后才存储新动作(的相关信息)。 如果你希望让程序更加高效,则可以添加额外代码来存储(记录)更多的未决动作( 的相关信息)。 采用这项技术可以减少计算量,带来明显的性能收益。但是请记住,缓式评估(延迟求值)技术并 非总是能够带来更快速的代码。以上述矩阵为例,如果矩阵的所有数值都被访问,那么缓式评估(延 迟求值)技术反而会使程序速度迟缓,因为这项技术需要额外的数据和逻辑。中有在[为满足性能目 标,你可以免除够多的非必要计算]情况下,缓式评估(延迟求值)才是有益的。其基本策略就是[延 缓那些很可能永远也不需要进行的工作]。 实践 44:以手工方式将代码优化 实践 29 曾经讨论过,大部分 Java 编译器在编译 Java 源码时,只进行少得可怜的优化。由于 Java 编 译器在优化方面所做甚少,因而你可以自己动手优化源码,令编译器产生优化的 bybcode。一般说 来,你提供给 JVM 的bytecode 愈上乘,程序的执行速度就愈快。 本实践内容并未提供 Java 代码优化办法的详尽列表。优化工作可能非常复杂,实现起来非常费时, 造就是为什么它们往往留给编译器去做的原因。不过本实践内容包含了一些简单、通用的优化技术 , 可以手工完成,不费太多力气。 在实现这些优化技术之前,请先检验你所使用的 Java 编译据确实没有为你执行这些优化措施——只 要检查 Java 编译器生成的 bytecode 便可以确定这一点。只要使用 javap 工具并加上—c选项,便可 反汇编(disassemble)由javac 编译器生成的.cIass 文件。例如: javac Test.java javap –c Test > Test.bc 以上调用 javac 编译Testjava,产生 Test.class.javap 反汇编 Test.class,并将生成的 bytecode 保存于 Test.bc。 稍后出现的范例内含一些常见的、大多数 java 编译器并未实施(掌握、实践)的优化机会。这些范例 会在适当地点包含可被优化的 Java 源码,以及重新撰写优化源码。为了加深理解并强调.我挑选了 java 编译器生成的一些 bytecode 陈列出来,让你有所比较。 本实践条款研究下列优化技术: � 剔除空白函数(Empty method removal) � 剔除无用代码(Dead code removal) � 削减强度(Strength reduction) � 合并常量(Constant folding) � 删减相同的子表达式(Common subexpression elimination) � 展开循环(Loop unrolling) � 简化代数(Algerbraic simplification) � 搬移循环内的不变式(Loop invariant code motion) 剔除空白函数〔Empty method removal〕 空白函数往往普经包含过代码。经历一段时间的系统开发之后,某个函数内的代码被各式各样的程 序员删除,终于导致这个函数不再能够履行它最初被设定的任务。考虑以下代码,其中含有被 main() 调用的两个空白函数; 下面是函数 main()、foo()和bar()生成的 bytecode: Method void bar() 0 return Method void foo() 0 return Method void main(java.lang.Stirng[]) 0 new #1 3 dup 4 invokespecial #3 7 astore_1 8 invokestatic #6 11 aload_1 12 invokevirtual #5 15 return 尽管 foo()和bar()空空如也,无所作为,main()仍然调用了它们。你可以放心移除这些函数调用动作 , 使代码更高效,同时为.class 文件[瘦身]。 编译据应该将空白函数标记出来。如果编译器设那么做,你可以在开发周期的最后阶段通过代码审 查(code examination)找出这些空白函数(并移除之)。 删除无用代码(Dead code removal) 实践 29 曾经演示,在两种情形下 Java 编译器会删除无法触及的(unreachable)代码——或曰无用代码 (dead code)。不过无用代码可能隐匿深处,以至于 java 编译器无法去除它们。这些无用代码可能由 粗心大意的程序员造成,或是因为同一份代码多次被修改而造成。由于逻辑改变了,某些代码变得 无法到达了。这些多余代码徒然增加.class 文件的大小。剔除无用代码,可以产生更精简、更易于理 解的逻辑。考虑以下例子: int j = 5; int[] a = new int[10]’ for(int I = 0; i<10; i++){ a[i] = j; if(j ==5) a[i] = 25; else a[i] = 10; } 这个循环中的逻辑使得某些代码永远不会被执行。循环总是为 a[i]赋值 25。代码应该重新撰写如下: int[] a = new int[10]; for(int i = 0; i<10; i++) a[i] = 25; 更新撰写的循环比原先版本快 4%,也更清晰更直观。用这种方式撰写循环后 可以实施进一步优化——请见 P157 的展开循环(loop unrolling)技术。 削减强度(Strength reduction) 所谓[消减强度]技术,就是以更高效的操作替换成本昂贵的操作。一个常见的优化手法是使用复式 航值操作符(compound assignment operators)。考虑以下的代码和表达式: int x = 5; int[] a = new int[N]; for (int i=0;i<N;i++= a[i] =a[i] + x 表达式 a[i] = a[i]+x 所产生的 bytecode 是: 11 aload_2 12 iload_3 13 aload_2 14 iload_3 15 iaload 16 iload_1 17 iadd 18 iastore 现在以复式赋值操作符(compound assignment operators)重新撰写循环: int x = 5; int[] a = new int[N]; for(int i=0; i // Push the value for a. 3 getstatic #7 //Push the value for b. 6 iadd //Pop a and b, push their sum. 7 bipush 100 //Push 100. 9 iadd //Pop sum and store it at index 1 10 istore_1 //of the local variable table(c). 变量 c身上执行的 dx 个表达式不能被编译器优化。这是因为变量 a和b并非被声明为 finall,因此 不是常量,故 c身上的加法操作会在运行期(而非编译期)进行。如果将变量 a和b声明为 final,编 译器便视它们为常量,从而可以进行优化。修改后的代码是: static final int a = 30; static final int b = 60; int c = a + b + 100; 编译为以下 bytecode: 0 sipush 190 //Push 190. 3 istore_1 //Pop 190 and store at index 1 of the local //variable table(c). 由于将变量声明为 finall,使得加法操作得以在编译期就进行,导致 bytecode 以常量值进行运算。因 此当你声明常量性数据时,请使用关链词 flnal,让编译器得以执行优化措施。 删减相同的子表达式(Common subexpression elimination) 为了使代码清楚易懂,有时人们会重复写出一些常见而初级的表达式。你应这些重复表达式,以一 个临时变量替代之。举个例子: SomeObject[] someObj = new SomeObject[N]; someObj[i+j] = new SomeObject(); someObj[i+j].foo(k); someObj[i+j].foo(k+1); someObj[i+j].foo(k+2); someObj[i+j].foo(k+3); someObj[i+j].foo(k+4); 我们可以删掉重复的子表达式 someObj[i+j],改用更有效的构件(construct),例如 SomeObject[] someObj = new SomeObject[N]; someObj[i+j] = new SomeObject(); tempObj = someObj[i+j]; //Create temporary tempObj.foo(k); //Use it… tempObj.foo(k+1); tempObj.foo(k+2); tempObj.foo(k+3); tempObj.foo(k+4); 就对多次迭代而言,这样优化后的代码比原先版本快两倍,而且体积更小。 展开循环(Loop unrolling) 展开循环的好处在于省略了[循环结构与分支]代码,从而产生更快的执行效果(译注:相当于变相的 inline)。它的缺点是会产生更多代码,使得.class 文件变大。 下而是一个典型循环: int[] ia = new Int[4]; for (int i=0; i<4; i++) ia[i] = 10; 展开后变成; int[] ia=nev int[4]; ia[0] = 10; ia[1] = 10; ia[2] = 10; ia[3] = 10; 这个展开后的循环比原先版本快 7%左右。 简化代数(Algebraic sum plification) 这项技术乃是利用代数规则(rules of algebra)来简化表达式。这种简化方法可以得到既精炼又快捷的 代码。考虑以下代码: int a = 1; int b = 2; int c = 3; int d = 4; int e =5; int f = 6;; int x = (f*a) + (f*b) + (f*c) + (f*d) + (f*e); 这段代码用到了 4个加法和 5个乘法。利用代数法则,你可以重新撰写并简化代码如下: int a = 1; int b = 2; int c = 3; int d = 4; int e =5; int f = 6;; int x = f*(a+b+c+d+e); 改写后的代码仍有 4个加法操作,但只保留了 1个乘法操作。新的版本更简单,生成的代码也比原 先的版本更短小更快速。 移动循环内的不变式(Loop invariant code motion) 所谓循环不变式(loop invariant)是指[位于循环之内但可被安全地移至循环外]的表达式。这样做可以 省下[为每一次循环迭代而计算表达式]的消耗时间。考虑以下代码(这里再次列出实践 29 所使用的 例子): int a = 10; int b = 20; int[] arr = new int[val]; for(int i=0; i //Create an object of type java.lang.Object //and push a reference to it(lock). 3 dup //Duplicate the top stack value and push it. 4 invokespecial #3 //Pop the object reference (lock),and invoke //its constructor. 7 astore_1 //Pop the object reference(lock) and store //it at index 1 of the local variable table. 创建一个元素个数为 0的array,并不像创建对象那样需要调用构造函数,所以速度会 快一些。此外,内含元素的 byte arrays 往往在 JVM 中有着比 int arrays 更紧凑的表述形 式。 上述两种做法中的任何一种都使代码成为[多线程安全](thread safe)。记住,同步控制[通 过instance 函数或 object reference 所取得的 lock]。完全不同于同步控制[通过 static 函 数或 class literal 所取得的 lock]。两个函数被声明为 synchronized 并不就意味它们具备 多线程安全性。你必须小心识别和区分通过同 步控制所取得的 locks 之间的微妙差异。 实践48484848:以[private [private [private [private 数据++++相应的访问函数((((accessoraccessoraccessoraccessor)])])])] 替换 [public[public[public[public/protected protected protected protected 数据]]]] 使用 synchronized 函数编码,目的在于使数据免遭混乱之灾。为了适当保护数据, 你必须确保正确地声明和访问它们。如果不能正确保护数据,无论你设置了什么样的同 步机制,class 的用户都有可能迂回而过。 举个例子,考虑以下 class,其中内含两个操控 array 的函数,该 array 被声明为 class 的 instance数据。两个函数都被声明为 synchronized,以确保不会并发地(concurrently)对array 进行增删动作。这个 class 是多线程安全(thread safe)吗? 这个class 不具[多线程安全性]!即使共享同一个 array 的那两个函数被声明为 synchronized,漏洞依然存在。由于数据并未被声明为 private,另一个线程可以绕过 synchronized 的函数,直接访问数据。例如: Test tst = new Test(); Thread t = new Thread(tst); t.start(); tst.intArray = null; 这段代码创建了一个 Test 对象,并在次线程(secondary thread)中执行其代码。 然后主线程直接访问 Test 的public 数据,将 intArray 值域设为 null。当次线 程调用 synchronized addToArray()时,该函数将会抛出一个 NullP0InterExceptlon 异 常 。 这是由于 addToArray()执行之际主线程已经将 intArray 变量(值域)设为 null。 问题的症结在于,intArray 不是 private 成员。如果它是,主线程就没有办法直接访问它 。 要想完全保护 synchronized 函数中的数据,必须令这些数据成为 class 的private 成 员 。 这是确保[数据免遭讹用,并且 class 具备多线程安全性] 的唯一方法。因此,上述 class 的 正确写法是: 如此一来它就具备[多线程安全性]了。但是,当你添加一个 public 访问函数(accessor)并 令它返回[目前已成为 private]的intArray 值域的 object reference 后,情形又如何呢?下面这个 class 还具备[多线程安全性]吗? 这个 class 又不具备[多线程安全性]了。问题在于 integerArray()返回一个 object reference, 指向 private 数据成员 intArray。这意味我们可以撰写如下代码: Test tst = new Test(); Thread t = new Thread(tst); t.start(); int[] temp = tst.integerArray(); temp[5] =1; 由于 integerArray()返回的 object reference 指向 intArray,尽管 addToArray()正在执行,这 段代码仍然可以修改 array 内容。你可以这样修正此问题;提供一个 synchronized 函数, 负责 cloning(克隆)intArray,然后返回一个 reference 指向这个新的 array 对象。这种方法 将使得 integerArray()返回的 reference 指向另一个 array 对象,而不是指向原本的 intArray。例如: 这个 class 现在具备[多线程安全]。integerArray()必须声明为 synchronized,以确保 array 在cloning(克隆)过程中不被篡改。添加的 clone 行为会带来一些开销,但这是[多线程安 全]所必需的。关于clone 技术的详细分析,包括浅层(shallow)和深层(deep)的clone 技术 , 请见实践64 64 64 64 和实践66666666。 记住,对于[在synchronized 函数中可被修改的数据],应使之成为 private,并根据需要提 供访问函数(accessor)。如果访问函数返回的是可变对象(mutable object),那么应该先 cloned(克隆)该对象。 实践49494949:避免无谓的同步控制 Java 提供了同步机制(synchronization),使得[访问同一对象]的多个线程可以安全地执 行。然而,增加无谓的同步控制,几乎和遗漏必要的同步控制一样糟糕。 有些时候,汲汲寻求[多线程安全性]的程序员对太多函数进行了同步控制。过度 的同步控制可能导致代码死锁(deadlocks)或执行缓慢(如果你想更多看看同步控 制所付出的代价,请参考实践34343434)。你的软件的主要目标之一,是高效执行而且不发生 死锁。所以你应当避免无谓的同步控制。 由于 synchronized 关键词实现方式的缘故,常常导致无谓的同步控制,造成并发 度(concurrency)的降低。考虑以下 cIass: 这个 class 无疑是[多线程安全](thread safe)的。为确保 arrays 不因多线程并发访问而遭 受损坏,每个函数都必须声明为 synchronized。例如由于 method1()和 method2()都访问(并可能修改)arrays ia1 和ia2,所以必须同步控制。method3() 和method4()也是如此。 然而请你注意,尽管 method1()与method2()彼此之间必须进行同步控制,但它们 却不需要与 method3()或method4()进行同步控制。这是因为 method1()与method2()并不 操控 method3()或method4()所操控的数据。反之亦然。 不幸的是,在某些 classes 中,这种 instance 函数的同步控制方式并不少见。Java 同步 控制机制其实并不十分粒度化(granular)。同步机制对每个对象只提供一个 lock,如果你 创建了上述 Test 的一个对象并在主线程中调用 method1(),若在次线程中调用 method3(), 你就付出了非必要的性能代价——这些函数彼此同步控制(尽管它们其实不需要如此)。 记住,当一个函数声明为 synchronized,所获得的 lock 乃是隶属于调用此函数的那个对 象。因此两个函数都试图争取同一个 lock. 为了修正这个问题,你需要为每个对象配备多个 locks。Java 没有提供这项功能, 因此你不得不设立自己的机制。—种做法是创建一些对象作为 instance 数据,这些对象 仅仅用来提供 locks(以下代码之所以采用[无任何元素之 byte array]用以锁 定对象,理由详见实践47474747)。先前代码采用这项技术后.修改如下: 请注意,这段代码不再拥有 synchronized 函数,它们被去掉了,取而代之的是函 数内的 synchronized 区段。这使得同步控制得以发生在不同的对象身上,并让[安 全并发](safely run concurrently)的函数做到这一点(译注:亦即提高了并发度)。例如 method1()可以和 method3()或method4()并发执行,因为它们争取的是不同对象的 locks。 使用这项技术时必须十分小心。你必须确信,你认为[无需同步机制操控]的函数, 的确可以达到预期目标而不至于引发错误。要切道,对多线程程序进行调试是非常耗时 的,绝对不要轻率地使用这项技术。 你或许会奇怪,为什么 method1()和method2()不同时锁定 ia1 和ia2,而是锁定 lock1 对 象。那样做也行,但很容易出错。当你锁定多个对象的时候,必须确保在代码中自始至 终使用同样的顺序锁定它们,否则可能导致死锁(deadlock)。有关这个问题及其避免之 道详见实践52525252。 实践50505050;访问共享变量时请使用 synchronized synchronized synchronized synchronized 或volatilevolatilevolatilevolatile 如果变量在线程之间被共享,你必须总是正确地访问它们,以确保操控所得的是正确有 效的数值。JVM 保证 32bits(或更少量)的数据读写是不可分割的(atomic)。这可能使得某 些程序员相信,访问共享变旦时无需进行同步控制,或不需要将变量声明为 volatile。 考虑下面这段代码: 现在让我们假想这段代码的使用方式。我们有可能创建一个RealTimeClock 对象和两个 线程。然后从两个线程中调用这个 class 所提供的函数。 变量 clkID 和clockTime 存储于主内存(main memory)内。但Java 语言允许线程保存这些 变量的私有专用副本(private working copy),这样可以使我们的两个线程更高效地运行。 当线程读写这些变量时,它们操作的是[私有专用副本],而非主内存中的[正本]。[私有 专用副本]只在特定的同步点(synchronization point)才与主内存的[正本]进行一致化动 作。 clockID 和setClockID()仅仅只是对 int 数据进行相应的读和写,所以这些函数的动作自 动成为[不可分割的](atomic)。但如果变量 clkID 存储于各线程的[私有专用副本]中 , 请考虑以下可能发生的事件序列(enents sequence): 1.线程 1调用 setClockID(),传入数值 5。 2 线程 2调用 setClocklD(),传入数值 10。 3 线程 1调用 clockID(),返回数值 5。 这种事件序列是可能发生的,因为变量 clkID 不保证与主内存内容维持一致。在步骤 1 中,线程 1于自己的专用副本中放置了数值 5。线程 2执行时,又在自己的专用副本中 放置了数值 10。到了步骤 3,线程 1从其专用副本中读取数值,返回 5。这些数值没有 机会与主内存中的[正本]一致化。 两种做法可以改正这个问题: 1 通过 synchronized 函数或 synchronized 的区段,访问 clkID 变量。 2.将 clkID 变量声明为 volatile。 每一种做法都会迫使 clkID 变量与主内存中的正本[一致化]。通过 synchronized 函数或 synchronized 区段访问 clkID 变量,尽管会造成那些代码无法被并发执行(execute concurrently),但确实保证了主内存中的 clkID 变量获得适时的更新。当[受保护码] (protected code)执行之前获得对象的 lock 时,主内存会更新;当[受保护码]执行之后释 放对象的 lock 时,主内存也会更新。 如果你将 clkID 变量声明为 volatile,代码可以并发(concurrently)执行,并且保证 clkID 变量的[私有专用副本]与[主内存内的正本]保持一致。不过这种一致化动作是在每次访 问这个变量的时候发生。 接下来再考虑 time()和setTime(),它们的操作对象是 long。这些函数不仅有着稍早描述 的相同问题,还有其他问题。1ong 数据一般采用 64btb(跨越两个 32—bit words)表述。或许某些 JVM 实现产品将 64—bibs 操作视为不可分割的(atomic),但现 今大部分 JVM 实现产品都不这样,而是将它视为两个独立的 32—bit 操作。考虑以下可 能在 instance 变量 clockTime 身上发生的事件序列: 1.线程 1调用 time()。 2.线程 1开始读取 instance 变量 clockTime,并读取第一个 32bits. 3.线程 1被线程 2抢先(preempted)。 4 线程 2调用 setTime(),对 instance 变量的两个 32bits 进行操作,以不同的数值替 换了两个 32bits。 5.线程 2被线程 1抢先(preempted)。 6 . 线程 1读取 instance 变量 clockTime 的第二个 32bib,并返回。 按照这种事件序列,tims()返回的数值将由 clockTime 旧值的前 32bits 及其新值 的后 32bits 组成。这个返回值是错误的。这是因为,面对 64-bit 数据,JVM 必须 执行一次以上的读写动作。先前讨论的[私有专用副本]与[内存]也是个问题。欲改正此 类问题,有两个选择:同步拧制对 clockTime 变量的访问,或是将它声明为 volatile。 总而言之,不可分割的操作(atomic operations)并不意味[多线程安全],理解这一点很重 要。此外,只要多个线程共享某些变量,它们就必须被访问于 synchronized 函数或 synchronized 区段内,或是被声明为 volatile。这样做可以确保变量与主内存完全保持一 致,从而在任何时候得到正确数值。 应该使用 volatile 或是 synchronized?这取决于多个因素。如果并发性(concurrency)很 重要,而且你不需要更新很多变量,则可以考虑使用 volatile。如果你需要更新许多变 量,使用 volatile 可能要比使用 synchronization 降低执行速度。记住,一旦变量被声明为 volatile,在每次访问它们时,它们就与主内存进行一致化。但如果使用 synchronized, 只有在取得 lock 和释放 lock 的时候,才会对变量和主内存进行一致化。 如果你要更新许多变量,而且不情愿为每次访问都付出[一致化]代价,或是你因 为其他什么原因打算消除并发性(concurrency),可考虑使用 synchronized。 下表总结了关键词 synchronized 和volatile 的区别。 表四: volatile 和synchronized 实践51:51:51:51: 在单一操作(single(single(single(single operation)operation)operation)operation)中锁定所有用到的对象 某个函数对不只一个对象进行操作,这是司空见惯的情形.仅仅对此函数进行同步控制,未必就能够让 程序拥有[多线程安全性].你必须仔细分析它操作了哪些对象,以及如何操作。 如果某个 synchronized 函数调用了某个 non-synchronize instance 函数来修改对象,它是[多线程安全] 的.这是因为一旦 non-synchronied 函数被 synchronized 函数调用,它也就变为 synchronized.但如果一个 synchronized 函数直接修改的对象并非所属之 class 的private instance 数据,程序就不具[多线程安全 性]. 举个例子,考虑以下程序,计算两个 arrays 总合.假设传入这个函数的 array 并非引对象的 instanc 数据。 技术 优点 缺点 synchronized 取得和释放 lock 时,进行私 有专用副本 与主内存正本的一致化 消除了并发性的可能 volatile 允许并发行 每次访问变量,就进行稀有专用内存与对 应之主内存的一致化 . 这段代码的疏漏在于,尽管函数被声明为 synchronized,它所操纵的对象却并非如此.记住,关键词 synchronized 锁定的是对象,而非函数或代码(参见实践 46).因此,当另一个线程改变未被锁定之对象 (内容)时,sumArrays()函数依然可以执行.这各情形下会产生错误结果。 的确有些时候仅仅同步控制一个函数是不够的,你还必须同步控制此函数所处理的对象.要使上述代 码正常工作,你必须有在访问 array 对象之前先锁定它们,以确保在循环期间它们不被篡改.先前代码 应该修改如下: 如你所见,函数的 sychronized 修饰符被去掉了,取而代之的是函数本体中的两个 synchronized 语 句.sumArrays()不再对其调用者(某对象)进行同步控制,而是对它所处理的对象进行同步控制.由于这 样的改进,循环中的 arrays 不可能被其他所处理的对象进行同步控制.由于这样的改进,循环中的 arrays 不可能被其他线程改变.不过锁定多个对象有可能导致死锁.关于死锁及其避方法,详见实践52。 使用同步控制时,一定要对关键词 synchronized 的所作所为牢记在心.它锁定的是对象而非函数或代 码.理解这一事实,将使你写出具有正史行为的代码,不至于沦为此类错误的牺牲品。 实践52:52:52:52:以固定而全局性的顺序取得多个 locks(locks(locks(locks(机锁))))以避免死锁 当两个或多个线程因[互相等待]而阻塞(blocked)时,就发生了死锁(deadlock).举个例子,线程 A等待取 得线程 B持有的资源 b,因而被线程 B阻塞;线程 B则必须在取得线程 A持有的资源 a之后才释放它 持有的资源 b.由于线程 A不会在获得线程 B的资源 b之前先释放它持有的资源 a,而线程式 B也不 会释放其资源 b,除非它得到了线程 A的资源,两者因而僵持不下,没完没了。 死锁是多线程程序最难解的问题之一.发现并修正这类问题的过程,不但困难而且耗时,因为它可能出 现在最不易想到的地方.例如,考虑以下程序,其中锁定了多个对象(与实践 51 的例子相同)。 这段代码在进行过程中,于处理两个 array 对象之前先正确锁定了它们.代码不仅短小、简明,并且对 其任务而言恰到好处。但是很不幸,它有一个潜在问题。问题在于它制造了一个潜在的死锁陷阱, 除非另一个线程式对同一个对象调用此函数时格外小心谨慎。为了看清这个潜在的死锁,请考虑以 下的事件顺序: 1. 创建两个 array 对象,ArrayA 和ArrayB。 2. 线程 1调用 sumArrays 函数:sumArrays(ArrayA,ArrayB); 3. 线程 2调用 sumArrays 函数:sumArrays(ArrayB,ArrayA); 4. 线程 1开始执行 sumArrays 函数,获得位置//1 的参数 a1 的lock,那是 ArrayA 的lock。 5. 接着,线程 1还没来得及获得位置//2 的ArrayB 的lock,就被线程 2占先了(preempted)。 6. 线程 2开始执行 sumArrays(),获得位置//1 的参数 a1 的lock,那是 ArrayB 的lock。 7. 线程 2试图获得位置//2 的参数 a2 的lock,亦即 ArrayA 的lock。此时线程 2被 阻 塞(blocked), 因为这个 lock 掌握在线程 1手中。 8. 线程 1开始执行,试图获得位置//2 的参数 a2 的lock,亦即 ArrayB 的lock。它也被阻塞了 (blocked),因为这个 lock 掌握在线程 1手中。 9. 两个线程于是处在死锁状态。 避免此类问题的一种做法是,令代码以固定且全局性的顺序取得 locks。本例中,如果线程 1和线程 2以相同的参数顺序调用 sumArays(),死锁就不会发生。这便是要求撰写多线程码的程序员,在调用 那些[于函数本体内将对象参数锁定]的函数时要格外小心。实施这样的技术睦起来似乎有点过分, 但如果哪天你遭遇此类死锁而不得不[解开]它们时,你就不会那么想了。 另一种做法是,将[锁定顺序]嵌入对象内部。这就允许代码征询[即将取其 lock]的那个对象,确定锁 定顺序。只在被锁定的对象都支持[锁定顺序](locking order)的观念,而且抓取 locks 的代码都坚 守这个策略,你就可以避开这一类潜在的死锁[陷阱]。 将锁定顺序嵌入对象内部的缺点在于,这种实现方式必须付出额外的内存和运行期代价。此外,欲 在先前例子中应用这项技术,每个 array 都需要一个外覆对象(wrapper object)用以内含锁定顺序。下 面是以[锁定顺序]技术修改后的代码: 其中 ArrayWithLockOrder class 被用做原先先例子中的 array 的外覆类(wrapper);每当为它创建一个 对象,它就将 static 变量 num_locks 的数值加 1。另一个 instance 变量 lock_order 将被设为 num_locks 的当前数值。这样可以确保此 class 的每一个对象拥有独一无二的 lock_order。Lock_order 被除数视 为顺序指示器(indicator),用来表明这个对象和相同类的其它对象和相同类的其他对象的先后关系。 请注意,对 static 变量 num_locks 操控处于 synchronized 语句控制之下。这么做是必要,因为同一 个class 的任何对象都共享其 static 变量。因此如果两个线程并发(concurrently )创建 ArrayWithLockOrder 对象,而操控 static 变量 num_locks 的代码又非 synchronized,这个变量内容将 会变得杂乱不堪。同步控制这些代码可以确保 ArrayWithLockOrder 对象的 lock_lrder 变量具有各不 相同的值。 sumArrays()也有了新的写法,其中包括断定[正确锁定顺序]的代码。申请locks 之前必须先向每一个 对象征询其锁定顺序。而后先锁定小数字(代表先被锁定)的对象。这些代码可以确保无论对象以 什么次序传入这个函数,它们叫以相同的顺序被锁定。 Static num_locks 值域和 lock_order 值域,都江堰市以 long。Long 型别是以 64-bit 带下负号的 2补码 方式实现。这意味 num_locks 和lock_order 将在创建了 9 223 372 036 854 775 807 个对象之后溢出。 你不太可能超出这个限制,不过世事难料。 嵌入[锁定顺序]需要额外的一些工作、内存和执行时间。不过如果此类死锁情形有可能出现在你的 代码中,你就会发现这样做非常值得。如果你承担不起额外的内存和执行代价,或者不愿面对 num_locks 和lock_order 值域的溢出可能性,你就应当为[锁定对象]这文件事谨慎地建立一个缺省顺 序。 实践53535353: 优先使用 notifyAllnotifyAllnotifyAllnotifyAll()而非 notifynotifynotifynotify() 本章先前提出的实践条款讨论了对象锁定(locks)同步机制(synchronization),以及围它们的各种 问题。同步机制使得线程之间可以相互协作,不会在同一时刻访问某个对象。Java 也允许事件通知 (event notification)机制。当一个或多个线程期待某个特定事件发生时,很需要这种特性。 Java 提供了就 wait()、notify()和notifyAll()函数,从而使[事件通知]变得更容易.notify()和notifyAll ()用以唤醒处于等待状态的线程,wait()则让线程进入等待状态.notify()和notifyAll()有什么不同?什 么时候你该使用其中某个?什么应该使用另一个? Notfiy()仅仅唤醒一个线程.当你知道只有一个线程处于等待状态时,这是很方便的.notify()的不 足在于,当多个线程处于等待状态,你无法预期被唤醒的是哪一个线程谁离线由 JAM 说了算,不爱你 的控制.JVM 在挑选出线者时,并不一定以最高优先权(prority)为依归。 假设三个线程正在等待某个 object lock.其中之一期待某个 boolean 变量值由 false 变为 true。后来, boolean 变量值的那段代码调用了 notify().notify()仅仅唤醒一个线程。由于有三个线程正在等待,被 除数唤醒的那一个可能并不是[对bollean 状态变化感兴趣]的线程式。真正等待[剥离了案状态变化] 的那个线程可能并未接获通知。 只有在两个前提下,使用 notify()才是安全的: 1. 只有一个线程式正在等待,因此保证被除数唤醒的一定能够是它。 2. 多个线程式正等待同一条件成立,哪个线程式被除数唤醒都江堰市无所谓。 对比的是,notifyAll()唤醒所有等待中的线程。调用这个函数可以确保:只在你有一个线程正在 等待某个条件,它就一定会被唤醒。在先前例子中,如果修改 boolean 变量值的那段代码调用 notifyAll(),所有等待中的线程式都江堰市将被唤醒。 因此,除非符合前述两个条件,否则使用 notifyAll()更好一些,因为它保证唤醒反有等待中的线程 式。记住,唤醒所有线程并非意味它们都会获得 lock,而是意味它们都被唤醒,然后竞逐 lock。 不幸的是,notifyAll()和notify()一样,没有提供调用一个途径,指定以何种顺序通知(唤醒)线程 式。唤醒顺序由 JVM 决定;除了保证所有等待中的线程都被唤醒之外,不做任何其他保证。线程 未必以优先权(priority)顺序接获通知。当需要[以特定顺序]通知多个线程时,你将面临困境。 为了解决这个问题,你不得不自行发展解决方案。有一个常见的解法名为 SpeciflcSpeciflcSpeciflcSpeciflc NotificationNotificationNotificationNotification PatternPatternPatternPattern,<>一书对它做了详细的描述。这个模式(pattern)非常有 用,因为它能够让你掌握通知(唤醒)线程的顺序。 让我重申先前关于线程、notify()和notifyAll()的要点。线程式的优先权(priority)不能确保线程式 一定被 notify()唤醒,也不能确定各线程被 notifyAll()以何种顺序唤醒。所以千万不要对[线程被唤醒 的顺序]有任何假设和依赖。永远不要对[线程如何被排程]有任何假设。[线程被唤醒的顺序]有任何 假设和依赖。永远不要对[线程如何被排程]有任何假设。[线程排程](thread scheduling)是与操作系 统相依的,会因平台不而不同。如果你打算让人的代码将来可以顺利移植,就不要对[线程排程]有 任何轻率的假设。 实践54:54:54:54:针对wait()wait()wait()wait()和notifyAll()notifyAll()notifyAll()notifyAll()使用旋锁(spin(spin(spin(spin locks)locks)locks)locks) 当你对多个线程进行同步控制(synchronizing),以便访问共享数据时,你经常需要使用wait()/notifyAll() 通告机制(notification mechanism).使用这些函数时,应该运用 spin-lock pattern(旋锁模式),你的代码才 能正常运行. 为了说明方便,让我们考虑机器人控制器程序.Robotontroller class 用来控制连接于计算机上的各个机 器人的动作.控制器和机器人都在独立线程中执行.控制器提供一个命令表,存储在 Robot class 的static commands 值域中.任何机器人都可以执行这些命令.当某个机器人完成某个命令,它就将该命令设为 null,然后等待来自控制器的更多命令。 机器人通过[对控制器对象调用 wait()]来等待命令。数据到达后,它们就执行命令表中的命令,然后 再回复等待状态。控制器+两机器人的代码看起来像这样: 注意,Robot class 的run()首先在位置//1 获取 RobotController object lock。此后 Robot 对象可以对命 令表进行操作,而 Robot/Controller 对象不能修改它。注意,RobotController 的loadCommands()在位 置//4 也要求获得相同的object lock.。同步控制这个函数可以确保机器人的命令表在使用中不被修改 。 Robot 在位置//1 获得 lock 后,位置//2 再次检查是否存在等待处理的命令表。如果不存在这样的一 个表,位置//3 对RobotController 对象调用 wait()。这个调用释放了位置//1 获得的 lock,并进入等待 状态。当 RobotController 的loadCommands()被调用,则调用 notifyAll(),于是唤醒所有 Robot 线 程 。 同一时刻下只有一个线程可以获得 lock,而其他线程仍然必须等待。获得 lock 的线程现在可以访问 命令表,移动机器人。 这段代码藏有[臭虫],某种情形下会引发失败。考虑下面的事件序列: 1. 创建一个 RobotController 对象,以及两个 Robot 对象。所有这些对象都执行于各自的线程中 。 2. 第一个 Robot 线程在位置//2 查看 commands 变量是否为 null。 3.Commands 变量为 null,于是第一个 Robot 线程调用 wait(),受用(blocked)于位置//3。 4. 第二个 Robot 线程在位置//2 查看 commands 变量是否为 null。 5.Commands 变量为 null,所以第二个 Robot 线程调用 wait(),同样受阻于位置//3。 6. RobotController 的loadCommands()被调用,接收一个命令表。 7. loadCommands()调用 notifyAll()。 8. 两个阻塞线程被唤醒,二者都试图获得 RobotController object lock。 9. 只有一个线程可获得 lock,假设由第一个 Robot 得到。在第一个 Robot 线程释放 lock 之 前 , 第二个 Robot 线程不会得到 lock。 10. 第一个 Robot 线程处理命令,然后设置 commands 变量为 null,释放 lock。 11. 第二个 Robot 线程获得 lock,尝试处理命令。 12. 由于 commands 变量为 null,代码失败,产生 NullPointerException 异常。 这段代码的问题在于,在处理命令之前没有重新检查 commands 变量值。因为调用 notifyAll()之后唤 醒了所有线程,它们以[无法被预言的顺序]最终都获得了 object lo ck(参见实践 53)。当它们获得 lock 开始执行的时候,是从 wait()当初被调用处开始(继续)执行。当一个线程被唤醒,它必须重新检 查它的等待条件。这是因为它可能不是第一个起而运行的线程,当初的等待条件可能已经变化了。 要这个例子中,第一个执行的线程改变了条件变量值。第一个线程将 commands 设置为 null。由于 没有重新检查 commands 变量值,所以第二个线程失败了。记住,只在代码等待着某个特定条件, 它就应当[在一个循环内](或谓旋锁,spin lock)做那件事(译注:那件事指的是[等待着某个特定 条件])。Tobot run()的正确实现方式如下: 代码只执行了一行:位置//1 的if(commands == null)修改成一个 while 循环。根据这段代码,每一个 被唤醒的线程在继续执行之前重新测试它等待的条件。每个线程在访问 commands 变量之前都先查 看命令表是否依然为 non-null。这是必须的,以防备先前被唤醒的线程修改了引变量值。 注意,wait()抛出的 InterruptedException 被忽略了。通常这种行为并不好(详见实践经 17),不 过 配 合恰恰可以例外。执行被中断时,你可以选择忽视异常,因为使用 spin lock(旋锁)的代码将重新 测试条件。不满足,线程将调用 wait()再次进入等待状态。如果 InterruptedException 在你的设计中 意味错误,则不应该忽视这个异常。 Spin lock(旋锁)不仅简洁、廉价、而且能确保[等待着某个条件变量]的那些代码[循规蹈矩]。如果 你忘了使用它们,你的代码有引起时候仍然可以正常工作,然而一旦线程有了某种[巧遇],你的代 码将会失败。偶尔才会失败,意味代码中有更难对付的臭虫。利用 spin lock(旋锁)可以除掉这些 潜在臭虫。 实践55555555:使用 wait()t wait()t wait()t wait()t 和notifyAll()notifyAll()notifyAll()notifyAll()替换轮询循环(pollingpollingpollingpolling loopsloopsloopsloops) 在更高级的通信机制(communication mechanisms)尚未出现之前,程序员必须依靠其他技术在系统 各部位间通信。常用的技术之一就是 polling loop(轮询循环)。 Poloing loop 由线程式中位于循环内的代码组成,它不断测试某个特定条件。轮贸易线程(plooing thread)等待的条件终会被另一个线程改变。一旦条件满足,代码会执行一些任务。如果条件不满 足,代码可能短暂休眠,而后再次测试该条件。举个例子,考虑下面例子,其中不断 polling,直到 pipe 提供数据: 程序以一个 ReadFromPipe 对象在独立的线程式中负责执行 polling(轮询)。Run()内含一个无限循环, 不断询问 Pipe 对象是否有数据可读。如果没有,polling 线程休眠 200 毫秒,然后再次询问。 这个代码可以工作,但性能低下,国为 polling loop 占用了 CPU 时间(processorcycles)。即使 pipe 之中没有数据,plolling 线程依然要求 CPU 向pipe 询问数据。 更高效的做法是使用 wait()并搭配 notify()或notifyAll()。只要正确使用这些函数,便可消除 polling(轮 询),避免浪费 CUP 资源。以下使用 wait()和notifyAll(),重新拟定排行的代码: 这段代码的优点是在位置//1 调用 pipe.wait()。调用 wait()时会释放 pipe object lock,暂停此一线程。 被暂停的线程并不会花费 CPU 时间。它将保持暂停状态,直到被 notify()或notifyAll()唤醒(参见实 践53),或是被中断(interrupted)。当这个线程被唤醒,再次调用 pipe.wait()进入暂停状态并直到被 唤醒,否则就开始处理它所收到的数据。 实践 34 曾经讨论过同步机制的高昂价。这些代价不应误导程序员回避同步机制而倾向于 polling loop,因为后者经前者性能低。运行前述两个例子便可看出,同步控制版本比 polling loop 版本快上 好几个数量级。所以你应当尽可能避免采用 polling loop(但实践 58 有一个不得不使用的例子)。 所以,请支队代码中的 polling loop,代之以 wait()、notify()或notifyAll()所形成的 spin lock(旋 锁, 见实践 54),这将使你的程序更高效。这些函数令等待状态下的线程暂停(虚悬),而不是没完没了 地查询(因而占用 CPU 资 源 )。 实践56:56:56:56:不要对 lockedlockedlockedlocked object(object(object(object(上锁对象))))之objectobjectobjectobject reference reference reference reference 重新赋值 实践 46 已经告诉你,关键词 synchronized 用来锁定对象.由于对象是在 synchronized 代码内被锁定,这 意味着什么呢?如果修改其 object reference,又意味什么呢?注意,同步控制对付的是对象,被锁定 的只是对象本身;但你必须小心,不要对上锁对象(locked object)的 object reference 重新赋值。如 果那么做会发生什么事?考虑下面这一段 stack 实现代码: 这段代码以 array 为底部结构,实现出 stack。首先创建一个初始大小为 10 的array,用来保存整数。 这个 class 实现了 push()和pop()用以模拟 stack 的用法。在 push()函数中,如果 array 没有足够空间 存放被压入的数值,就重新分配 array 创建出更多存储存储空间。注意,这个 class 请注意不使用 vector,困为我们无法在 vector 中存储基本型别数值。Arrays 和vector 的更多信息,请参考实践 4 和实践 41。 注意,这段代码打算被多个线程访问。Push()和pop()对class 内被共享的 instance 数据的每一次访问 , 都是在 synchronized 区块中进行。这样可以多个线程不会并发(concurrently)访问 array 而导致错误 结果。 这段代码有一个重大的缺陷。它对Stack class 的整数型 array 对象(由intArr 指向)进行了同步控制 。 当push()重新分配 array 时,问题逐渐浮现出来。彼时 object reference intArr 被重新赋值,指向一个 新建的、更大的 array 对象。请注意,这个情况发生在 push()的synchronized 区块运行期间。这个区 块对 intArr 所指之对象进行了同步控制。于是,该 synchronized 区块中被锁定的对象不会再被用到。 考虑以下的事件序列(events sequence): 1. 线程 1调用 push(),获得了 intArr object lock。 2. 线程 2被线程 2占先(preempted)。 3. 线程 2调用 pop(),于是被阻塞(blocked),因为它试图获得线程 1目前持有的 lock,该 lock 是线程 1通过 push()获得的。 4. 线程 1重新获得控制权,并重新分配 array。intArr 现在改指向另一个对象。 5.Push()退出,释放原本的 intArr object lock。 6. 线程 1双一次调用 push(),获得新的 intArr 对象的 lock。 7. 线程 1被线程 2占行(preempted)。 8. 线程 2获得了旧的 intArr 对象的 lock,并试图访问其内存。 现在,线程 1拥有一个由 intArr 指向之新对象的 lock,线程 2拥有一个由 intArr 指向之旧对象的 lock。 由于这两个线程持有不同的 locks,所以它们可以并发(conurrently)执行 synchronized)执行 synchronized push()和pop(),从而形成错误。很明显,这不会是我们想要的结果。 问题是由[push()重新赋值 locked object(上锁对象)之object reference]而引起的。当一个 object 被锁定 , 就存在[其他线程被一个 object lock 阻塞]的可能性。如果你将 locked object 的object reference 重新指 向另一个对象,那么其他线程所期待的那个 lock,将系于一个从此不再与此程序有所关连的对象身 上。 你可以修改代码,去掉对 intArr 的同步控制,转而对 push()和pop()进行同步控制。为此,我们添加 关键词 synchronized 作为函数修饰符。正确的代码看起来是这样: 上述更动变更了锁定对象:不再锁定 intArr 所指的对象,而是锁定调用函数 的那个对象。这样就可 以重新赋值 intArr 的object reference,因为程序所获得的 lock 不再与 intArr 所指的那个对象有关。 实践 57:不要调用 stop()或suspend() 在Java 2 SDK 中,stop()和suspend()均已不被获得支持。这类函数将不再得到后继支持,并可能在 未来版本中被完全剔除。这类函数往往有其他可替换函数。但也可能无可替换,因为它们天生就不 安全。Stop()和suspend()就是属于不安全函数,目前没有替换物。 尽管它们在 Java 2 SDK 中已经不再获得支持,Java 仍然包含了它们的实现和 API,因此它们依然可 以被调用。但是使用这些函数所派生的问题比它们所解决的问题还多。最好离它们远一点。 Stop()的本意是用来中止一个线程。中止线程的问题根源不在 object locks,而在 object 的状态。当stop() 中止一个线程时,会释放线程持有的所有 locks。但是你并不知道当时代码正在做什么。举个例子, 考虑一个[将数据写入共享内存缓冲区(shared memory buffer)]的线程,该缓冲区由一个 object 表示 。 如果你打算通过 stop()中止这个线程,你如何知道此线程已经完成了它当时的 write 动作?事实上你 不知道。如果这个执行被强行中止,[代表共享内存缓冲区]的那个 object 可能不再处于有效状态, 于是,另一个访问该缓冲区俄线程,取得的将是无效数据。 如果小心一点,你可以安全的使用 stop()。如果你知道当你调用它时,被中止的线程并未处于[更新 或处理其他 object 或data]的活跃状态,那么就可以安心地使用 stop()。不过这种情形极为罕见。此 外你也可能以为你在安全状况下调用 stop(),而事实上你并不在安全状况下。由于这些问题,stop() 被认为时不安全的,不再获得支持。 那么我们如何中止一个线程的运行呢?由于不存在 stop()的直接替代物,我们不得使用某种形式的 线程协作(thread cooperation)。实践 58 讨论了这个问题,并提供一个如何安全中止线程的范例。 Suspend()的本意时用来[暂时悬挂起一个线程](它由一个对应的 resume()函数,用来恢复先前被悬 挂起来的线程。Resume()也不再获得 Java 2 SDK 支 持 )。Suspend()同样时不安全的,但其原因和 stop() 的故事不同。 和stop()不同,suspend()并不释放[即将被悬挂支线程说持有的 locks]。这些 locks 在线程恢复执行前 永远不会释放。 假设有一个线程用来[将某个被挂起的线程恢复执行(resume)]。它需要取得一个 lock,而这个 lock 却由[等待被释放]的那个线程所把持。完善的设计可以排除这个问题,然而还是很有可能出现这个 问题的。记住,当一个献策还能够被挂起时,它不会释放它所持有的 locks。因此上述事件序列(events sequence)导致死锁,因为持有 lock 的线程已被挂起(不可能释放 locks),而即将调用 resume()的线 程正在等待那个 lock。这就产生了死锁。由于这个问题,suspend()和resume()被视为不安全,不再 获得 Java 的支持。 那么我们如何中止一个线程的运行呢?由于不存在 stop()的直接替代物,我们不得不使用某种形式 的线程协作(thread cooperation)。实践 58 讨论了这个问题,并提供一个如何安全中止线程的范例。 Suspend()的本意时用来[暂时悬挂起一个线程](它由一个对应的 resume()函数,用来恢复先前被悬 挂起来的线程。resume()也已不再获得 Java2 SDK支 持 )。Suspend()同样时不安全的,但其原因和 stop() 的故事不同。 和stop()不同,suspend()并不释放[即将被悬挂之线程所持有的 locks]。这些 locks 在线程恢复执行前 永远不会被释放。 假设由一个线程用来[将某个被挂起的线程恢复执行(resume)]。它需要取得一个 lock,而这个 lock, 而这个 lock 却由[等待被释放]的那个线程所把持。完善的设计可以排除这个问题,然而还是很有可 能出现这个问题的。记住,当一个线程被挂起时,它不会释放它所持有的 locks。因此上述事件序列 (events sequence)导致死锁,因为持有 lock 的线程已被挂起(不可能释放 locks),而即将调用 resume() 的线程正在等待那个 lock。这就产生了死锁。由于这个问题,suspend()和resume()被视为不安全, 不再获得 Java 的支持。 乍看之下,Java API还有另一个函数似乎可以安全中止线程:destroy()。这个函数是 java.lang.Thread class 的一部分,不过它目前尚未被实现完成。这个函数的相关文件显示,即使它被实现出来,也会 遇到和 suspend()同样的问题。如果未来它真的被实现出来了,请仔细分析它到底做了些什么。最有 可能的情况时,它的实现也不会是中止线程的一种安全办法。 总结:请避免使用 stop()和suspend()。stop()带来[搅乱内部数据]的风险,suspend()带来死锁的风险。 两者都会引发不可预测的行为和不正确的行为。多线程编程已经够复杂了,请不要雪上加霜地又去 使用会引发问题的函数。 实践 58:通过线程(threads)之间的协作来中止线程 实践 57 建议大家不要使用 stop()来中止线程。实践 55 建议大家不要使用 polling loops(轮询循环)。 由于没有 stop()代替物,我们不得不寻找其他能够安全中止线程的办法。不幸的时,这里需要用 到polling loops。 当线程的 run()结束时,线程就中止了运行。想要安全地停止一个线程,你需要一个机制,优雅地退 出[你希望中止之线程]的run()。由一种做法时在 class 内提供一个变量,以及一个用来设置此变量 值的函数。该变量用来表示线程何时应该被中止。你可能会这样实现这个技术: 这个设计尝试令线程优雅地中止,不过它有一个马上就要谈及的缺陷。在 run()函数中,代码每次通 过循环时都要查看它是否被其自己或其他线程要求中止。如果时,它可以进行一些清理工作,然后 退出 run(),最后中止线程。 这个解决方案的缺点是,线程的中止有可能被不情愿地推迟。考虑一下这个情形:线程 1对线程 2 调用 stopThread()。线程 2可能正在它的 while 循环内部进行一些任务,可能不会及时查看它的 stop 变量。这项技术也有优点,线程被要求中止时,能够以自己的方式做事。这样一来,它就有机会在 退出前进行必要的清理工作。 上述代码并不完全正确,问题出现在变量 stop 身上。当我们在 stopThread()中将 stop 变量设置为 true, 并不能保证 run()内的代码看得见变化了的值。之所以存在这个问题,因为 Java 允许线程在其[私有 专用内存](private working memory)中保留主内存(main memory)的变量副本,这可以优化性能。 在访问变量时,对[私有专用内存]的操作,其性能高于对[主内存]的操作。私有专用内存及其相关问 题,详见实践 50。 由两种方法可用来确保变量的私有专用副本与其主内存的版本保持一致: 1. 在synchronized 函数或 synchronized 区块中进行访问。 2. 声明变量为 volatile。 当代码进入(或离开)synchronized 函数或区段时,代码所访问的变量从主内存被复制出来(或复 制到主内存)。这可确保变量值时准确的,并能够反应出其他线程施加于变量身上的变化。 前面的例子中并没有在 synchronized 函数或 synchronized 区段中访问变量 stop。如果不打算加入同 步机制,则可以使用第二个选项。 将变量声明为 volatile,可迫使 Java 运行层在每次访问变量时都对其私有专用副本和主内存之间进 行一致话。声明变量 stop 为volatile 可以确保其值时最新的,保持与其他线程加诸的修改一致。下 面时利用关键词 volatile 修改的代码。 在Java 尚未提供 stop()的安全替代物之前,你必须采用类似的技术安全中止线程。这个技术要求线 程之间合作,使线程得以优雅地被中止,并维持对象状态。 实践 59:运用 interfaces 支持多重继承(multiple inheritance) Java 经常被说成不支持多重继承。事实上,它只是不支持普通意义上的多重继承,亦即不支持[实现 上的多重继承](multiple inheritance of implementation),但它确实支持[接口上的多重继承](multiple inheritance of implementation),但它确实支持[接口上的多重继承(multiple inheritance of interface)]。 欲理解这两种多重继承的含义有何不同,请考虑一组用可以表示车辆及其价值的 classes。你或许会 声明多个 Java classes 来表现各种车辆、其价值和实现,不过以下设计时错误的: class Vehicle //译注:车辆 { //Methods of a Vehicle } class Asset //译注:资产 { //Methods of an asset } class MyCar extends Vehicle,Asset { //... } 当你编译这段代码,会产生如下消息: MyCar.java:1: Multiple inheritance is not supported. Class MyCar extends Vehicle, Asset 1 error 所谓[实现上的多重继承]时通过[扩展(extending)一个以上的 classes]来进行,正如以上所尝试的那 样。这种做法明显违背了 Java 规则,因而导致[Java 不支持多重继承]的说法。 然而 Java 提供了 interface。和 class 颇为相似,interface 表现出的时个 reference 型别,代表一组函数 签 名(signatures)和预 期 行 为 。Interface 不可包含任何实现内容,因此也就不需要有具体行为。Interface 中的每一个函数的返回类型和签名式(signatures),描绘出该函数未来的行为方式。 有了 interface, Java 便可支持多重继承——更明确地说时[接口上的多重继承]。换言之尽管 Java calss 不能扩展(extend)多个 classes,却可实现(implement)多个 interfaces。 现在让我们再次考虑先前那个问题,设计一组 classes,用以表现车辆及其价值。你可以通过 Java interface 来表示车辆的各种公共接口,而后以 concrete classes(具象类)实现一或多个接口,用以支持 由这(些)个 interface(s)表达出的各种不同种类的行为。考虑下面的 interface 声明: 现在考虑 vehicle interface 的一个具象(具体)实现: MyCar class 是vehicle interface 的一份具象(具体)实现。作为一份具象实现,它必须实现出 vehicle interface 所声明的每一个函数。它甚至额外提供了三个函数:loadPeopleInCar()、adultSeating()和 childSeating()。 现在假设你想要建立另一个[既是 cehicle 又是 Asset]的class。由于 vehicle 和Asset 都是 interfaces, 所以你办得到: PassengerCar 实现了两个 interfaces。注意,当你实现某个 interface 时,你的 class 必须为 interface 的 所有函数提供实现代码。PassengerCar class 还额外提供了三个函数:loadPeopleInCar()、adultSeating() 和childSeating(),适当表现出这两汽车的行为和某些价值。 PassengerCar class 展现了 interface 的多重继承。一旦采用这类继承方式,其他 classes 便可视需要实 现各种 interfaces。 你也可以将 interface 声明式当做[标识接口](marker interface)来运用。所谓标识接口是不含如何函 数的一种 Interface,用来表示实现此接口的任何 class 一定具有某种属性(性质,property)。 举个例子,Java 程序库中有一个[标识接口]是Serializable。这个接口不含任何函数,但任何 classes 只要表现了它,都被期望出[可序列化](seralizing; 次第可读写)性质。[标识接口]应当提供完备文 档,说明实现此接口之 class 应当表现出什么样的属性(性质)。如此一来,一旦你想实现某个[标识 接口],就知道如何编码,由[标识接口]指定的属性(性质)也就因此得以妥善体现。 要测试一个 class 是否实现了某个[标识接口],请使用 instanceof 操作符,像这样: 记住,和 class 一样,interface 声明的是个 reference 型别。因此如果某个 class 实现了某个 interface, 该class object 也将是那个 interface 的一份实体(instance)。例如,由于先前的 PassengerCar class 实 现了 vehicle 和Asset 两接口,所以 PassengerCar 对象同时也是 vehicle 和Asset 的实体(instance)。 考虑如下代码: 总而言之,要想支持单一继承和多重继承,或实现[标识接口](marker interface), 你可以使用接口(interface)。所谓接口,仅限于 public 函数和常量,不包含如何实现代码。这使得 interface 成为一种契约(contract)表达方式,这个契约日后将由某个 concrete class(具象类)实现 之。在 Java 中,classes 只可以扩展(继承)另一个 class,却可以实现多个 interfaces。这体现了 java 所支持的[接口多重继承]。 实践 60:避免 interfaces 中的函数发生冲突 没有什么能够防止两个 interfaces 对常量使用相同名称,或以相同名称或签名式(signature)来声明 函数。如果一个 class 同时实现两个或多个具有这类特征的 interfaces,则可能会引发问题。考虑下 面为高尔夫球选手(golfer)和保龄球选手(bowler)所做的 interface 声明: 现 在 ,class 不可能同时实现二者。尽管两个 interface 的computeScore()具有相同的签名式(signature), 返回型别却不相同,因此编译任何[试图实现上述二者]的class 都会产生编译错误。例如: class Enthrsiast implements Golfer, Bowler { public void computeScore() { //… } } 编译这个 class,获得的编译器输出消息如下: Enthusiast.java: 3 : The method void computeScore() declared in class Enthusiast cannot override the method of the same signature declared in interface Golfer. They must have the same signature declared in interface Golfer. They must have the same return type. public void computeScore() 1 error 如果可以取得这些 interfaces 的源码,就可以令 computeScore()的返回型别一致,籍以修正这个问题 。 例如: 现在写出一个 class,实现上述两个 interfaces: class ENthusiast implements Golfer,Bowler { public void computeScore() { //... } } 由于两个 interfaces 提供的函数其[签名式(signature)及返回型别都相同],因此 Enthusiast class 得 以通过编译。此外你或许注意到,两个 interface 所声明的常量也同名。这没什么大不了,如果想通 过Enthusiast class 访问这些常量,只需明确指出其完整名称即可: 现在你面临语义(semantices)问题。当你实现 Enthusiast 的computeScore()时,你计算的是哪个分 数呢?高尔夫球的 computeScore()函数语义,完全不同于保龄球的 computeScore()函数语义。究竟该 如何实现,让人有些为难。 如果这个函数具有通用实现法则,就不成问题了。可惜本例之中不存在某种正确的 computeScore() 实现法可以同时满足高尔夫球和保龄球计分规则。 如果你能接触到这些 interfaces 源码并且能够修改它们,则可以轻而易举消除这些问题。如果你没有 源码,就得另做打算。你可以另外定义一个 Interface,扩展(继承)既有的 Golfer interface,其中声 明一个新函数,不与 Bowler interface 中的那一个发生冲突,例如: 而后让 Enthusiast class 实现此 MyGolfer 而非 Golfer,像这样: 现在Enthusiast class 需要实现 Bowler interface 和Golfer interface 中声明的 computeScore(),以及 MyGolfer interface 声明的 computeGolfScore()。这使得 Enthusiast class 可以拥有两个不同函数,计算 两种不同球类的分数。 这个解法有个严重问题。无论 Golfer interface 用于何处,相应的代码只能调用 computeScore(),以及 MyGolfer interface 声明的 computeGolfScore()。其原因在于 Golfer interface 所引用(指向)的对象, 只能调用该 interface 及其扩展(继承)之其他 interface 所规划的函数。因此像上面那样实现另一个 interface,并未能够彻底解决语义冲突问题。这种情况下,你的最佳选择是放弃[Enthusiast class 既是 Golfer 又是 Bowler]的含义,转而实现两个独立的 classes。例如: 名称冲突(name clash)问题总是可能发生,你应该设法使其可能性愈低愈好。如果采用不同的名称 , 先前例中的名称冲突就可能不复存在了。例如不用 computeScore,而以 computeGolfScore 和 computeBowlingScore 命名之。谨慎地为函数命名,尽管无法消除每一个潜在的名称冲突问题,但可 以尽可能地减少问题。程序员实现来源于不同程序的 interfaces 时,最有可能与名称冲突问题不期而 遇。 总而言之,interface 时程序员设计和开发代码的利器。但是在定义 interfaces 时,存在着函数及常量 名称冲突的可能性。你应当谨慎地为函数和常量命名,减少与其他 interface 名称发生冲突的机会。 实践61616161:如需提供部分实现(partialpartialpartialpartial implementationimplementationimplementationimplementation),请使用 abstractabstractabstractabstract classesclassesclassesclasses(抽象类) Java 提供了声明 abstract class(抽象类)的能力。所谓 abstract class 时一种本身不能被具现化 (instantiated)的 class,但允许为其内声明的函数提供实现代码。Abstrac class 函数不同于 interface 函数,后者不能提供任何实现代码。更多相关信息请见实践 59、实践 60 和实践 62。 abstract calss 可以定义(1)带有缺省实现代码(default implementation)的函数(2)无任何实现代码的函 数。由于 abstract calss 未必为其所有函数都提供完整实现内容,所以建立其实体并没有意义。事实 上如果你企图建立一个 abstract calss 实体, 编译器会提出[抗议]。 如果你打算声明一个 class 却只提供部分实现、abstract calss 就可以一显身手了。你为[一部分 abstract calss 函数]提供的实现代码,如果不是对此 class 的所有对象都有意义,就可作为缺省实现代码。此 后,任何 subclass 如果想改变函数缺省行为,可以覆写(overridden)这一份映省实现代码。至于未被 实现的 abstract calss 函数可能是只对 subclass 有意义,因此应该由 subclass 提供实现代码,而不是在 abstract calss 中实现完成。 abstract calss 中未曾实现的函数,称为abstract methods(抽象函数)。任何一个[扩展(继承) abstract calss 的derived class,如果希望被视为 concrete class(具象类),必须实现 abstract calss 中的所有 abstract 函数。所谓 concrete calss(具象类)是一种可据以创建实物的 class。如果某个 class 扩展(继承)了某个 abstract calss,但未能提供所有 abstract 函数的实现代码,那么它依然是个 abstract calss。 为演示 abstract calss 的用法,试考虑以下的 class 声明,用以计算“two-player, better-ball”高尔夫球 赛蹭的团体成绩: TeamScores class 被声明为 abstract,因为其部分实现对所有 subclasses 都适用一—尽管剩余部分需由 各自的 subclass 提供。这个 class 用以汁算四球赛的双人队成绩。无论球队和成绩如何不同,团队成 绩计算法是相同的,所以可以在 abstract class 中完成 processBetterBallTeamScores()函数的实现代码。 其他诸如选手个人成绩等的计算函数,需由 subclasses 提供。例如某个 subclass 可能这么实现: Result class 扩展(继承)了abstract TeamScores class,提供了所有 abstract 函数的实现。Result 并非只 能提供[base class TeamScores 遗留未定之函数]的实现代码,它可以通过穆写方式提供其自己的 processBetterBallTeamScores(),那是原本已有实现内容的一个函数。 正如上述所见,当你打算提供[只带部分实现]的class 时(也许这一部分实现对所有 deived classes 都 通用)abstract class 非常有用。那些未被实现的函数(于是成为 abstract)必是因为[如果由 base class 提 供实现代码将缺乏实际意义],所以必须留待 derived class 提供实现代码。 实践62626262:区分 interfaceinterfaceinterfaceinterface、abstractabstractabstractabstract class class class class 和concreteconcreteconcreteconcrete classclassclassclass 该在何时设计 Interface(接口)、abstract class(抽象类)和concrete class(具象类),经常令人困惑。实践59 和实践 61 各自分析了 Interfaces 和abstract class,本实践则是总结三种设汁的差异,提供一些指导性 建议,让你依据你的程序条件和需求来进选它们。 下表详细说明了三种设计的各自特性。 表五:interface、abstract classes 和concrete classes 的特性 interface abstract classes concrete classes 描述 一种契约(contract)表示法,不 带实现 一种契约(contract) 表示法,带有部分实 现 一种具象(具体)实 现,经常时某个 interface 或者某个 abstract class 的实现 使用时机 当你想要支持单一或多个 interface 继承,或为了确定某一 marker interface(标识接口) 当你打算提供带有部 分实现的 class 当你打算提供一份完 整的具象实现 下表扼要总结了 interface、abstract classes 和concrete classes 之间的主要区别: 表六:interface、abstract classes 和concrete classes 的总结 interface 的所有函数都暗自为 abstract。但你不可以明确声明它们为 abstract,否则会导致编译错误。 在设计代码时请将这些信息牢记干心,可以确保你使用正确的构件(constructs)来表达你所期望的接 口和意图(interface and contracts)。 译注:自实践 63(p213)以降,至实践 65(p233)止,大量用到 immutable(不可变)、mutable(可 变的)及 immutability(不变性、恒常性)等字眼,用于 classes、objects、interfaces 和methods 身上 。 以下大量保留了这些名词。 实践 63636363: 审慎地定义和实现 immutableimmutableimmutableimmutable classesclassesclassesclasses ((((不可变类)))) Immutable object(不可变对象)是面向对象程序设计中颇具价值必要的一种构件。是的,有时候你 会希望禁止某个 object 被改动内容。根据定义,immutable object 是一种一旦建构好就不再变化的 object,在其生存期间不可被改变内容。Immutable classes 通常用来表述字符串、颜色和数字。 Immutable object 提供了极具价值的服务。由于它们保证自己的状态从建构之后就一定不再改变,因 此它们天生具备[多线程安全性]。如果一个线程可以在另一个线程读取某数据时修改那份数据,并 发(concurrency)议题就非常重要。由于 immutable object 永远不改变本身数据,所以我们完全没有必 要对他们同步控制。 上述[不需同步机制]的特性有可能带来显著的性能收益——实际收益取决于 immutable object 的设 计方式(关于同步机制的成本讨论,请见实践34343434)。 不过任何通过 immutable object 获得的性能好 处,都有可能被[为了支持 immutability(不可变性)而不得不实现的额外代码]抵消。例如,实现 immutable object 时你必须实现 clone(克隆)功能,而其代价可能不小。实践64 64 64 64 将详细讨论 clone。 尽管 immutability(不变性)只是 object 的一个属性,但它必须通过实实在在地编码才能展现,并没 有什么 Java 关键词可以指定这个属性。Class 的定义和实现多方面合作才可能造就不变性: ·将class 中的所有数据声明为 private。 ·只提供取值函数(getter),不允许存在设值函数(setter)。 内容限制 Public 函数以及 public static final 常量 无任何限制 无任何限制 实现 不允许实现 允许部分实现 要求完全实现 支持多重继 承 支持抽象函 数 允许实现 允许创建实 体 允许部分实 现 interface YYNNN abstract class NYYNY concrete class NNYYN ·声明 class 为final。 ·从获取器返回 reference to mutable objects 之前,先克隆(cloning)那些 mutable objects。参 见实践64646464。 ·将[传递给构造函数之 reference to mutable object]先克隆(clone)一份(参见实践64646464)。 ·在构造函数中设定 class 内含的所有数据。 由于 immutable object 不能被修改,所以它的所有数据都必须声明为 private。如果不如此,数据就可 以被改动,也就违反了 immutable object 的意义。 不允许存在设值函数(setter)是因为,这种函数能够改变 class 的数据内容。class 必须被声明为 finalfinalfinalfinal, 为的则是防止它被子类化(subclassed)。要知道 subclass 可以提供设值函数(setter),或覆写某个取 值函数(getter),返回与 base class 不一致的值。 此外,任何 reference to mutable object 在[被传递给构造函数]或[从取值函数(getter)返回]之前,那 些mutable objects 必须先被克隆(clone)一份。如果不这样,不变性(immutability)就可能丢失(参见实 践64646464)。由于这些限制,所有与 immutable class 相关的数据都必须由其构造函数设置。考虑以下的 immutable class: 这个 class 被声明为 final 以防 subclassing(子类化)。其所有数据都被声明为 private,并且只提供数据 取值函数(getter)而无设值函数(setter)。此外所有数据均由构造函数设置。这些特性确保此 class 的所 有对象创建之后不能被修改。当然,你还必须确保这个 class 没有任何函数会改变内部数据,否则会 破坏不变性。 这个 class 不需克隆(clone)任何数据,因为其构造函数所接受的,或其取值函数(getter)返回的,都只 是基本型别(primitive type)和 reference to immutable object。基本型别不是对象,所以对它们而言 克隆(clone) 动作无任何意义;StringStringStringString class 是恒常不变的,所以也不需要对它施行克隆动作。稍后实 践64 64 64 64 将讨论有关[对mutable object 进行克隆动作]的细节。 实践64646464:欲传递或接收 mmmm utableutableutableutable objects(objects(objects(objects(可变对象))))之objectobjectobjectobject references references references references 时,请实 施clone()clone()clone()clone() 实现 immutable class(不可变类)时,必须对传入或传出之 mutable objects(可变对象)进行克隆(clone) 动 作 。实践63636363下了这样的定义:如果一个object 及其指涉(引用)之所有objects 都不会被改变,该object 便是为恒常不变的(immutable)。如果不实施克隆动作,你的对象不变性(immutability)就得不到保证。 这是因为他处可能保留了这个 immutable object 内的某个 object 的reference,并对其进行了修改,从 而冲破了不变性的[界限]。 考虑以下的 DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo class 和UserUserUserUser class。我们希望 DiskDriveInfo DiskDriveInfo DiskDriveInfo DiskDriveInfo 恒常不变,User User User User 则封装[共享 这个磁盘驱动器]的用户。User User User User 对象被存储为 DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo object 的一部分。下面例子谨慎地令 DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo class 为final,所有值域皆为取 private,并且只提供取值函数(getter)(译注:这一切符 合实践63 63 63 63 所列条件)。现在,DiskDriveInfo DiskDriveInfo DiskDriveInfo DiskDriveInfo 是恒常不变的吗?如果不是,该再做些什么呢? 我必须告诉你,DiskDriveInfo DiskDriveInfo DiskDriveInfo DiskDriveInfo 并非恒常不变。这个 class 的对象内容可以被改变。试考虑以下代码, 创建一个 DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo 对象然后测试其不变性: 这段代码输出为 : User with shared access is Duke User with shared access is Fred 出了什么问题?这段代码在位置//1 创建了一个名为“DukeDukeDukeDuke”的User User User User 对象 sharel,在位置//2 创建了一 个我们以为恒常不变的 DiskDriveInfo DiskDriveInfo DiskDriveInfo DiskDriveInfo 对象,并将刚才那个 User User User User 对象传给它。接下来查询 DiskDriveInfo DiskDriveInfo DiskDriveInfo DiskDriveInfo 对象,打印共享用户“DukeDukeDukeDuke”。而后,位置//3 改变 User User User User 对象 share1 的名字为“FreFreFreFredddd”。 当程序再次查询 DiskDriveInfo DiskDriveInfo DiskDriveInfo DiskDriveInfo 对象的用户名称时,发现其名称已经由“DukeDukeDukeDuke”变成了“FredFredFredFred”。 问 题在于DiskDriveInfo DiskDriveInfo DiskDriveInfo DiskDriveInfo 构造函数接收了一个reference to user object,而且没有为它制作一份复件(copy) 或克隆件(clone)。实践1111已经说过,Javs 参数总是以 by value(传值)方式传递。所以 DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo 构造函数接受的是 reference to user object 的复件。于是 DiskDriveInfo DiskDriveInfo DiskDriveInfo DiskDriveInfo 的driveShare 值域和 Test main() 中的局部变量 sharel 均指向同一个对象。因此,通过二者中的任何一个所进行的修改,影响的是同 一个对象。以下显示位置//l 代码执行后的对象布局(object layout): 位置//2 的代码执行后,对象布局(object layout)相当于: 注意,由于 reference to user object 并为被克隆(cloned),所以 share1 和driveShare 这两个 reference 分享了同一个 User 对象。位置//3 的代码执行过后,对象布局变成这样(不妙): ShallowShallowShallowShallow Cloning(Cloning(Cloning(Cloning(浅层克隆)))) 为了修正这个问题,你可以使用一种名为 ShallowShallowShallowShallow CloningCloningCloningCloning (浅层克隆)的技术。此技术对对象进行逐 位拷贝(bitwise copy)。如果 cloned object包含了一些object references,那么新的 object 将内含与[cloned object 所含之 object reference]完全一致的副本。所以 new object 和cloned object 依然共享数据。 DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo class 必须克隆(cloning)它所收到的任何 reference to mutable object。这么一来它就拥 有一个 reference,指向那个[不能被其他代码改变]的对象的复件。 修改后的 DiskDriveInfo DiskDriveInfo DiskDriveInfo DiskDriveInfo 支持克隆动作,看起来是这样: 由于这里用上了 User clone(),所以其定义必须做相应改变。如果你因为某些因素无法为 User class 添加 clone()函数,则必须借助其他方法。一种办法是修改 DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo class 使其不再内含 UserUserUserUser 对象,而是存储一个 String String String String 表示用户名称,以及一个 int int int int 表示用户节点。 假设你能接触 User User User User 源码,你必须改善它使之支持 clone()。若要支持(缺省的)浅层克隆,只需实现 CloneableCloneableCloneableCloneable interface interface interface interface 并提供一个 clone()。关于克隆的详细信息,以及为何调用 super.clone(),请见实 践66 66 66 66 仍。改进后的 UserUserUserUser class 是: 对User User User User 做出如此改变之后,先前的测试程序就会产生正确输出: User share1 = new User("Duke", 10); DiskDriveInfo dd = new DiskDriveInfo(sizeInMeg, "myDrive",share1); User share = dd.share(); System.out.println("User with shared access is " + share.userName()); share1.setUserName("Fred"); //1 System.out.println("User with shared access is " + share.userName()); 程序输出为: User with shared access is Duke User with shared access is Duke 由于调用 DiskDriveInfo DiskDriveInfo DiskDriveInfo DiskDriveInfo 构造函数时 User User User User 对象被克隆了,因此位置//1 改变 User User User User 对象并不会影响 DiskDriveInfo DiskDriveInfo DiskDriveInfo DiskDriveInfo 对象。[恒常不变的] DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo class 现在终于完成了。对象布局如下: 当函数返回一个 reference 指向[immutable object 内含之 mutable object]时,也有相同的问题:外界有 权[触及]你的内部数据并修改它。因此必须克隆(clone)任何被上述 reference 所指的 mutable object。 举个例子。driveShare 是个 reference to User object,由 DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo class 的share()返回。返回之前 它必须先被施行克隆动作。是的,仅仅定义一个不带设值函数(setter)的class 是远远不够的。你必须 关注如何接收和返回 object references。 你或许对参数 String String String String 和Int Int Int Int 有些不放心,但它们并不需要被克隆。因为 StringStringStringString class 及任何基本型别 都是恒常不变的,它们不会被他处改变内容,所以不会带来问题。 Vector Vector Vector Vector 与CloningCloningCloningCloning(克隆动作) 如果把 DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo class 改变一下,令它存储一个 VectorVectorVectorVector.其中元素是[被共享驱动器]的所有 UsUsUsUserererer 对象,会发生什么事呢?还记得吗,目前的实现方式只支持一个 User User User User 对象。现在,DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo class 应该变成: 测试程序也要改为支持 VectorVectorVectorVector。当程序执行起来,结果令人惊奇不已。修改后的测试程序看起来是 : 这段代码产生如下输出: Users with shared access are Duke,Duke2 Users with shared access are Fred,Duke2 这不是我们所预期的结果!发生了什么事?我们对 DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo class 的惟一改变也只不过是改用 Vector Vector Vector Vector 存储多个 User User User User 对象而已。 问题出在对 Vector Vector Vector Vector 的克隆动作上。缺省情况下,VectorVectorVectorVector clone()执行的是浅层克隆(shallow clone)。 Vector Vector Vector Vector 的值域 driveShare 其实是个 object reference,因此当代码对 Vector Vector Vector Vector 进行克隆时,虽然建立了 一个新副本,其内容(references to UserUserUserUser object)却没有被克隆。下图显示位置//1 代码执行后的对象布 局: Deep Cloning(深层克隆)))) 由于 VectorcVectorcVectorcVectorc c1one()的缺省实现是浅层克隆,为运应上述问题,你必须提供自己的深层克隆版本。 深层克隆可以确保 DiskDriveInfo DiskDriveInfo DiskDriveInfo DiskDriveInfo 对象中的 Vector Vector Vector Vector 的elementData 值城,指向它自己的 User User User User 对象(这 是复件),而非 shareVec 变量所指的 User User User User 对象(这是原件)。这样就得以保证 DiskDriveInfo DiskDriveInfo DiskDriveInfo DiskDriveInfo 对象的不 变性。 解决这个问题的办法之一是对 Vector Vector Vector Vector 进行 subclassing(子类化),覆写其 clone()函数,并提供你自己 的深层克隆做法。以下显示 subclassing 后的 VectorVectorVectorVector,其 clone()函数采用深层克隆实现手法; 注意,这段代码对 Vector Vector Vector Vector 内的每个元素所指的对象进行了克隆动作。以此 ShareVector ShareVector ShareVector ShareVector 应用于 DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo class 和测试码,会得到正确结果。执行深层克窿之后,对象布局如下: 现在,通过 shareVec object reference 进行改动,将不会影响到恒常不变的 DiskDriveInfo DiskDriveInfo DiskDriveInfo DiskDriveInfo 对象。 这个做法产生了预期结果,但仍有不足之处。它要求定义一个新 class,其作用仅仅在于改变既有 class 的克隆方式(深层或浅层)。此外它还要求修改 DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo class 的用户——用户必须改用新的 ShareVector ShareVector ShareVector ShareVector 代替 VectorVectorVectorVector。 另一种做法是由 DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo class 自己一一克隆其所含的 User User User User 对象。我为此写了一个 private 函 数,免得代码过于重复。修改后的 DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo class 看起来是: 这个做法产生了我们预期的结果。它还具有额外的好处:DiskDriveInfoDiskDriveInfoDiskDriveInfoDiskDriveInfo class 客户端不需要修改。 总而言之,在实现—个immutable class(不可变类)时,请遵循下列规则: � 声明这个 class 为final。 � 声明所有数据为 private。 � 只提供取值函数(getter),不提供设值函数(setter)。 � 在构造函数中设置所有 instance 数据。 � 如果函数返回 reference to mutable objects,请克隆那些 mutable objects。 � 如果函数接受 reference to mutable objects,请克隆那些 mutable objects。 � 如果缺省之浅层克隆(shallow clone)不能符合 immutable object 的正常行为 请实现出深层克隆(deep clone)。关于克隆的实现,详见实践66666666。 实践65656565:使用继承(inheritance) (inheritance) (inheritance) (inheritance) 或委托(delegation) (delegation) (delegation) (delegation) 来定义 immutableimmutableimmutableimmutable classes(classes(classes(classes(不可变类)))) 实践 63 仍概述了 immutable ojeccts(不可变对象)的一些优点,以及如何设计和实现你自 己的immutable ojeccts。本节探讨另外三个你可以用来定义 immutable classes 的技 术。 每个都有其优点和缺点。这些技术是: � immutable interface(不可变接口) � 公共接口或基类(common interface or base class) � immutable delegation class (不可变的委托类) immutable interface (不可变接口) 假设你有一个表示圆圈的 mutable class MutableleCircle。由于 immutable interface 具有[多 线程安全](thread safe)优势,所以你打算令程序以 immutable object 的方式使用这个 class。最初的 MutableleCircle class 是这样: 为了让这个 class 成为一个 immutable class,你可以声明一个 immutable interface,再由 这个 class 去实现它。例如: 由于 immutable interface 仅仅曝露底部(underluing)class 的non-mutating methods,所以通 过这个 interface 访问的 objects 能保持不变性。这使你得以利用它来防止被改动。例如, 下面的代码通过 ImmutableCircle 返回一个 reference to MutableCircle object,从而有效地 阻止了这个代码通过编译: 注意,createwheel()返回一个 reference to MutableCircle object。而你知道,如果对象的 型别是 ImmutableCircle,就只能访问 ImmutableCircle interfance 所定义的函数。在此情 况下,惟一可用的函数就是immutable radious()。试图从ImmutableCircle object reference 访问 MutableCircle 提供的函数,会被编译器视为错误,出现这样的消息: Test.java:12:Method setRadius(double) not found in interface ImmutableCircle iWheel.setRadius(7.4) 1 error 这正是我们以这种方式撰写代码时的期望结果。不过这种设计方式有个缺陷:它只能有 效运作至[其用户终于知道如何避开这种以 interface。建立起来的不变性]为止。下面的 代码就冲破了不变性的约束: 这段代码不仅顺利编译,还产生如下输出: Radius of wheel is 5.0 Radius of wheel is now 7.4 这段输出显示,我们原以为恒常不变的 ImmutableCircle 对象被改动了。采用这种方法, ImmutableCircle .class 的用户可以便用简单的转型(cast)动作轻易消除不变性。记住, interface 声明的是个 interface 型别,因此型别为 ImmutableCircle 的对象可以转型为派生 型别 MutableCircle,转型后的对象当然就可以访问 MutableCircle 的函数,这就打破了 不变性。 你或许认为,程序员不得不花些气力来撰写转型动作,这已足以起到威慑力量。无论如 何,不变性可以被攻破。 公共接口或公共基类(Common interface Base Class) 欲防止不变性被打破,还需另谋他法。另一个办法是使用公共的interface 或公共的 base class,以及两个 derived classes 组织如下: � 一个 inteface 或abstract base class,其内声明一些可被其 derived classes 共享的 immutable 函数。 � 一个 derived class,提供 mutable 函数实现代码。 � 另一个 derived class,提供 immutable 函数实现代码。 举个例子,你可以像这样设计一个 interface 和两个 derived classes: 这项技术允许函数在其签名式(signature)中指定使用: � Mutable class——如果函数要求获得一个 mutable object。 � Immutable class——如果函数需要保持 immutability(不变性)。 � 中立的 interface 或base class——如果函数不在乎 immutability(不变性)。 这个解决方案也避免了前述 immutable interface class 所暴露的转型问题。现在这 些classes 的不变性不会因为转型而流失。举个例子,考虑一下代码: 函数foo()接收一个 reference to MutablePinNumbers object 作为参数,所以它可以访问 MutablePinNumbers class 的[可变函数](muhNs mehods)。相反地,函数 ber()接收一个 reference to ImmutablePinNumbers object 作为参数,所以它不能改变参数 p所指对象; 在这个函数运行期间,object 保持了不变性。如果程序企图在两个 classes 之间转型,编 译器会表示异议。 这种实现方法可以确保不变性(immutability)不会被简简单单的转型动作打破。 Immutable Delegation Class (不可变的委托类) 另一种做法足使用 immuatble delegation class,其中只包含 immutable methods,并将外 界对它们的调用任务委托(delegates)给class 内含的 mutable object。例如, 回到 p226 的circle classes 例了,委托技术可以这样运用: ImmutableCircle class 使用分层手法(layering),或曰 [有一个(has-a)]关系,与Mutableclrcle class 发生联系。当你创建一个 ImmutableCircle object 时,也创建丁一个 Mutableclrcle object,不过前者的用户不得访问后者(译注;因为后者是 private),只能访问由 ImmutableCircle class 提供的 immutable methods。这些 classes 的用户不能在[可变]和[不 可变]之间转型。这一点和前面所举的 immutable methods interface 例子不同。 当你无法修改既有之 mutable class 源码时,这个办法尤其有用。也许这个 class 是你正 在使用的程序库的一部分,而你无法改动源码,以至于无法使用其他技术。在这种情况 下你可以使用这种分层(layering)手法。 这种做法借有副作用。你需要花更多气力去实现委托模型(delegation model),以及更多 的努力玄理解和维护。此外,每调用一次受托函数(delegatied model),都得付出性能上 代价。决定使用以下何种技术之前,请考虑这些因素。 下表列出了各种提供 immutable object 的技术的优缺点。 表7:immutability(不变性)技术 实践66666666: 实现clone()clone()clone()clone()时记得调用 super.clone()super.clone()super.clone()super.clone() 对于支持克降(cloning)的classes 其操作中必须调用 j ava.lang.objectclone()。这只要在 clone()实现代码中调用 super.clone()即可完成。 [clone ()之中首先必须调用 superclone()],可确保 java.long.object 的clone(届终会被调用 , 因而使得克降件得以被正确建构出来。Java.1ang.object 的clone()函数会创建一个止确型 别的新对象,并执行浅层克隆(shallow clone),办即从[被克隆物]复制所有位域(fields)到 [新对象]身上。即使你需要深层克隆(deep clone),仍然斋要调用 java.1ang.object 的clone() 函数,才能创建对象的正确型别。探浅两种克阵操作的区分,请见实践 64。 Java.1ang.object 的clone()函数也确保得以创建出正确的 derived object。考虑以下代码: 技术 优点 缺点 Immutable inference 不可 变(恒常)接口 简单易行。无需付出性能上 的代价 有破绽,可被破坏 Common interface or base calss 公共接口或基类 没有破绽。清晰地将 mutable object 和immutable objects 分离 需实现更多的 classes,完成 更深的 classes 继承系 Immutable delegation class 不可变(恒常)委托类 没有破绽。当你无法修改既 有的mutable object 源码 时,此法可用 需付出性能上的代价 任何 classes 如果支持克隆操作,就必须实现 Closeable interface——这是一个标识接口 (marker interface,详见实践 59),也就是说它并没有实现任何函数。任何 classes 如果实 现Closeable,就是宣称它们支持克隆操作。 上面这个 class 的clone()实现代码有个问题:它没有调用 super. clone()。想象一下,如 果House class 被子类化(subclasses)而n程序之中通过 subclass 对象调用 clone() ,会发 生什么事呢? 这段代码会导致远行期异常。当程序通过 reference to TwoStoryHouse Object 调用 House clone()时,问题就出现了。调用 House clone()会创建一个 House 对象,而非一个 TwoStoryHouse 对象。下图显示对象的表述方式: 于是当代码企图将 House 对象转型为 TwoStoryHouse 时,由于这个对象原本就不是个 TwoStoryHouse,所以 JVM 在运行期抛出了 ClassCastException 异常。 只要正确实现 House 的clone()函数,令它调用 super.clone(),就可以改正上述问题。调 用super.clone()便是确保 java.1ang .object 的clone()函数一定会被调用——这样就能够创 建正确型别的对象。House clone()的正确实现方式应该是: 这种实现方式保证 java.lang.object 的clone()会被调用,也保证建立正确的对象,于是先 前的转型动作也就不会失败了: 现在,对象的表述形式如下: 如果你打算实现深层克隆(deep clone),这条规则同样适用。你应当调用 super.clone()获 得正确型别的对象,然后再进行深层克隆操作。这项技术在实践 64 的Sharevector 类中 曾经用过。 实践67676767:别只依赖 finalize()finalize()finalize()finalize()清理nonnonnonnon—memory(memory(memory(memory(内存以外))))的资源 在垃圾问收器(garbage collector)回收某个 class object 之前,JVM 会调用这个 class 的 finalize()。这个函数有时被宣扬为[在回收某个对象的内存之前确保释放 non-memory 资 源]的有效方法。由于垃圾回收器仅仅释放对象内存,finalize()因此提供了一种释放其他 资源的办法。 程序员可能会在 finalize()中调用那些[将业已开启之 sockets 或file handles 关闭]的函数。 这种思路的出发点在于确保程序永远不会耗尽这些资源。然而事实上即使 finalize()函数 中有这些动作,程序依然可以用光这些资源。 问题的症结在于,对象的 finalize()函数只有在垃圾回收器释放对象占用的空间 之前才会被调用。实践 7讨论过,当垃圾回收器执行时,可能并非所有符合回收条件的 对象都被回收(实际情况取决于垃圾回收机制所采用之算法),此外亦无法保证 finalize() 在可预期的时间上执行。这是由于[终结(finalization)动作[与垃圾回收]之间的异步 (asynchronous)本性造成的。由此推断,finalize()未必在程序结束前被执行。这意味即使 你正确撰写了 finalize()来释放 non-memory 资源,你的程序结束前仍有可能耗尽这些资 源。 避免这个问题的可能办法之一是使用 System.runFinalization()。这个函数要求 JVM 在垃 圾回收器下次运行之前,对所有标记为[可终结的(finalizable)]对象调用其 finalize()。是 否能够做到这一点,取决于垃圾回收器和这个函数所采用的算法。但不能保证调用该函 数就一定会执行起 finalize()。 早先的另一个可用函数是 System.runFinalization(),现已作废。这个函数仅仅保证所有 对象的 finalize()将在 JVM 退出之前被执行。这意味等到 finalize()执行时,你可能已经 耗尽了资源。通常你会希望 finalize()在你程序运行期间被执行,而不是等到最后 JVM 退出时刻。这个函数被认为是不安全的,因此在 Java2 中被废除了。 综合以上所言,你不能把希望单单寄托于[finalize()被调用]这件事情上。你应当实现自 己的 non-memory 资源清理机制,然后结合 class 的finalize()一道使用。必须确信那些需 要如此清理的 classes 都必然包含一个 public 函数,专门负责释放资源。这个函数应当 被class 的finalize()调用,以确保当 JVM 调用finalize()时能够释放 non-memory 资 源 。 如果 finalize()末被调用,用户则可以直接(径行)调用这个 public 函数,以释放相关资源。 考虑下面的 class: 这个 class 提供了一个 finalize(),它调用 cleanup()。当 JVM 调用 finalize()时,就会释放 non-memory 资源。由于 cleanup()是public,所以也可以在任何时候被其他代码调用。 例如,你可以实现出一个包含 cleanup()的资源池管理框架(resource pool management framework)。 你也可能用期世地调用 cleanup(),以确保不至于耗尽 class 所管理的资源——实际情况 取决于你的系统设计方案。由于你可能频繁地调用它,所以必须注意如何让它被多次调 用。例如,请注意,上述 cleanup()代码在调用 close()前,曾经仔细检查 object reference 是否是 null,并在调用 close()之后将 object references 设置为 null。这种做法可以确保当 外界多次调用 cleanup()时,不会导致重复调用 close()。此外也请注意,cleanup()被声明 为synchronized,这可以保证多个线程(threads)不会针对相同的对象同时进入这个函数。 上述 finalize()也小心谨慎地在 finally 区段中调用了 super.finalize()(关于 finally 区段的详 细信息,请见实践 16、实践 21 和实践 22)。所有 finalize()都应当调用 super.finalize()以 确保任何 superclass 的finalize()被调用。是的,finalize()必须由程序员手工串接起来, 这一点和自动被调用的 superclass 构造函数不同。Super.finzlize()的调用动作被安置在 f2naIly 区段中,确保它一定会被调用,而无论 cleanup()是否产生异常。 由于无法保证 finalize()是否被调用、何时汐调用,所以你应当提供一个 Public 专员函数 , 用以执行 non-momory 资源清理工作;该函数应当由 class 的finalize() 调用之。 实践68686868:在构造函数内调用 nonnonnonnon—flnal flnal flnal flnal 函数时要当心 当程序新建一个 class 对象时,class 构造函数会被调用。构造函数的目的在于将对象初始化。构造 函数运行期间可以调用 class 的某些函数,这很普遍,因为那些被调用的函数或许包含一些初始化动 作。举个例子: Class Base 的构造函数调用了一个 non-final 函数 lookup(),查询数据库中的一些数据。这段代码的运 转与设想相同,Base 的instance 数据 val 被赋值为 5(其实 lookup()应该返回 dblookup()的操作结果。 为了简化,我让它返回数值 5)。 想象一下,如果一个 derived class 覆写了 Base 的lookup()会怎么样?这种做法可能导致不易察觉的 结果。例如: 代码输出如下: From main() d.value() return 0 问题出在 Derived 的lookup()返回值居然是 0。你可能奇怪这是怎么发生的,明明已经有了函数实现 代码呀!该函数返回 instance 变量num,它在 instance 变量初始化时被赋值为 10.事实真象是,当 Derived 的1ookup()开始执行时,其 instance 变量初始化工作还未来得及进行呢。 注意,这个 lookup()是在 Derived 对象建构期间由 Base 构造函数调用的(译注:而 Base 构造函数是 被Derived 构造函数自动调用的)。当执行权进入 Derived 的lookup()时,其 instance 变量初始化行为 尚未进行。这种情况下,那些 instance 变量仅仅被设置为缺省初值(default initial values)。因此当时 的val 被设置为 0,于是 0被传出去(实践 32 非常细致地分析了对象的建构和初始化步骤)。 当构造函数调用 non-final 函数时,就有可能发生这种错误。如果该函数被一个 derived class 覆 写 , 而是该函数返回一个在[instance 变量初始化期间]被初始化的值,就会产生问题。这种错误或许不太 常见。不过知道它的存在还是好的,万一哪一天你遇上了,就可以节省大量时间。
还剩226页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 8 金币 [ 分享pdf获得金币 ] 0 人已下载

下载pdf

pdf贡献者

yn43

贡献于2016-01-24

下载需要 8 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!
下载pdf