第2章
创建和销毁对象
本章的主题是创建和销毁对象:何时以及如何创建对象,何时以及如何避免创建对象,如何确保它们能够适时地销毁,以及如何管理对象销毁之前必须进行的各种清理动作。
第1条:用静态工厂方法代替构造器
对于类而言,为了让客户端获取它自身的一个实例,最传统的方法就是提供一个公有的构造器。还有一种方法,也应该在每个程序员的工具箱中占有一席之地。类可以提供一个公有的静态工厂方法(static factory method),它只是一个返回类的实例的静态方法。下面是一个来自Boolean(基本类型boolean的装箱类)的简单示例。这个方法将boolean基本类型值转换成了一个Boolean对象引用:
注意,静态工厂方法与设计模式[Gamma95]中的工厂方法(Factory Method)模式不同。本条目中所指的静态工厂方法并不直接对应于设计模式(Design Pattern)中的工厂方法。
如果不通过公有的构造器,或者说除了公有的构造器之外,类还可以给它的客户端提供静态工厂方法。提供静态工厂方法而不是公有的构造器,这样做既有优势,也有劣势。
静态工厂方法与构造器不同的第一大优势在于,它们有名称。如果构造器的参数本身没有确切地描述正被返回的对象,那么具有适当名称的静态工厂会更容易使用,产生的客户端代码也更易于阅读。例如,构造器BigInteger(int, int, Random)返回的BigInteger可能为素数,如果用名为BigInteger.probablePrime的静态工厂方法来表示,显然更为清楚。(Java 4版本中增加了这个方法。)
一个类只能有一个带有指定签名的构造器。编程人员通常知道如何避开这一限制:通过提供两个构造器,它们的参数列表只在参数类型的顺序上有所不同。实际上这并不是个好主意。面对这样的API,用户永远也记不住该用哪个构造器,结果常常会调用错误的构造器。并且在读到使用了这些构造器的代码时,如果没有参考类的文档,往往不知所云。
由于静态工厂方法有名称,所以它们不受上述限制。当一个类需要多个带有相同签名的构造器时,就用静态工厂方法代替构造器,并且仔细地选择名称以便突出静态工厂方法之间的区别。
静态工厂方法与构造器不同的第二大优势在于,不必在每次调用它们的时候都创建一个新对象。这使得不可变类(详见第17条)可以使用预先构建好的实例,或者将构建好的实例缓存起来,进行重复利用,从而避免创建不必要的重复对象。Boolean.valueOf(boolean)方法说明了这项技术:它从来不创建对象。这种方法类似于享元(Flyweight)模式[Gamma95]。如果程序经常请求创建相同的对象,并且创建对象的代价很高,则这项技术可以极大地提升性能。
静态工厂方法能够为重复的调用返回相同对象,这样有助于类总能严格控制在某个时刻哪些实例应该存在。这种类被称作实例受控的类(instance-controlled)。编写实例受控的类有几个原因。实例受控使得类可以确保它是一个Singleton(详见第3条)或者是不可实例化的(详见第4条)。它还使得不可变的值类(详见第17条)可以确保不会存在两个相等的实例,即当且仅当a==b时,a.equals(b)才为true。这是享元模式[Gamma95]的基础。枚举(enum)类型(详见第34条)保证了这一点。
静态工厂方法与构造器不同的第三大优势在于,它们可以返回原返回类型的任何子类型的对象。这样我们在选择返回对象的类时就有了更大的灵活性。
这种灵活性的一种应用是,API可以返回对象,同时又不会使对象的类变成公有的。以这种方式隐藏实现类会使API变得非常简洁。这项技术适用于基于接口的框架(interface-based framework)(详见第20条),因为在这种框架中,接口为静态工厂方法提供了自然返回类型。
在Java 8之前,接口不能有静态方法,因此按照惯例,接口Type的静态工厂方法被放在一个名为Types的不可实例化的伴生类(详见第4条)中。例如,Java Collections Framework的集合接口有45个工具实现,分别提供了不可修改的集合、同步集合,等等。几乎所有这些实现都通过静态工厂方法在一个不可实例化的类(java.util.Collections)中导出。所有返回对象的类都是非公有的。
现在的Collections Framework API比导出45个独立公有类的那种实现方式要小得多,每种便利实现都对应一个类。这不仅仅是指API数量上的减少,也是概念意义上的减少:为了使用这个API,用户必须掌握的概念在数量和难度上都减少了。程序员知道,被返回的对象是由相关的接口精确指定的,所以他们不需要阅读有关的类文档。此外,使用这种静态工厂方法时,甚至要求客户端通过接口来引用被返回的对象,而不是通过它的实现类来引用被返回的对象,这是一种良好的习惯(详见第64条)。
从Java 8版本开始,接口中不能包含静态方法的这一限制成为历史,因此一般没有任何理由给接口提供一个不可实例化的伴生类。已经被放在这种类中的许多公有的静态成员,应该被放到接口中去。但是要注意,仍然有必要将这些静态方法背后的大部分实现代码,单独放进一个包级私有的类中。这是因为在Java 8中仍要求接口的所有静态成员都必须是公有的。在Java 9中允许接口有私有的静态方法,但是静态域和静态成员类仍然需要是公有的。
静态工厂的第四大优势在于,所返回的对象的类可以随着每次调用而发生变化,这取决于静态工厂方法的参数值。只要是已声明的返回类型的子类型,都是允许的。返回对象的类也可能随着发行版本的不同而不同。
EnumSet(详见第36条)没有公有的构造器,只有静态工厂方法。在OpenJDK实现中,它们返回两种子类之一的一个实例,具体则取决于底层枚举类型的大小:如果它的元素有64个或者更少,就像大多数枚举类型一样,静态工厂方法就会返回一个RegalarEumSet实例,用单个long进行支持;如果枚举类型有65个或者更多元素,工厂就返回JumboEnumSet实例,用一个long数组进行支持。
这两个实现类的存在对于客户端来说是不可见的。如果RegularEnumSet不能再给小的枚举类型提供性能优势,就可能从未来的发行版本中将它删除,不会造成任何负面的影响。同样地,如果事实证明对性能有好处,也可能在未来的发行版本中添加第三甚至第四个EnumSet实现。客户端永远不知道也不关心它们从工厂方法中得到的对象的类,它们只关心它是EnumSet的某个子类。
静态工厂的第五大优势在于,方法返回的对象所属的类,在编写包含该静态工厂方法的类时可以不存在。这种灵活的静态工厂方法构成了服务提供者框架(Service Provider Framework)的基础,例如JDBC(Java数据库连接)API。服务提供者框架是指这样一个系统:多个服务提供者实现一个服务,系统为服务提供者的客户端提供多个实现,并把它们从多个实现中解耦出来。
服务提供者框架中有三个重要的组件:服务接口(Service Interface),这是提供者实现的;提供者注册API(Provider Registration API),这是提供者用来注册实现的;服务访问API(Service Access API),这是客户端用来获取服务的实例。服务访问API是客户端用来指定某种选择实现的条件。如果没有这样的规定,API就会返回默认实现的一个实例,或者允许客户端遍历所有可用的实现。服务访问API是“灵活的静态工厂”,它构成了服务提供者框架的基础。
服务提供者框架的第四个组件服务提供者接口(Service Provider Interface)是可选的,它表示产生服务接口之实例的工厂对象。如果没有服务提供者接口,实现就通过反射方式进行实例化(详见第65条)。对于JDBC来说,Connection就是其服务接口的一部分,DriverManager.registerDriver是提供者注册API,DriverManager.getConnection
是服务访问API,Driver是服务提供者接口。
服务提供者框架模式有着无数种变体。例如,服务访问API可以返回比提供者需要的更丰富的服务接口。这就是桥接(Bridge)模式[Gamma95]。依赖注入框架(详见第5条)可以被看作是一个强大的服务提供者。从Java 6版本开始,Java平台就提供了一个通用的服务提供者框架java.util.ServiceLoader,因此你不需要(一般来说也不应该)再自己编写了(详见第59条)。JDBC不用ServiceLoader,因为前者出现得比后者早。
静态工厂方法的主要缺点在于,类如果不含公有的或者受保护的构造器,就不能被子类化。例如,要想将Collections Framework中的任何便利的实现类子类化,这是不可能的。但是这样也许会因祸得福,因为它鼓励程序员使用复合(composition),而不是继承(详见第18条),这正是不可变类型所需要的(详见第17条)。
静态工厂方法的第二个缺点在于,程序员很难发现它们。在API文档中,它们没有像构造器那样在API文档中明确标识出来,因此,对于提供了静态工厂方法而不是构造器的类来说,要想查明如何实例化一个类是非常困难的。Javadoc工具总有一天会注意到静态工厂方法。同时,通过在类或者接口注释中关注静态工厂,并遵守标准的命名习惯,也可以弥补这一劣势。下面是静态工厂方法的一些惯用名称。这里只列出了其中的一小部分:
简而言之,静态工厂方法和公有构造器都各有用处,我们需要理解它们各自的长处。静态工厂经常更加合适,因此切忌第一反应就是提供公有的构造器,而不先考虑静态工厂。
第2条:遇到多个构造器参数时要考虑使用构建器
静态工厂和构造器有个共同的局限性:它们都不能很好地扩展到大量的可选参数。比如用一个类表示包装食品外面显示的营养成分标签。这些标签中有几个域是必需的:每份的含量、每罐的含量以及每份的卡路里。还有超过20个的可选域:总脂肪量、饱和脂肪量、转化脂肪、胆固醇、钠,等等。大多数产品在某几个可选域中都会有非零的值。
对于这样的类,应该用哪种构造器或者静态工厂来编写呢?程序员一向习惯采用重叠构造器(telescoping constructor)模式,在这种模式下,提供的第一个构造器只有必要的参数,第二个构造器有一个可选参数,第三个构造器有两个可选参数,依此类推,最后一个构造器包含所有可选的参数。下面有个示例,为了简单起见,它只显示四个可选域:
当你想要创建实例的时候,就利用参数列表最短的构造器,但该列表中包含了要设置的所有参数:
这个构造器调用通常需要许多你本不想设置的参数,但还是不得不为它们传递值。在这个例子中,我们给fat传递了一个值为0。如果“仅仅”是这6个参数,看起来还不算太糟糕,问题是随着参数数目的增加,它很快就失去了控制。
简而言之,重叠构造器模式可行,但是当有许多参数的时候,客户端代码会很难编写,并且仍然较难以阅读。如果读者想知道那些值是什么意思,必须很仔细地数着这些参数来探个究竟。一长串类型相同的参数会导致一些微妙的错误。如果客户端不小心颠倒了其中两个参数的顺序,编译器也不会出错,但是程序在运行时会出现错误的行为(详见第51条)。
遇到许多可选的构造器参数的时候,还有第二种代替办法,即JavaBeans模式,在这种模式下,先调用一个无参构造器来创建对象,然后再调用setter方法来设置每个必要的参数,以及每个相关的可选参数:
这种模式弥补了重叠构造器模式的不足。说得明白一点,就是创建实例很容易,这样产生的代码读起来也很容易:
遗憾的是,JavaBeans模式自身有着很严重的缺点。因为构造过程被分到了几个调用中,在构造过程中JavaBean可能处于不一致的状态。类无法仅仅通过检验构造器参数的有效性来保证一致性。试图使用处于不一致状态的对象将会导致失败,这种失败与包含错误的代码大相径庭,因此调试起来十分困难。与此相关的另一点不足在于,JavaBeans模式使得把类做成不可变的可能性不复存在(详见第17条),这就需要程序员付出额外的努力来确保它的线程安全。
当对象的构造完成,并且不允许在冻结之前使用时,通过手工“冻结”对象可以弥补这些不足,但是这种方式十分笨拙,在实践中很少使用。此外,它甚至会在运行时导致错误,因为编译器无法确保程序员会在使用之前先调用对象上的freeze方法进行冻结。
幸运的是,还有第三种替代方法,它既能保证像重叠构造器模式那样的安全性,也能保证像JavaBeans模式那么好的可读性。这就是建造者(Builder)模式[Gamma95]的一种形式。它不直接生成想要的对象,而是让客户端利用所有必要的参数调用构造器(或者静态工厂),得到一个builder对象。然后客户端在builder对象上调用类似于setter的方法,来设置每个相关的可选参数。最后,客户端调用无参的build方法来生成通常是不可变的对象。这个builder通常是它构建的类的静态成员类(详见第24条)。下面就是它的示例:
注意NutritionFacts是不可变的,所有的默认参数值都单独放在一个地方。builder的设值方法返回builder本身,以便把调用链接起来,得到一个流式的API。下面就是其客户端代码:
这样的客户端代码很容易编写,更为重要的是易于阅读。Builder模式模拟了具名的可选参数,就像Python和Scala编程语言中的一样。
为了简洁起见,示例中省略了有效性检查。要想尽快侦测到无效的参数,可以在builder的构造器和方法中检查参数的有效性。查看不可变量,包括build方法调用的构造器中的多个参数。为了确保这些不变量免受攻击,从builder复制完参数之后,要检查对象域(详见第50条)。如果检查失败,就抛出IllegalArgumentException(详见第72条),其中的详细信息会说明哪些参数是无效的(详见第75条)。
Builder模式也适用于类层次结构。使用平行层次结构的builder时,各自嵌套在相应的类中。抽象类有抽象的builder,具体类有具体的builder。假设用类层次根部的一个抽象类表示各式各样的比萨:
注意,Pizza.Builder的类型是泛型(generic type),带有一个递归类型参数(recursive
type parameter),详见第30条。它和抽象的self方法一样,允许在子类中适当地进行方法链接,不需要转换类型。这个针对Java缺乏self类型的解决方案,被称作模拟的self类型(simulated self-type)。
这里有两个具体的Pizza子类,其中一个表示经典纽约风味的比萨,另一个表示馅料内置的半月型(calzone)比萨。前者需要一个尺寸参数,后者则要你指定酱汁应该内置还是外置:
注意,每个子类的构建器中的build方法,都声明返回正确的子类:NyPizza.Builder
的build方法返回NyPizza,而Calzone.Builder中的则返回Calzone。在该方法中,子类方法声明返回超级类中声明的返回类型的子类型,这被称作协变返回类型(covariant return type)。它允许客户端无须转换类型就能使用这些构建器。
这些“层次化构建器”的客户端代码本质上与简单的NutritionFacts构建器一样。为了简洁起见,下列客户端代码示例假设是在枚举常量上静态导入:
与构造器相比,builder的微略优势在于,它可以有多个可变(varargs)参数。因为builder是利用单独的方法来设置每一个参数。此外,构造器还可以将多次调用某一个方法而传入的参数集中到一个域中,如前面的调用了两次addTopping方法的代码所示。
Builder模式十分灵活,可以利用单个builder构建多个对象。builder的参数可以在调用build方法来创建对象期间进行调整,也可以随着不同的对象而改变。builder可以自动填充某些域,例如每次创建对象时自动增加序列号。
Builder模式的确也有它自身的不足。为了创建对象,必须先创建它的构建器。虽然创建这个构建器的开销在实践中可能不那么明显,但是在某些十分注重性能的情况下,可能就成问题了。Builder模式还比重叠构造器模式更加冗长,因此它只在有很多参数的时候才使用,比如4个或者更多个参数。但是记住,将来你可能需要添加参数。如果一开始就使用构造器或者静态工厂,等到类需要多个参数时才添加构造器,就会无法控制,那些过时的构造器或者静态工厂显得十分不协调。因此,通常最好一开始就使用构建器。
简而言之,如果类的构造器或者静态工厂中具有多个参数,设计这种类时,Builder模式就是一种不错的选择,特别是当大多数参数都是可选或者类型相同的时候。与使用重叠构造器模式相比,使用Builder模式的客户端代码将更易于阅读和编写,构建器也比JavaBeans更加安全。
第3条:用私有构造器或者枚举类型强化Singleton属性
Singleton是指仅仅被实例化一次的类[Gamma95]。Singleton通常被用来代表一个无状态的对象,如函数(详见第24条),或者那些本质上唯一的系统组件。**使类成为Singleton
会使它的客户端测试变得十分困难,**因为不可能给Singleton替换模拟实现,除非实现一个充当其类型的接口。
实现Singleton有两种常见的方法。这两种方法都要保持构造器为私有的,并导出公有的静态成员,以便允许客户端能够访问该类的唯一实例。在第一种方法中,公有静态成员是个final域:
私有构造器仅被调用一次,用来实例化公有的静态final域Elvis.INSTANCE。由于缺少公有的或者受保护的构造器,所以保证了Elvis的全局唯一性:一旦Elvis类被实例化,将只会存在一个Elvis实例,不多也不少。客户端的任何行为都不会改变这一点,但要提醒一点:享有特权的客户端可以借助AccessibleObject.setAccessible方法,通过反射机制(详见第65条)调用私有构造器。如果需要抵御这种攻击,可以修改构造器,让它在被要求创建第二个实例的时候抛出异常。
在实现Singleton的第二种方法中,公有的成员是个静态工厂方法:
对于静态方法Elvis.getInstance的所有调用,都会返回同一个对象引用,所以,永远不会创建其他的Elvis实例(上述提醒依然适用)。
公有域方法的主要优势在于,API很清楚地表明了这个类是一个Singleton:公有的静态域是f?inal的,所以该域总是包含相同的对象引用。第二个优势在于它更简单。
静态工厂方法的优势之一在于,它提供了灵活性:在不改变其API的前提下,我们可以改变该类是否应该为Singleton的想法。工厂方法返回该类的唯一实例,但是,它很容易被修改,比如改成为每个调用该方法的线程返回一个唯一的实例。第二个优势是,如果应用程序需要,可以编写一个泛型Singleton工厂(generic singleton factory)(详见第30条)。使用静态工厂的最后一个优势是,可以通过方法引用(method reference)作为提供者,比如Elvis::instance就是一个Supplier。除非满足以上任意一种优势,否则还是优先考虑公有域(public-f?ield)的方法。
为了将利用上述方法实现的Singleton类变成是可序列化的(Serializable)(详见第12章),仅仅在声明中加上implements Serializable是不够的。为了维护并保证Singleton,
必须声明所有实例域都是瞬时(transient)的,并提供一个readResolve方法(详见第89条)。否则,每次反序列化一个序列化的实例时,都会创建一个新的实例,比如,在我们的例子中,会导致“假冒的Elvis”。为了防止发生这种情况,要在Elvis类中加入如下readResolve方法:
实现Singleton的第三种方法是声明一个包含单个元素的枚举类型:
这种方法在功能上与公有域方法相似,但更加简洁,无偿地提供了序列化机制,绝对防止多次实例化,即使是在面对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型经常成为实现Singleton的最佳方法。注意,如果Singleton必须扩展一个超类,而不是扩展Enum的时候,则不宜使用这个方法(虽然可以声明枚举去实现接口)。
第4条:通过私有构造器强化不可实例化的能力
有时可能需要编写只包含静态方法和静态域的类。这些类的名声很不好,因为有些人在面向对象的语言中滥用这样的类来编写过程化的程序,但它们也确实有特别的用处。我们可以利用这种类,以java.lang.Math或者java.util.Arrays的方式,把基本类型的值或者数组类型上的相关方法组织起来。我们也可以通过java.util.Collections的方式,把实现特定接口的对象上的静态方法,包括工厂方法(详见第1条)组织起来。(从Java 8开始,也可以把这些方法放进接口中,假定这是你自己编写的接口可以进行修改。)最后,还可以利用这种类把final类上的方法组织起来,因为不能把它们放在子类中。
这样的工具类(utility class)不希望被实例化,因为实例化对它没有任何意义。然而,在缺少显式构造器的情况下,编译器会自动提供一个公有的、无参的缺省构造器(default constructor)。对于用户而言,这个构造器与其他的构造器没有任何区别。在已发行的API中常常可以看到一些被无意识地实例化的类。
企图通过将类做成抽象类来强制该类不可被实例化是行不通的。该类可以被子类化,并且该子类也可以被实例化。这样做甚至会误导用户,以为这种类是专门为了继承而设计的(详见第19条)。然而,有一些简单的习惯用法可以确保类不可被实例化。由于只有当类不包含显式的构造器时,编译器才会生成缺省的构造器,因此只要让这个类包含一个私有构造器,它就不能被实例化:
由于显式的构造器是私有的,所以不可以在该类的外部访问它。AssertionError不是必需的,但是它可以避免不小心在类的内部调用构造器。它保证该类在任何情况下都不会被实例化。这种习惯用法有点违背直觉,好像构造器就是专门设计成不能被调用一样。因此,明智的做法就是在代码中增加一条注释,如上所示。
这种习惯用法也有副作用,它使得一个类不能被子类化。所有的构造器都必须显式或隐式地调用超类(superclass)构造器,在这种情形下,子类就没有可访问的超类构造器可调用了。
第5条:优先考虑依赖注入来引用资源
有许多类会依赖一个或多个底层的资源。例如,拼写检查器需要依赖词典。因此,像下面这样把类实现为静态工具类的做法并不少见(详见第4条):
同样地,将这些类实现为Singleton的做法也并不少见(详见第3条):
以上两种方法都不理想,因为它们都是假定只有一本词典可用。实际上,每一种语言都有自己的词典,特殊词汇还要使用特殊的词典。此外,可能还需要用特殊的词典进行测试。因此假定用一本词典就能满足所有需求,这简直是痴心妄想。
建议尝试用SpellChecker来支持多词典,即在现有的拼写检查器中,设dictionary域为nonfinal,并添加一个方法用它来修改词典,但是这样的设置会显得很笨拙、容易出错,并且无法并行工作。静态工具类和Singleton类不适合于需要引用底层资源的类。
这里需要的是能够支持类的多个实例(在本例中是指SpellChecker),每一个实例都使用客户端指定的资源(在本例中是指词典)。满足该需求的最简单的模式是,当创建一个新的实例时,就将该资源传到构造器中。这是依赖注入(dependencyinjection)的一种形式:词典(dictionary)是拼写检查器的一个依赖(dependency),在创建拼写检查器时就将词典注入(injected)其中。
依赖注入模式就是这么简单,因此许多程序员使用多年,却不知道它还有名字呢。虽然这个拼写检查器的范例中只有一个资源(词典),但是依赖注入却适用于任意数量的资源,以及任意的依赖形式。依赖注入的对象资源具有不可变性(详见第17条),因此多个客户端可以共享依赖对象(假设客户端们想要的是同一个底层资源)。依赖注入也同样适用于构造器、静态工厂(详见第1条)和构建器(详见第2条)。
这个程序模式的另一种有用的变体是,将资源工厂(factory)传给构造器。工厂是可以被重复调用来创建类型实例的一个对象。这类工厂具体表现为工厂方法(Factory Method)模式[Gamma95]。在Java 8中增加的接口Supplier,最适合用于表示工厂。带有Supplier的方法,通常应该限制输入工厂的类型参数使用有限制的通配符类型(bounded wildcard type),详见第31条,以便客户端能够传入一个工厂,来创建指定类型的任意子类型。例如,下面是一个生产马赛克的方法,它利用客户端提供的工厂来生产每一片马赛克:
虽然依赖注入极大地提升了灵活性和可测试性,但它会导致大型项目凌乱不堪,因为它通常包含上千个依赖。不过这种凌乱用一个依赖注入框架(dependency injection framework)便可以终结,如Dagger [Dagger]、Guice [Guice]或者Spring [Spring]。这些框架的用法超出了本书的讨论范畴,但是,请注意:设计成手动依赖注入的API,一般都适用于这些框架。
总而言之,不要用Singleton和静态工具类来实现依赖一个或多个底层资源的类,且该资源的行为会影响到该类的行为;也不要直接用这个类来创建这些资源。而应该将这些资源或者工厂传给构造器(或者静态工厂,或者构建器),通过它们来创建类。这个实践就被称作依赖注入,它极大地提升了类的灵活性、可重用性和可测试性。
第6条:避免创建不必要的对象
一般来说,最好能重用单个对象,而不是在每次需要的时候就创建一个相同功能的新对象。重用方式既快速,又流行。如果对象是不可变的(immutable)(详见第17条),它就始终可以被重用。
作为一个极端的反面例子,看看下面的语句:
该语句每次被执行的时候都创建一个新的String实例,但是这些创建对象的动作全都是不必要的。传递给String构造器的参数(“bikini”)本身就是一个String实例,功能方面等同于构造器创建的所有对象。如果这种用法是在一个循环中,或者是在一个被频繁调用的方法中,就会创建出成千上万不必要的String实例。
改进后的版本如下所示:
这个版本只用了一个String实例,而不是每次执行的时候都创建一个新的实例。而且,它可以保证,对于所有在同一台虚拟机中运行的代码,只要它们包含相同的字符串字面常量,该对象就会被重用[JLS,3.10.5]。
对于同时提供了静态工厂方法(static factory method)(详见第1条)和构造器的不可变类,通常优先使用静态工厂方法而不是构造器,以避免创建不必要的对象。例如,静态工厂方法Boolean.valueOf (String)几乎总是优先于构造器Boolean(String),注意构造器Boolean(String)在Java 9中已经被废弃了。构造器在每次被调用的时候都会创建一个新的对象,而静态工厂方法则从来不要求这样做,实际上也不会这样做。除了重用不可变的对象之外,也可以重用那些已知不会被修改的可变对象。
有些对象创建的成本比其他对象要高得多。如果重复地需要这类“昂贵的对象”,建议将它缓存下来重用。遗憾的是,在创建这种对象的时候,并非总是那么显而易见。假设想要编写一个方法,用它确定一个字符串是否为一个有效的罗马数字。下面介绍一种最容易的方法,使用一个正则表达式:
这个实现的问题在于它依赖String.matches方法。虽然String.matches方法最易于查看一个字符串是否与正则表达式相匹配,但并不适合在注重性能的情形中重复使用。问题在于,它在内部为正则表达式创建了一个Pattern实例,却只用了一次,之后就可以进行垃圾回收了。创建Pattern实例的成本很高,因为需要将正则表达式编译成一个有限状态机(f?inite state machine)。
为了提升性能,应该显式地将正则表达式编译成一个Pattern实例(不可变),让它成为类初始化的一部分,并将它缓存起来,每当调用isRomanNumeral方法的时候就重用同一个实例:
改进后的isRomanNumeral方法如果被频繁地调用,会显示出明显的性能优势。在我的机器上,原来的版本在一个8字符的输入字符串上花了1.1μs,而改进后的版本只花了0.17μs,速度快了6.5倍。除了提高性能之外,可以说代码也更清晰了。将不可见的Pattern实例做成f?inal静态域时,可以给它起个名字,这样会比正则表达式本身更有可读性。
如果包含改进后的isRomanNumeral方法的类被初始化了,但是该方法没有被调用,那就没必要初始化ROMAN域。通过在isRomanNumeral方法第一次被调用的时候延迟初始化(lazily initializing)(详见第83条)这个域,有可能消除这个不必要的初始化工作,但是不建议这样做。正如延迟初始化中常见的情况一样,这样做会使方法的实现更加复杂,从而无法将性能显著提高到超过已经达到的水平(详见第67条)。
如果一个对象是不变的,那么它显然能够被安全地重用,但其他有些情形则并不总是这么明显。考虑适配器(adapter)的情形[Gamma95],有时也叫作视图(view)。适配器是指这样一个对象:它把功能委托给一个后备对象(backing object),从而为后备对象提供一个可以替代的接口。由于适配器除了后备对象之外,没有其他的状态信息,所以针对某个给定对象的特定适配器而言,它不需要创建多个适配器实例。
例如,Map接口的keySet方法返回该Map对象的Set视图,其中包含该Map中所有的键(key)。乍看之下,好像每次调用keySet都应该创建一个新的Set实例,但是,对于一个给定的Map对象,实际上每次调用keySet都返回同样的Set实例。虽然被返回的Set实例一般是可改变的,但是所有返回的对象在功能上是等同的:当其中一个返回对象发生变化的时候,所有其他的返回对象也要发生变化,因为它们是由同一个Map实例支撑的。虽然创建keySet视图对象的多个实例并无害处,却是没有必要,也没有好处的。
另一种创建多余对象的方法,称作自动装箱(autoboxing),它允许程序员将基本类型和装箱基本类型(Boxed Primitive Type)混用,按需要自动装箱和拆箱。自动装箱使得基本类型和装箱基本类型之间的差别变得模糊起来,但是并没有完全消除。它们在语义上还有着微妙的差别,在性能上也有着比较明显的差别(详见第61条)。请看下面的程序,它计算所有int正整数值的总和。为此,程序必须使用long算法,因为int不够大,无法容纳所有int正整数值的总和:
这段程序算出的答案是正确的,但是比实际情况要更慢一些,只因为打错了一个字符。变量sum被声明成Long而不是long,意味着程序构造了大约231个多余的Long实例(大约每次往Long sum中增加long时构造一个实例)。将sum的声明从Long改成long,在我的机器上使运行时间从6.3秒减少到了0.59秒。结论很明显:要优先使用基本类型而不是装箱基本类型,要当心无意识的自动装箱。
不要错误地认为本条目所介绍的内容暗示着“创建对象的代价非常昂贵,我们应该要尽可能地避免创建对象”。相反,由于小对象的构造器只做很少量的显式工作,所以小对象的创建和回收动作是非常廉价的,特别是在现代的JVM实现上更是如此。通过创建附加的对象,提升程序的清晰性、简洁性和功能性,这通常是件好事。
反之,通过维护自己的对象池(object pool)来避免创建对象并不是一种好的做法,除非池中的对象是非常重量级的。正确使用对象池的典型对象示例就是数据库连接池。建立数据库连接的代价是非常昂贵的,因此重用这些对象非常有意义。而且,数据库的许可可能限制你只能使用一定数量的连接。但是,一般而言,维护自己的对象池必定会把代码弄得很乱,同时增加内存占用(footprint),并且还会损害性能。现代的JVM实现具有高度优化的垃圾回收器,其性能很容易就会超过轻量级对象池的性能。
与本条目对应的是第50条中有关“保护性拷贝”(defensive copying)的内容。本条目提及“当你应该重用现有对象的时候,请不要创建新的对象”,而第50条则说“当你应该创建新对象的时候,请不要重用现有的对象”。注意,在提倡使用保护性拷贝的时候,因重用对象而付出的代价要远远大于因创建重复对象而付出的代价。必要时如果没能实施保护性拷贝,将会导致潜在的Bug和安全漏洞;而不必要地创建对象则只会影响程序的风格和性能。
第7条:消除过期的对象引用
当你从手工管理内存的语言(比如C或C++)转换到具有垃圾回收功能的比如Java语言时,程序员的工作会变得更加容易,因为当你用完了对象之后,它们会被自动回收。当你第一次经历对象回收功能的时候,会觉得这简直有点不可思议。它很容易给你留下这样的印象,认为自己不再需要考虑内存管理的事情了,其实不然。
请看下面这个简单的栈实现的例子:
这段程序(它的泛型版本请见第29条)中并没有很明显的错误。无论如何测试,它都会成功地通过每一项测试,但是这个程序中隐藏着一个问题。不严格地讲,这段程序有一个“内存泄漏”,随着垃圾回收器活动的增加,或者由于内存占用的不断增加,程序性能的降低会逐渐表现出来。在极端的情况下,这种内存泄漏会导致磁盘交换(Disk Paging),甚至导致程序失败(OutOfMemoryError错误),但是这种失败情形相对比较少见。
那么,程序中哪里发生了内存泄漏呢?如果一个栈先是增长,然后再收缩,那么,从栈中弹出来的对象将不会被当作垃圾回收,即使使用栈的程序不再引用这些对象,它们也不会被回收。这是因为栈内部维护着对这些对象的过期引用(obsolete reference)。所谓的过期引用,是指永远也不会再被解除的引用。在本例中,凡是在elements数组的“活动部分”(active portion)之外的任何引用都是过期的。活动部分是指elements中下标小于size的那些元素。
在支持垃圾回收的语言中,内存泄漏是很隐蔽的(称这类内存泄漏为“无意识的对象保持”(unintentional object retention)更为恰当)。如果一个对象引用被无意识地保留起来了,那么垃圾回收机制不仅不会处理这个对象,而且也不会处理被这个对象所引用的所有其他对象。即使只有少量的几个对象引用被无意识地保留下来,也会有许许多多的对象被排除在垃圾回收机制之外,从而对性能造成潜在的重大影响。
这类问题的修复方法很简单:一旦对象引用已经过期,只需清空这些引用即可。对于上述例子中的Stack类而言,只要一个单元被弹出栈,指向它的引用就过期了。pop方法的修订版本如下所示:
清空过期引用的另一个好处是,如果它们以后又被错误地解除引用,程序就会立即抛出NullPointerException异常,而不是悄悄地错误运行下去。尽快地检测出程序中的错误总是有益的。
当程序员第一次被类似这样的问题困扰的时候,他们往往会过分小心:对于每一个对象引用,一旦程序不再用到它,就把它清空。其实这样做既没必要,也不是我们所期望的,因为这样做会把程序代码弄得很乱。清空对象引用应该是一种例外,而不是一种规范行为。消除过期引用最好的方法是让包含该引用的变量结束其生命周期。如果你是在最紧凑的作用域范围内定义每一个变量(详见第57条),这种情形就会自然而然地发生。
那么,何时应该清空引用呢?Stack类的哪方面特性使它易于遭受内存泄漏的影响呢?简而言之,问题在于,Stack类自己管理内存。存储池(storage pool)包含了elements数组(对象引用单元,而不是对象本身)的元素。数组活动区域(同前面的定义)中的元素是已分配的(allocated),而数组其余部分的元素则是*的(free)。但是垃圾回收器并不知道这一点;对于垃圾回收器而言,elements数组中的所有对象引用都同等有效。只有程序员知道数组的非活动部分是不重要的。程序员可以把这个情况告知垃圾回收器,做法很简单:一旦数组元素变成了非活动部分的一部分,程序员就手工清空这些数组元素。
一般来说,只要类是自己管理内存,程序员就应该警惕内存泄漏问题。一旦元素被释放掉,则该元素中包含的任何对象引用都应该被清空。
内存泄漏的另一个常见来源是缓存。一旦你把对象引用放到缓存中,它就很容易被遗忘掉,从而使得它不再有用之后很长一段时间内仍然留在缓存中。对于这个问题,有几种可能的解决方案。如果你正好要实现这样的缓存:只要在缓存之外存在对某个项的键的引用,该项就有意义,那么就可以用WeakHashMap代表缓存;当缓存中的项过期之后,它们就会自动被删除。记住只有当所要的缓存项的生命周期是由该键的外部引用而不是由值决定时,WeakHashMap才有用处。
更为常见的情形则是,“缓存项的生命周期是否有意义”并不是很容易确定,随着时间的推移,其中的项会变得越来越没有价值。在这种情况下,缓存应该时不时地清除掉没用的项。这项清除工作可以由一个后台线程(可能是ScheduledThreadPoolExecutor)来完成,或者也可以在给缓存添加新条目的时候顺便进行清理。LinkedHashMap类利用它的removeEldestEntry方法可以很容易地实现后一种方案。对于更加复杂的缓存,必须直接使用java.lang.ref。
内存泄漏的第三个常见来源是监听器和其他回调。如果你实现了一个API,客户端在这个API中注册回调,却没有显式地取消注册,那么除非你采取某些动作,否则它们就会不断地堆积起来。确保回调立即被当作垃圾回收的最佳方法是只保存它们的弱引用(weak reference),例如,只将它们保存成WeakHashMap中的键。
由于内存泄漏通常不会表现成明显的失败,所以它们可以在一个系统中存在很多年。往往只有通过仔细检查代码,或者借助于Heap剖析工具(Heap Prof?iler)才能发现内存泄漏问题。因此,如果能够在内存泄漏发生之前就知道如何预测此类问题,并阻止它们发生,那是最好不过的了。
第8条:避免使用终结方法和清除方法
终结方法(finalizer)通常是不可预测的,也是很危险的,一般情况下是不必要的。使用终结方法会导致行为不稳定、性能降低,以及可移植性问题。当然,终结方法也有其可用之处,我们将在本条目的最后再做介绍;但是根据经验,应该避免使用终结方法。在Java 9中用清除方法(cleaner)代替了终结方法。清除方法没有终结方法那么危险,但仍然是不可预测、运行缓慢,一般情况下也是不必要的。
C++的程序员被告知“不要把终结方法当作是C++中析构器(destructors)的对应物”。在C++中,析构器是回收一个对象所占用资源的常规方法,是构造器所必需的对应物。在Java中,当一个对象变得不可到达的时候,垃圾回收器会回收与该对象相关联的存储空间,并不需要程序员做专门的工作。C++的析构器也可以被用来回收其他的非内存资源。而在Java中,一般用try-finally块来完成类似的工作(详见第9条)。
终结方法和清除方法的缺点在于不能保证会被及时执行[JLS,12.6]。从一个对象变得不可到达开始,到它的终结方法被执行,所花费的这段时间是任意长的。这意味着,注重时间(time-critical)的任务不应该由终结方法或者清除方法来完成。例如,用终结方法或者清除方法来关闭已经打开的文件,就是一个严重的错误,因为打开文件的描述符是一种很有限的资源。如果系统无法及时运行终结方法或者清除方法就会导致大量的文件仍然保留在打开状态,于是当一个程序再也不能打开文件的时候,它可能会运行失败。
及时地执行终结方法和清除方法正是垃圾回收算法的一个主要功能,这种算法在不同的JVM实现中会大相径庭。如果程序依赖于终结方法或者清除方法被执行的时间点,那么这个程序的行为在不同的JVM中运行的表现可能就会截然不同。一个程序在你测试用的JVM平台上运行得非常好,而在你最重要顾客的JVM平台上却根本无法运行,这是完全有可能的。
延迟终结过程并不只是一个理论问题。在很少见的情况下,为类提供终结方法,可能会随意地延迟其实例的回收过程。一位同事最近在调试一个长期运行的GUI应用程序的时候,该应用程序莫名其妙地出现OutOfMemoryError错误而死掉。分析表明,该应用程序死掉的时候,其终结方法队列中有数千个图形对象正在等待被终结和回收。遗憾的是,终结方法线程的优先级比该应用程序的其他线程的优先级要低得多,所以,图形对象的终结速度达不到它们进入队列的速度。Java语言规范并不保证哪个线程将会执行终结方法,所以,除了不使用终结方法之外,并没有很轻便的办法能够避免这样的问题。在这方面,清除方法比终结方法稍好一些,因为类的设计者可以控制自己的清除线程,但清除方法仍然在后台运行,处于垃圾回收器的控制之下,因此不能确保及时清除。
Java语言规范不仅不保证终结方法或者清除方法会被及时地执行,而且根本就不保证它们会被执行。当一个程序终止的时候,某些已经无法访问的对象上的终结方法却根本没有被执行,这是完全有可能的。结论是:永远不应该依赖终结方法或者清除方法来更新重要的持久状态。例如,依赖终结方法或者清除方法来释放共享资源(比如数据库)上的永久锁,这很容易让整个分布式系统垮掉。
不要被System.gc和System.runFinalization这两个方法所诱惑,它们确实增加了终结方法或者清除方法被执行的机会,但是它们并不保证终结方法或者清除方法一定会被执行。唯一声称保证它们会被执行的两个方法是System.runFinalizersOnExit,及其臭名昭著的孪生兄弟Runtime.runFinalizersOnExit。这两个方法都有致命的缺陷,并且已经被废弃很久了[ThreadStop]。
使用终结方法的另一个问题是:如果忽略在终结过程中被抛出来的未被捕获的异常,该对象的终结过程也会终止[JLS,12.6]。未被捕获的异常会使对象处于破坏的状态(corrupt state),如果另一个线程企图使用这种被破坏的对象,则可能发生任何不确定的行为。正常情况下,未被捕获的异常将会使线程终止,并打印出栈轨迹(Stack Trace),但是,如果异常发生在终结方法之中,则不会如此,甚至连警告都不会打印出来。清除方法没有这个问题,因为使用清除方法的一个类库在控制它的线程。
使用终结方法和清除方法有一个非常严重的性能损失。在我的机器上,创建一个简单的AutoCloseable对象,用try-with-resources将它关闭,再让垃圾回收器将它回收,完成这些工作花费的时间大约为12ns。增加一个终结方法使时间增加到了550ns。换句话说,用终结方法创建和销毁对象慢了大约50倍。这主要是因为终结方法阻止了有效的垃圾回收。如果用清除方法来清除类的所有实例,它的速度比终结方法会稍微快一些(在我的机器上大约是每个实例花500ns),但如果只是把清除方法作为一道安全网(safety net),下面将会介绍,那么清除方法的速度还会更快一些。在这种情况下,创建、清除和销毁对象,在我的机器上花了大约66ns,这意味着,如果没有使用它,为了确保安全网多花了5倍(而不是50倍)的代价。
终结方法有一个严重的安全问题:它们为终结方法攻击(f?inalizer attack)打开了类的大门。终结方法攻击背后的思想很简单:如果从构造器或者它的序列化对等体(readObject和readResolve方法,详见第12章)抛出异常,恶意子类的终结方法就可以在构造了一部分的应该已经半途夭折的对象上运行。这个终结方法会将对该对象的引用记录在一个静态域中,阻止它被垃圾回收。一旦记录到异常的对象,就可以轻松地在这个对象上调用任何原本永远不允许在这里出现的方法。从构造器抛出的异常,应该足以防止对象继续存在;有了终结方法的存在,这一点就做不到了。这种攻击可能造成致命的后果。final类不会受到终结方法攻击,因为没有人能够编写出final类的恶意子类。为了防止非final类受到终结方法攻击,要编写一个空的final的finalize方法。
那么,如果类的对象中封装的资源(例如文件或者线程)确实需要终止,应该怎么做才能不用编写终结方法或者清除方法呢?只需让类实现AutoCloseable,并要求其客户端在每个实例不再需要的时候调用close方法,一般是利用try-with-resources确保终止,即使遇到异常也是如此(详见第9条)。值得提及的一个细节是,该实例必须记录下自己是否已经被关闭了:close方法必须在一个私有域中记录下“该对象已经不再有效”。如果这些方法是在对象已经终止之后被调用,其他的方法就必须检查这个域,并抛出IllegalStateException异常。
那么终结方法和清除方法有什么好处呢?它们有两种合法用途。第一种用途是,当资源的所有者忘记调用它的close方法时,终结方法或者清除方法可以充当“安全网”。虽然这样做并不能保证终结方法或者清除方法会被及时地运行,但是在客户端无法正常结束操作的情况下,迟一点释放资源总比永远不释放要好。如果考虑编写这样的安全网终结方法,就要认真考虑清楚,这种保护是否值得付出这样的代价。有些Java类(如FileInputStream、FileOutputStream、ThreadPoolExecutor和java.sql.Connection)都具有能充当安全网的终结方法。
清除方法的第二种合理用途与对象的本地对等体(native peer)有关。本地对等体是一个本地(非Java的)对象(native object),普通对象通过本地方法(native method)委托给一个本地对象。因为本地对等体不是一个普通对象,所以垃圾回收器不会知道它,当它的Java对等体被回收的时候,它不会被回收。如果本地对等体没有关键资源,并且性能也可以接受的话,那么清除方法或者终结方法正是执行这项任务最合适的工具。如果本地对等体拥有必须被及时终止的资源,或者性能无法接受,那么该类就应该具有一个close方法,如前所述。
清除方法的使用有一定的技巧。下面以一个简单的Room类为例。假设房间在收回之前必须进行清除。Room类实现了AutoCloseable;它利用清除方法自动清除安全网的过程只不过是一个实现细节。与终结方法不同的是,清除方法不会污染类的公有API:
内嵌的静态类State保存清除方法清除房间所需的资源。在这个例子中,就是num-JunkPiles域,表示房间的杂乱度。更现实地说,它可以是f?inal的long,包含一个指向本地对等体的指针。State实现了Runnable接口,它的run方法最多被Cleanable调用一次,后者是我们在Room构造器中用清除器注册State实例时获得的。以下两种情况之一会触发run方法的调用:通常是通过调用Room的close方法触发的,后者又调用了Cleanable的清除方法。如果到了Room实例应该被垃圾回收时,客户端还没有调用close方法,清除方法就会(希望如此)调用State的run方法。
关键是State实例没有引用它的Room实例。如果它引用了,会造成循环,阻止Room实例被垃圾回收(以及防止被自动清除)。因此State必须是一个静态的嵌套类,因为非静态的嵌套类包含了对其外围实例的引用(详见第24条)。同样地,也不建议使用lambda,因为它们很容易捕捉到对外围对象的引用。
如前所述,Room的清除方法只用作安全网。如果客户端将所有的Room实例化都包在try-
with-resource块中,将永远不会请求到自动清除。用下面这个表现良好的客户端代码示范一下:
正如所期待的一样,运行Adult程序会打印出Goodbye,接着是Cleaning room。但是下面这个表现糟糕的程序又如何呢?哪一个将永远不会清除它的房间?
你可能期望打印出Peace out,然后是Cleaning room,但是在我的机器上,它没有打印出Cleaning room,就退出程序了。这就是我们之前提到过的不可预见性。Cleaner规范指出:“清除方法在System.exit期间的行为是与实现相关的。不确保清除动作是否会被调用。”虽然规范没有指明,其实对于正常的程序退出也是如此。在我的机器上,只要在Teenager的main方法上添加代码行System.gc(),就足以让它在退出之前打印出Cleaning room,但是不能保证在你的机器上也能看到相同的行为。
总而言之,除非是作为安全网,或者是为了终止非关键的本地资源,否则请不要使用清除方法,对于在Java 9之前的发行版本,则尽量不要使用终结方法。若使用了终结方法或者清除方法,则要注意它的不确定性和性能后果。
第9条:try-with-resources优先于try-finally
Java类库中包括许多必须通过调用close方法来手工关闭的资源。例如InputStream、OutputStream和java.sql.Connection。客户端经常会忽略资源的关闭,造成严重的性能后果也就可想而知了。虽然这其中的许多资源都是用终结方法作为安全网,但是效果并不理想(详见第8条)。
根据经验,try-finally语句是确保资源会被适时关闭的最佳方法,就算发生异常或者返回也一样:
这看起来好像也不算太坏,但是如果再添加第二个资源,就会一团糟了:
这可能令人有点难以置信,不过就算优秀的程序员也会经常犯这样的错误。起先,我曾经在《Java Puzzlers》[Bloch05]第88页犯过这个错误,时隔多年竟然没有人发现。事实上,在2007年,close方法在Java类库中有2/3都用错了。
即便用try-finally语句正确地关闭了资源,如前两段代码范例所示,它也存在着些许不足。因为在try块和finally块中的代码,都会抛出异常。例如,在firstLineOfFile方法中,如果底层的物理设备异常,那么调用readLine就会抛出异常,基于同样的原因,调用close也会出现异常。在这种情况下,第二个异常完全抹除了第一个异常。在异常堆栈轨迹中,完全没有关于第一个异常的记录,这在现实的系统中会导致调试变得非常复杂,因为通常需要看到第一个异常才能诊断出问题何在。虽然可以通过编写代码来禁止第二个异常,保留第一个异常,但事实上没有人会这么做,因为实现起来太烦琐了。
当Java 7引入try-with-resources语句时[JLS, 14.20.3],所有这些问题一下子就全部解决了。要使用这个构造的资源,必须先实现AutoCloseable接口,其中包含了单个返回void的close方法。Java类库与第三方类库中的许多类和接口,现在都实现或扩展了AutoCloseable接口。如果编写了一个类,它代表的是必须被关闭的资源,那么这个类也应该实现AutoCloseable。
以下就是使用try-with-resources的第一个范例:
以下是使用try-with-resources的第二个范例:
使用try-with-resources不仅使代码变得更简洁易懂,也更容易进行诊断。以first-
LineOfFile方法为例,如果调用readLine和(不可见的) close方法都抛出异常,后一个异常就会被禁止,以保留第一个异常。事实上,为了保留你想要看到的那个异常,即便多个异常都可以被禁止。这些被禁止的异常并不是简单地被抛弃了,而是会被打印在堆栈轨迹中,并注明它们是被禁止的异常。通过编程调用getSuppressed方法还可以访问到它们,getsuppressed方法也已经添加在Java 7的Throwable中了。
在try-with-resources语句中还可以使用catch子句,就像在平时的try-finally语句中一样。这样既可以处理异常,又不需要再套用一层代码。下面举一个稍费了点心思的范例,这个firstLineOfFile方法没有抛出异常,但是如果它无法打开文件,或者无法从中读取,就会返回一个默认值:
结论很明显:在处理必须关闭的资源时,始终要优先考虑用try-with-resources,而不是用try-finally。这样得到的代码将更加简洁、清晰,产生的异常也更有价值。有了try-with-resources语句,在使用必须关闭的资源时,就能更轻松地正确编写代码了。实践证明,这个用try-finally是不可能做到的。