chapter_02 创建和销毁对象
Creating and Destroying Objects
本章涉及创建和销毁对象:何时以及如何创建对象,何时以及如何避免创建对象,如何确保它们被及时销毁,以及如何管理在销毁之前必须执行的清理操作。
Item-1:用静态工厂方法代替构造函数
在 Java 中,获得一个类实例最简单的方法就是使用 new 关键字,即通过构造函数来实现对象的创建。就像这样:
|
|
不过在实际的开发中,我们经常还会见到另外一种获取类实例的方法:
|
|
像这样的:不通过 new,而是用一个静态方法来对外提供自身实例的方法,即为我们所说的静态工厂方法。要注意的是静态工厂方法与设计模式中的工厂方法模式不同。静态工厂方法与构造函数相比的优点如下。
静态工厂方法有具体名字
由于语言的特性,Java 的构造函数都是跟类名一样的。这导致的一个问题是构造函数的名称不够灵活,经常不能准确地描述返回值,在有多个重载的构造函数时尤甚,如果参数类型、数目又比较相似的话,那更是很容易出错。
|
|
Date 类有很多重载函数,对于开发者来说,假如不是特别熟悉的话,恐怕是需要犹豫一下,才能找到合适的构造函数的。而对于其他的代码阅读者来说,估计更是需要查看文档,才能明白每个参数的含义了。
Date 类在目前的 Java 版本中,只保留了一个无参和一个有参的构造函数,其他的都已经标记为 @Deprecated 了。
而如果使用静态工厂方法,就可以给方法起更多有意义的名字,比如前面的 valueOf、newInstance、getInstance
等,对于代码的编写和阅读都能够更清晰。
静态工厂方法不需要在每次调用时创建新对象。
这允许不可变类使用预先构造的实例,或在构造实例时缓存实例,并重复分配它们以避免创建不必要的重复对象。Boolean.valueOf(boolean)
方法说明了这种技术:它从不创建对象。这种技术类似于享元模式。如果经常请求相同的对象,特别是在创建对象的代价很高时,它可以极大地提高性能。
有时候外部调用者只需要拿到一个实例,而不关心是否是新的实例;又或者我们想对外提供一个单例时,如果使用工厂方法,就可以很容易的在内部控制,防止创建不必要的对象,减少开销。在实际的场景中,单例的写法也大都是用静态工厂方法来实现的。
可以通过静态工厂方法获取返回类型的任何子类的对象。
这条不用多说,设计模式中的基本的原则之一:『里氏替换』原则,就是说子类应该能替换父类。显然,构造方法只能返回确切的自身类型,而静态工厂方法则能够更加灵活,可以根据需要方便地返回任何它的子类型的实例。
|
|
比如上面这段代码,Person 类的静态工厂方法可以返回 Person 的实例,也可以根据需要返回它的子类Player 或者 Cooker。(当然,这只是为了演示,在实际的项目中,一个类是不应该依赖于它的子类的。但如果这里的 getInstance ()
方法位于其他的类中,就更具有的实际操作意义了)
可以有多个参数相同但名称不同的工厂方法
构造函数虽然也可以有多个,但是由于函数名已经被固定,所以就要求参数必须有差异时(类型、数量或者顺序)才能够重载了。举例来说:
|
|
Child 类有 age 和 weight 两个属性,如代码所示,它已经有了两个构造函数:Child(int age, int weight) 和 Child(int age)
,这时候如果我们想再添加一个指定 wegiht 但不关心 age 的构造函数,一般是这样:
|
|
但要把这个构造函数添加到 Child 类中,我们都知道是行不通的,因为 java 的函数签名是忽略参数名称的,所以 Child(int age) 跟 Child(int weight) 会冲突。这时候,静态工厂方法就可以登场了。
|
|
其中的 newChildWithWeight 和 newChildWithAge
,就是两个参数类型相同的的方法,但是作用不同,如此,就能够满足上面所说的类似Child(int age) 和 Child(int weight)
同时存在的需求。(另外,这两个函数名字也是自描述的,相对于一成不变的构造函数更能表达自身的含义,这也是上面所说的第一条优势 ——『它们有名字』)
可以减少对外暴露的属性
软件开发中有一条很重要的经验:对外暴露的属性越多,调用者就越容易出错。所以对于类的提供者,一般来说,应该努力减少对外暴露属性,从而降低调用者出错的机会。考虑一下有如下一个 Player 类:
|
|
Player 对外提供了一个构造方法,让使用者传入一个 type 来表示类型。那么这个类期望的调用方式就是这样的:
|
|
但是,我们知道,提供者是无法控制调用方的行为的,实际中调用方式可能是这样的:
|
|
提供者期望的构造函数传入的值是事先定义好的几个常量之一,但如果不是,就很容易导致程序错误。要避免这种错误,使用枚举来代替常量值是常见的方法之一,当然如果不想用枚举的话,使用我们今天所说的主角静态工厂方法也是一个很好的办法。如果把以上需求用静态工厂方法来实现,代码大致是这样的:
|
|
注意其中的构造方法被声明为了 private,这样可以防止它被外部调用,于是调用方在使用 Player 实例的时候,基本上就必须通过 newRunner、newSwimmer、newRacer
这几个静态工厂方法来创建,调用方无须知道也无须指定 type 值,这样就能把 type 的赋值的范围控制住,防止前面所说的异常值的情况。
多了一层控制,方便统一修改
我们在开发中一定遇到过很多次这样的场景:在写一个界面时,服务端的数据还没准备好,这时候我们经常就需要自己在客户端编写一个测试的数据,来进行界面的测试,像这样:
|
|
要写一连串的测试代码,如果需要测试的界面有多个,那么这一连串的代码可能还会被复制多次到项目的多个位置。这种写法的缺点呢,首先是代码臃肿、混乱;其次是万一上线的时候漏掉了某一处,忘记修改,那就可以说是灾难了。但是如果你像我一样,习惯了用静态工厂方法代替构造器的话,则会很自然地这么写,先在User 中定义一个 newTestInstance 方法:
|
|
然后调用的地方就可以这样写了:
|
|
是不是瞬间就觉得优雅了很多?!而且不只是代码简洁优雅,由于所有测试实例的创建都是在这一个地方,所以在需要正式数据的时候,也只需把这个方法随意删除或者修改一下,所有调用者都会编译不通过,彻底杜绝了由于疏忽导致线上还有测试代码的情况。
静态工厂返回对象的类,可以随每次调用而变化(因静态工厂方法的参数值不同)
只要是已声明的返回类型的子类,都是可以返回的。返回对象的类也可能因版本而异。
例如,EnumSet类是没有公共构造函数的,只有静态工厂方法。在 OpenJDK 实现中,因底层 enum 类型的大小不同,这个静态工厂方法会返回两个子类中的一个实例。
|
|
客户端看不到这两个实现类的存在。如果 RegularEnumSet 不再为小型 enum 类型提供性能优势,它可能会在未来的版本中被消除,而不会产生不良影响。类似地,如果事实证明 EnumSet 有益于性能,未来的版本可以添加第三或第四个 EnumSet 实现。客户端既不知道也不关心从工厂返回的对象的类;它们只关心它是EnumSet 的某个子类。
当编写包含静态工厂方法的类时,返回对象的类不需要存在。
这种灵活的静态工厂方法构成了服务提供者框架的基础,比如 Java 数据库连接 API(JDBC)。
PS:这部分主要说了服务提供者框架,原文说的特别晦涩难懂,直接参考如下。
参考这里,说明了服务提供者框架
静态工厂的缺点一
如果类中没有公有构造器或受保护的构造器,就不能被子类继承。
这可能是一种因祸得福的做法,因为它鼓励程序员使用组合而不是继承。
静态工厂的缺点二
程序员很难找到静态工厂方法。 它们在 API 文档中不像构造函数那样引人注目,因此很难弄清楚如何实例化一个只提供静态工厂方法而没有构造函数的类。Javadoc 工具总有一天会关注到静态工厂方法。与此同时,你可以通过在类或接口文档中对静态工厂方法多加留意,以及遵守通用命名约定的方式来减少这个困扰。
下面是一些静态工厂方法的常用名称。
-
from,一种型转换方法,该方法接受单个参数并返回该类型的相应实例,例如:
1
Date d = Date.from(instant);
-
of,一个聚合方法,它接受多个参数并返回一个包含这些参数的实例,例如:
|
|
- valueOf,一种替代 from 和 of 但更冗长的方法,例如:
|
|
- instance或getInstance,返回一个实例,该实例由其参数(如果有的话)描述,但不具有相同的值,例如:
|
|
- create 或 newInstance,与 instance 或 getInstance 类似,只是该方法保证每个调用都返回一个新实例,例如:
|
|
-
getType,类似于 getInstance,但如果工厂方法位于不同的类中,则使用此方法。其类型是工厂方法返回
的对象类型,例如:FileStore fs = Files.getFileStore(path);
-
newType,与 newInstance 类似,但是如果工厂方法在不同的类中使用。类型是工厂方法返回的对象类型,例如:
|
|
- type,一个用来替代 getType 和 newType 的比较简单的方式,例如:
|
|
总之,『考虑使用静态工厂方法代替构造器』,除了上面说的优势,更多的是在工程学上的意义:
能够增大类的提供者对自己所提供的类的控制力。
Item-2:当构造函数有多个参数时,考虑改用构建器
(Consider a builder when faced with many constructor parameters)
静态工厂和构造函数都有一个局限:它们不能对大量可选参数做很好的扩展。以一个类为例,它表示包装食品上的营养标签。这些标签上有一些字段是必需的,如:净含量、毛重和每单位份量的卡路里,另有超过 20个可选的字段,如:总脂肪、饱和脂肪、反式脂肪、胆固醇、钠等等。
大多数产品只需要这些可选字段中的几个,且具有非零值。怎么给这样的类编写构造函数或静态工厂呢?传统的方式是使用可伸缩构造函数,在这种模式中,只向构造函数提供必需的参数。即,向第一个构造函数提供单个可选参数,向第二个构造函数提供两个可选参数,以此类推,最后一个构造函数是具有所有可选参数的。这是它在实际应用中的样子。为了简洁起见,只展示具备四个可选字段的情况:
|
|
当你想要创建一个实例时,可以使用包含所需的参数的最短参数列表的构造函数:
|
|
这个构造函数包含一些你本不想设置的参数,但是你必须为它们传递一个值。在本例中,我们为 fat 传递了一个0。只有六个参数时,看起来可能没啥,但随着参数的增加,它很快就会失控。
简单地说,可伸缩构造函数模式是可行的,但是当有很多参数时,客户端代码会很难编写,而且读起来更困难。读者想知道所有这些值是什么意思,必须仔细清点参数。相同类型参数的长序列会导致细微的错误。如果客户端不小心颠倒了其中两个参数的顺序,编译器不会报错,但是程序会在运行时出错。
当你在构造函数中遇到许多可选参数时,另一种选择是 JavaBean 模式,在这种模式中,先调用一个无参数的构造函数来创建对象,然后调用 setter 方法来设置每个所需的参数和每个感兴趣的可选参数:
|
|
这个模式弥补了可伸缩构造函数模式的不足。创建实例很容易,虽然有点冗长,但很容易阅读生成的代码:
|
|
但是,JavaBean 模式本身有严重的缺点。因为构建是在多个调用之间进行的,所以 JavaBean 可能在构建的过程中处于不一致的状态。该类不能仅通过检查构造函数参数的有效性来强制一致性。在不一致的状态下尝试使用对象可能会导致错误的发生,而包含这些错误的代码很难调试。另一个缺点是,JavaBean 模式很难让类不可变,并且需要程序员额外的努力来确保线程安全。
通过在对象构建完成时手动「冻结」对象,并在冻结之前不允许使用对象,可以减少这些缺陷,但是这种变通方式很笨拙,在实践中很少使用。此外,它可能在运行时导致错误,因为编译器不能确保程序员在使用对象之前调用它的 freeze 方法。
幸运的是,还有第三种选择,它结合了可伸缩构造函数模式的安全性和 JavaBean 模式的可读性。它是建造者模式的一种形式。客户端不直接生成所需的对象,而是使用所有必需的参数调用构造函数(或静态工厂),并获得一个 builder 对象。然后,客户端在构建器对象上调用像 setter 这样的方法来设置每个需要的可选参数。
最后,客户端调用一个无参数的构建方法来生成对象,这通常是不可变的。构建器通常是它构建的类的静态成员类。示例如下:
|
|
NutritionFacts 类是不可变的,所有参数默认值都在一个位置。builder的setter方法返回builder本身,这样就可以链式调用,从而得到一个流式的 API。下面是客户端代码的样子:
|
|
该客户端代码易于编写,更重要的是易于阅读。
PS:若实体类数量较多时,内嵌静态类的方式还是比较冗长。或可将「构建器」独立出来,广泛适应多个实体类。以下案例仅供参考:
|
|
如此,可移除整个 Builder 类,NutritionFacts 类保留无参无方法体的私有构造;类成员必须实现 setter和 getter:
|
|
使用案例改为:
|
|
为了简洁起见,示例省略了有效性检查。想要检测出无效的参数,可以检查构建器的构造函数和方法中的参数有效性。build方法调用的构造器中的多个参数包含不可变量,为了确保这些不可变量免受攻击,builder复制完参数后,要检查对象的字段,如果检查失败,抛出一个 IllegalArgumentException,它的详细消息指示哪些参数无效。
建造者模式非常适合于类层次结构。使用构建器的并行层次结构,每个构建器都嵌套在相应的类中。抽象类有抽象类构建器;具体类有具体类构建器。例如,考虑一个在层次结构处于最低端的抽象类,它代表各种比萨饼:
|
|
请注意,Pizza.Builder 是具有递归类型参数的泛型类型。这与抽象 self 方法一起,允许方法链接在子类中正常工作,而不需要强制转换。对于 Java 缺少自类型这一事实,这种变通方法称为模拟自类型习惯用法。
这里有两个具体的比萨子类,一个是标准的纽约风格的比萨,另一个是 calzone。前者有一个所需的大小参数,而后者让你指定酱料应该是内部还是外部:
|
|
注意,每个子类的构建器中的构建方法声明为返回正确的子类:构建的方法 NyPizza.Builder 返回 NyPizza,而在 Calzone.Builder 则返回 Calzone。这种技术称为协变返回类型,其中一个子类方法声明为返回超类中声明的返回类型的子类型。它允许客户使用这些构建器,而不需要强制转换。这些「层次构建器」的客户端代码与简单的 NutritionFacts 构建器的代码基本相同。为简洁起见,下面显示的示例客户端代码假定枚举常量上的静态导入:
|
|
建造者模式非常灵活。一个构建器可以多次用于构建多个对象。构建器的参数可以在构建方法的调用之间进行调整,以改变创建的对象。构建器可以在创建对象时自动填充某些字段,例如在每次创建对象时增加的序列号。
建造者模式也有缺点。为了创建一个对象,你必须首先创建它的构建器。虽然在实际应用中创建这个构建器的成本可能并不显著,但在以性能为关键的场景下,这可能会是一个问题。而且,建造者模式比可伸缩构造函数模式更冗长,因此只有在有足够多的参数时才值得使用,比如有 4 个或更多参数时,才应该使用它。但是请记住,你可能希望在将来添加更多的参数。但是,如果你以构造函数或静态工厂开始,直至类扩展到参数数量无法控制的程度时,也会切换到构建器,但是过时的构造函数或静态工厂将很难处理。因此,最好一开始就从构建器开始。
总之,在设计构造函数或静态工厂的类时,建造者模式是一个很好的选择,特别是当许多参数是可选的或具有相同类型时。与可伸缩构造函数相比,使用构建器客户端代码更容易读写,而且构建器比 JavaBean 更安全。
Item-3:使用(私有构造函数)或(枚举)创建单例
(Enforce the singleton property with a private constructor or an enum type)
单例是一个只实例化一次的类。单例通常表示无状态对象,比如函数或系统组件,它们在本质上是唯一的。将一个类设计为单例会使它的客户端测试时变得困难, 除非它实现了作为其类型的接口,否则无法用模拟实现来代替单例。
实现单例有两种常见的方法。两者都基于:构造函数私有、和导出公共静态成员,以提供对唯一实例的访问。在第一种方法中,成员是一个 final 字段:
|
|
私有构造函数只调用一次,用于初始化 public static final 修饰的 Elvis 类型字段 INSTANCE。由于没有 public或 protected 的构造函数,保证了Elvis的全局唯一性:一旦初始化了 Elvis 类,就只会存在一个 Elvis 实例。
但有一点需要注意:拥有特殊权限的客户端可以借助AccessibleObject.setAccessible
方法利用反射调用私有构造函数。如果需要防范这种攻击,请修改构造函数,使其在请求创建第二个实例时抛出异常。
使用AccessibleObject.setAccessible
方法调用私有构造函数示例:
|
|
在实现单例的第二种方法中,公共成员是一种静态工厂方法:
|
|
所有对 getInstance() 方法的调用都返回相同的对象引用,并且不会创建其他 Elvis 实例。
公共字段方法的主要优点是 API 明确了类是单例的:public static 修饰的字段是 final 的,因此它总是包含相同的对象引用。第二个优点是更简单。
静态工厂方法的优点:
-
可以在不更改 API 的情况下决定类是否是单例。工厂方法返回唯一的实例,但是可以对其进行修改,为调用它的每个线程返回一个单独的实例。
-
如果应用程序需要的话,可以编写泛型的单例工厂。
-
方法引用能够作为一个提供者,例如
Elvis::getInstance 是 Supplier<Elvis>的提供者。
方法引用作为提供者的例子:
|
|
除非能够满足以上任意一种优势,否则还是优先考虑public字段的方法。
要想将上述单例变成可序列化的,仅仅在声明中加上 implements Serializable 是不够的。为了维护单例,必须声明所有示例域都是瞬时( transient )的,并提供一个readResolve方法。否则,每次反序列化一个序列化的实例时,都会创建一个新的实例,比如,在我们的例子中,会导致“假冒的 Elvis“ 。为了防止发生这种情况,将这个 readResolve 方法添加到 Elvis 类中:
|
|
实现单例的第三种方法是声明一个包含单个元素的枚举类型:
|
|
这种方法类似于 public 字段方法,但是它更简洁,默认提供了序列化机制,提供了对多个实例化的严格保证,即使面对复杂的序列化或反射攻击也是如此。
Item-4:用私有构造函数
让类不可实例化
(Enforce noninstantiability with a private constructor)
有时候我们需要只包含静态方法和静态字段的类,这样的类实例化毫无意义,然而,在没有显式构造函数的情况下,编译器提供了一个公共的、无参数的默认构造函数。对于用户来说,这个构造函数与其他构造函数没有区别。
将类做成抽象类来强制该类不可被实例化是不对的,因为可以对类进行子类化,并实例化子类。此外,它误导用户认为类是为继承而设计的。
最简单的方法是,将构造函数私有(private):
|
|
因为显式构造函数是私有的,所以在类之外是不可访问的。
不是必须抛出AssertionError()
异常,但是抛异常可以防止构造函数被意外调用。
它保证类在任何情况下都不会被实例化。这个习惯用法有点违反常规,因为构造函数是明确提供的,但不能调用它。因此,如上述代码所示,包含注释是明智的做法。
这种做法也有副作用,它使得一个类不能被继承。因为所有子类构造函数都必须调用超类构造函数,无论是显式的还是隐式的,但这种情况下子类都没有可访问的超类构造函数可调用。
Item-5:优先考虑依赖注人来引用资源
(Prefer dependency injection to hardwiring resources)
许多类依赖于一个或多个底层资源。例如,拼写检查程序依赖于字典。常见做法是,将这种类实现为静态工具类:
|
|
类似地,也常看到它们的单例实现
|
|
这两种方法都不好,这里只使用一个字典,实际上每种语言都有自己的字典。
静态工具类和单例不适用于需要引用底层资源的类。
我们需要SpellChecker 支持多个dictionary实例。所以可以在创建新实例时将资源传递给构造函数。
这就是依赖注入的一种形式:字典是拼写检查器的依赖项,在创建它时被注入到拼写检查器中。
|
|
依赖注入模式非常简单,许多程序员在不知道其名称的情况下使用了多年。虽然拼写检查器示例只有一个资源(字典),但是依赖注入可以处理任意数量的资源和任意依赖路径。它保持了不可变性,因此多个客户端可以共享依赖对象(假设客户端需要相同的底层资源)。依赖注入同样适用于构造函数、静态工厂和构建器。
这种模式的另一种表现形式,是将资源工厂传递给构造函数。工厂是一个对象,可以反复调用它来创建类的实例。这就是工厂方法模式。Java 8 中引入的Supplier<T>
非常适合表示工厂。
带有Supplier<T>
的方法,应该限制输入工厂的类型参数使用有限制的通配符类型,以便客户端能够传入一个工厂,来创建指定类型的任意子类型。
例如,这里有一个生产瓷砖方法,每块瓷砖都使用客户提供的工厂来制作瓷砖:
|
|
尽管依赖注入极大地提高了灵活性和可测试性,但它可能会使大型项目变得混乱,这些项目通常包含数千个依赖项。使用依赖注入框架(如 Dagger、Guice 或 Spring),可以消除这种混乱。
总之,不要使用单例或静态工具类来实现依赖于一个或多个底层资源的类,这些资源的行为会影响类的行为,也不要让类直接创建这些资源。相反,将创建它们的资源或工厂传递给构造函数(或静态工厂或构建器)。这种操作称为依赖注入,它将大大增强类的灵活性、可复用性和可测试性。
Item-6:避免创建不必要的对象
(Avoid creating unnecessary objects)
尽量重用一个对象,不要每次需要的时候就创建一个相同功能的对象。复用对象的效率更高,如果对象是不可变的,那么它总是可以被复用的。看下面的反面例子:
|
|
该语句每次执行时都会创建一个新的 String 实例,而这些对象创建都不是必需的。String 构造函数的参数(“wjy”) 本身就是一个 String 实例,在功能上与构造函数创建的所有对象相同。如果这种用法发生在循环或频繁调用的方法中,会创建大量不必要的String 实例。改进后的版本如下:
|
|
这是单个 String 实例,而不是每次执行时都创建一个新的实例。只要字符串字面量相同,同一虚拟机中运行的其他代码都可以复用该对象。
我们应该用静态工厂方法代替构造函数,以避免创建不必要的对象。
静态工厂方法Boolean.valueOf(String)
,比构造函数Boolean(String)
好很多。
每次调用构造函数都会创建一个新的对象,而静态工厂方法只会创建一个对象。
除了复用不可变对象之外,如果知道可变对象不会被修改,也可以复用它们。有些对象的创建的代价相比而言要昂贵得多。如果你需要重复地使用这样一个「昂贵的对象」,那么最好将其缓存以供复用。假设你要编写一个方法来确定字符串是否为有效的罗马数字。下面是使用正则表达式最简单的方法:
|
|
这个方法依赖String.matches
方法,虽然String.matches
方法是检查字符串和正则表达式是否匹配的最简单方法,但是不适合在注重性能的情景重复使用。因为String.matches
方法在内部为正则表达式创建了一个 Pattern 实例,并且只使用一次,之后就进行垃圾收集了。创建一个 Pattern 实例是很昂贵的,因为它需要将正则表达式编译成有限状态机。
为了提升性能,应该显示的将正则表达式编译成一个Pattern实例(它是不可变的),让它成为类初始化的一部分,并将它缓存起来,并在每次调用 isRomanNumeral
方法时复用同一个实例:
|
|
如果频繁调用 isRomanNumeral,改进版本将提供显著的性能提升。在我的机器上,初始版本输入 8 字符的字符串花费 1.1μs(微秒),而改进的版本需要 0.17μs,快了6.5 倍。不仅性能得到了改善,代码也更清晰。
将不可见的Pattern实例做成final静态域,合理的命名,这样比正则表达式可读性更好。
如果加载包含改进版 isRomanNumeral 方法的类时,该方法从未被调用过,那么初始化字段 ROMAN 是不必要的。因此,可以用延迟初始化字段的方式在第一次调用 isRomanNumeral 方法时才初始化字段,而不是在类加载时初始化,但不建议这样做。通常情况下,延迟初始化会使实现复杂化,而没有明显的性能改善。
注:类加载通常指的是类的生命周期中加载、连接、初始化三个阶段。当方法没有在类加载过程中被使用时,可以不初始化与之相关的字段。
如果一个对象是不可变的,那么它可以被安全的重用。但有时并不是这样,适配器就是这样,有时候也叫视图。
适配器指的是:它把功能委托给一个后备对象,并为后备对象提供一个可替代的接口。由于适配器除了后备对象之外,没有其他状态信息,所以针对某个给定对象的特定适配器而言,它不需要创建多个适配器实例。
例如,Map 接口的 keySet 方法返回 Map 对象的 Set 视图,其中包含 Map 中的所有键。
你可能以为,对 keySet 的每次调用都必须创建一个新的 Set 实例,但是对给定 Map 对象上的 keySet 的每次调用都可能返回相同的 Set 实例。虽然返回的 Set 实例通常是可变的,但所有返回的对象在功能上都是相同的:当返回的对象之一发生更改时,所有其他对象也会发生更改,因为它们都由相同的 Map 实例支持。
虽然创建 keySet 视图对象的多个实例基本上是无害的,但这是不必要的,也没有好处。
自动装箱允许程序员混合基本类型和包装类型,根据需要自动装箱和拆箱,但是自动装箱会创建不必要的对象。
自动装箱模糊了基本类型和包装类型之间的区别, 两者有细微的语义差别和不明显的性能差别。
下面的方法计算所有正整数的和。为了做到这一点,程序必须使用 long,因为 int 值不够大,不足以容纳所有正整数值的和:
|
|
虽然这段程序的结果是正确的,但是因为变量sum是Long而不是long,程序创建了2的31次方个多余的Long实例,每次向sum中添加long类型的i,都会创建一个Long对象。将 sum 的声明从 Long 更改为 long,机器上的运行时间将从 6.3 秒减少到 0.59 秒。结论明显:基本类型优于包装类,还应提防意外的自动装箱。
在现代 JVM 实现上,创建和回收这些小对象的构造函数成本是很低廉的。创建额外的对象来增强程序的可读性是件好事。
建立数据库链接的成本是非常高的,因此复用这些对象是有意义的。然而,一般来说,维护自己的对象池会使代码混乱,增加内存占用,并损害性能。现代 JVM 实现具有高度优化的垃圾收集器,在轻量级对象上很容易胜过这样的对象池。
Item-7:排除过时的对象引用
(Eliminate obsolete object references)
C和C++是需要手动管理内存的,而Java是有垃圾回收功能的,即当你用完对象之后,它们会被垃圾收集器自动回收。但是这样,我们也要考虑内存管理的事情。考虑以下简单的堆栈实现:
|
|
这个程序没有明显的错误。你可以对它进行详尽的测试,它会以优异的成绩通过所有的测试,但是有一个潜在的问题。简单地说,该程序有一个「内存泄漏」问题,由于垃圾收集器活动的增加或内存占用的增加,它可以悄无声息地表现为性能的降低。在极端情况下,这种内存泄漏可能导致磁盘分页,甚至出现OutOfMemoryError 程序故障,但这种故障相对少见。
如果一个栈先增长,然后收缩,那么从栈中弹出来的对象将不会被当作垃圾回收,即使使用栈的程序不再引用这些对象。这是因为栈内部维护着对这些对象的过期引用(指永远不会解除的引用)。本例中,凡是在elements数组的活动部分之外的引用都是过期的,活动部分指elements中下标小于size的那些元素。
在支持垃圾回收的语言中,内存泄露是很隐蔽的,称这类内存泄露为无意识的对象保持
更为恰当。如果一个对象引用被无意识的保留起来了,那么垃圾回收机制不仅不会处理这个对象,而且也不会处理被这个对象所引用的所有其他对象。即使只有少量的几个对象引用被无意识的保留下来,也会有许多的对象被排除在垃圾回收机制之外,从而对性能造成潜在的重大影响。
解决这类问题的方法很简单:一旦对象的引用过期,就清空这些引用即可。对于上述例子的Stack类而言,只要一个单元被弹出栈,指向它的引用就过期了,pop方法修改后如下:
|
|
用 null 处理过时引用的另一个好处是,如果它们随后被错误地关联引用,程序将立即失败,抛出
NullPointerException异常,而不是悄悄的错误运行下去,尽早的检测出程序中的错误总是有益的。
对于每一个对象引用,一旦程序不再用到它,就把它清空。其实完全没必要这样,因为这样会把代码弄的很乱。清空对象引用应该是一种例外,而不是一种规范行为。消除过期引用最好的方法是,让包含该引用的变量结束其生命周期。如果你在最狭窄的范围内定义每个变量,那么这种情况自然而然会发生。
那么,什么时候应该取消引用呢?Stack 类的哪些方面容易导致内存泄漏?问题在于,stack类自己管理内存。存储池包含了elements数组(对象引用单元,而不是对象本身)的元素。数组活动区域(同前面的定义)中的元素是已分配的(allocated),而数组其余元素则是空闲的。但垃圾回收器并不知道这一点,对垃圾回收器而言,elements数组中的所有对象引用都同样有效。只有程序员知道数组的非活动部分不重要。只要数组元素成为非活动部分的一部分,程序员就可以通过手动清空数组元素,有效地将这个情况传递给垃圾收集器。
一般来说,一个类管理它自己的内存时,程序员应该警惕内存泄漏。当释放一个元素时,该元素中包含的任何对象引用都应该被置为 null。
另一个常见的内存泄漏来源是缓存。一旦将对象引用放入缓存中,就很容易忘记它。并且在它变得无关紧要之后很久仍将它留在缓存中。有几个解决办法。如果你要实现这样的缓存:只要缓存之外存在某个项的键的引用,该项就有意义,那么就可以用WeakHashMap代表缓存,当条目过时后,条目将被自动删除。
记住,WeakHashMap 只有在缓存条目的预期生存期由键的外部引用(而不是值)决定时才有用。
缓存的生命周期不是很容易确定,随着时间的推移,一些缓存变的无用。所以应该定时清空无用的缓存。这些清除工作可以由一个后台线程(可能是ScheduledThreadPoolExecutor)来完成,或者向缓存添加新条目时顺便进行清理。LinkedHashMap 类通过其 removeEldestEntry 方法可以很容易地实现后一种方案。对于更复杂的缓存,你可能需要直接使用 java.lang.ref。
内存泄露的第三个来源是监听器或其他回调。如果你实现了一个api,客户端在这个api中注册回调,却没有显式的取消注册,除非你采取某些动作,否则它们就会不断的堆积起来。确保回调被及时地垃圾收集的一种方法是仅存储对它们的弱引用,例如,将它们作为键存储在 WeakHashMap 中。
由于内存泄露不会表现为明显的故障,它们可能会在系统中存在多年。往往只有通过仔细检查代码,或者借助Heap 刨析工具(Heap Profiler),才能发现内存泄露问题。因此,如果能够在内存泄露发生之前就知道如何预测此类问题,并阻止它们发生,那最好不过了。
Item-8:避免使用终结器和清除器
(Avoid finalizers and cleaners)
终结方法(finalizer)会导致不稳定,性能降低,以及可移植性问题,应避免使用终结方法。在java9中用清除方法(cleaner)代替了终结方法,但是清除方法也会导致运行缓慢,也不要用。
c++中,析构函数是回收与对象相关联的资源的常用方法,和构造函数相对应。在 Java 中,当对象变得不可访问时,垃圾收集器将回收与之关联的存储,无需程序员进行任何特殊工作。c++ 析构函数还用于回收其他非内存资源。在 Java 中,使用带有资源的 try-with-resources 或 try-finally 块用于此目的。
终结器和清除器的一个缺点是不能保证它们会被立即执行。当对象变得不可访问,终结器或清除器对它进行操作的时间是不确定的。这意味着永远不应该在终结器或清除器中执行任何对时间要求很严格的操作。例如,依赖终结器或清除器关闭文件就是一个严重错误,因为打开的文件描述符是有限的资源。如果由于系统在运行终结器或清除器的延迟导致许多文件处于打开状态,程序可能会运行失败,因为它不能再打开其他文件。
终结器和清除器执行的快速性主要是垃圾收集算法的功能,在不同的实现中存在很大差异。依赖于终结器的及时性或更清晰的执行的程序的行为可能也会发生变化。这样的程序完全有可能在测试它的 JVM 上完美地运行,然后在最重要的客户喜欢的 JVM 上悲惨地失败。
为类提供终结器可能会延迟其实例的回收。一个长期运行的程序出现了OOM,程序死掉的时候,终结方法队列中有数千个图形对象正在等待被终结和回收,但是,终结方法的线程优先级,比该应用的其他线程优先级要低的多,所以图形对象的终结速度赶不上进入队列的速度。
java语言规范不仅不保证终结方法或清除方法会及时执行,而且就不保证它们会被执行。当一个程序终止的时候,某些已经无法访问的对象上的终结方法却根本没有被执行,这是完全有可能的。结论是:永远不应该依赖终结方法或者清除方法来更新重要的持久状态。例如,依赖终结方法或者清除方法来释放共享资源(比如数据库)上的永久锁,这很容易让整个分布式系统垮掉。
不要被System.gc()
和System.runFinalization();
这两个方法所诱惑。它们确实增加了终结方法或者清除方法被执行的机会,但是它们并不保证终结方法或者清除方法一定会被执行。唯一声称保证它们会被执行的两个方法是System.runFinalizersOnExit();
及其臭名昭著的孪生兄弟Runtime.runFinalizersOnExit();
。这两个方法都有致命的缺陷,并且已经被废弃很久了。
终结器的另一个问题是,在终结期间抛出的未捕获异常被忽略,该对象的终结过程也会终止。未捕获的异常会使对象处于损坏状态,如果另一个线程试图使用这样一个损坏的对象,可能会导致任意的不确定性行为。
正常情况下,未捕获的异常将终止线程并打印堆栈跟踪(Stack Trace),但如果在终结器中出现,则不会打印警告。清除器没有这个问题,因为使用清除器的库可以控制它的线程。
使用终结器和清除器会严重影响性能。在我的机器上,创建一个简单的 AutoCloseable 对象,使用
try-with-resources 关闭它以及让垃圾收集器回收它的时间大约是 12ns。相反,使用终结器将时间增加到550ns。换句话说,使用终结器创建和销毁对象大约要慢 50 倍。这主要是因为终结器抑制了有效的垃圾收集。
终结器有一个严重的安全问题:它们会让你的类受到终结器攻击。终结器攻击背后的思想很简单:如果从构造函数或它的序列化等价物(readObject 和 readResolve 方法(Item-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域,表示房间的杂乱度。更现实地说,它可以是final的long,包含一个指向本地对等体的指针。State实现了Runnable接口,它的run方法最多被Cleanable调用一次,后者是我们在Room构造器中用清除器注册State实例时获得的。以下两种情况之一会触发run方法的调用:通常是通过调用Room的close方法触发的,后者又调用了cleanable的清除方法。如果到了Room实例应该被垃圾回收时,客户端还没有调用close方法,清除方法就会(希望如此)调用state的run方法。
关键是State实例没有引用它的Room实例。如果它引用了,会造成循环,阻止Room实例被垃圾回收(以及防止被自动清除)。因此State必须是一个静态的嵌套类,因为非静态的嵌套类包含了对其外围实例的引用(详见第24条)。同样地,也不建议使用lambda,因为它们很容易捕捉到对外围对象的引用。
就像我们之前说的,Room 类的清除器只是用作安全网。如果客户端将所有 Room 实例包围在带有资源的 try块中,则永远不需要自动清理。
|
|
正如所期待的一样,运行Adult程序会打印出Goodbye,接着是Cleaning room。但这个从不打扫房间的不守规矩的程序怎么办?
|
|
你可能期望打印出Peace out,然后是Cleaning room,但是在我的机器上,它没有打印出Cleaning room,就退出程序了。这就是我们之前提到过的不可预见性。Cleaner规范指出:“清除方法在System.exit
期间的行为是与实现相关的。不确保清除动作是否会被调用。”虽然规范没有指明,其实对于正常的程序退出也是如此。在我的机器上,只要在Teenager的main方法上添加代码行System.gc()
,就足以让它在退出之前打印出cleaning room,但是不能保证在你的机器上也能看到相同的行为。
总而言之,除非是作为安全网,或者是为了终止非关键的本地资源,否则请不要使用清除方法,对于在Java9之前的发行版本,则尽量不要使用终结方法。若使用了终结方法或者清除方法,则要注意它的不确定性和性能后果。
Item-9:使用 try-with-resources 优于 try-finally
Java类库中包括许多必须通过调用close方法来手工关闭的资源。
例如Inputstream、Outputstream和java.sql.Connection
。客户端经常会忽略资源的关闭,这会造成严重的性能后果。虽然这其中的许多资源都是用终结方法作为安全网,但是效果并不理想。
根据经验,try-finally 语句是确保正确关闭资源的最佳方法,即使在出现异常或返回时也是如此:
|
|
这可能看起来不坏,但添加第二个资源时,情况会变得更糟:
|
|
使用 try-finally 语句关闭资源的正确代码(如前两个代码示例所示)也有一个细微的缺陷。try 块和 finally块中的代码都能够抛出异常。例如,在 firstLineOfFile 方法中,由于底层物理设备发生故障,对 readLine 的调用可能会抛出异常,而关闭的调用也可能出于同样的原因而失败。在这种情况下,第二个异常将完全覆盖第一个异常。异常堆栈跟踪中没有第一个异常的记录,这可能会使实际系统中的调试变得非常复杂(而这可能是希望出现的第一个异常,以便诊断问题)。虽然可以通过编写代码来抑制第二个异常而支持第一个异常,但实际上没有人这样做,因为它太过冗长。
当 Java 7 引入try-with-resources
语句时,所有这些问题都一次性解决了。要使用这个结构,资源必须实现AutoCloseable 接口,它由一个单独的 void-return close 方法组成。Java 库和第三方库中的许多类和接口现在都实现或扩展了 AutoCloseable。如果你编写的类存在必须关闭的资源,那么也应该实现 AutoCloseable。
下面是使用 try-with-resources 的第一个示例:
|
|
示例二:
|
|
使用 try-with-resources
不仅使代码变得更简洁易懂,也更容易进行诊断。以 firstLineOfFile()
方法为例,如果调用 readline和(不可见的) close方法都抛出异常,后一个异常就会被禁止,以保留第一个异常。事实上,为了保留你想要看到的那个异常,即便多个异常都可以被禁止。这些被禁止的异常并不是简单地被抛弃了,而是会被打印在堆栈轨迹中,并注明它们是被禁止的异常。通过编程调用 get Suppressed
方法还可以访问到它们,getsuppressed
方法也已经添加在Java7的 Throwable中了。
在 try-with- resources
语句中还可以使用 catch子句,就像在平时的try-finally
语句中一样。这样既可以处理异常,又不需要再套用一层代码。下面举一个稍费了点心思的范例。这个firstLineOfFile()
方法没有抛出异常,但是如果它无法打开文件,或者无法从中读取,就会返回一个默认值。
|
|
结论明显:在处理必须关闭的资源时,优先使用 try-with-resources,而不是try-finally。
chapter_03 对象的通用方法
虽然 Object 是一个具体的类,但它主要是为扩展而设计的。它的所有非 final 方法(equals、hashCode、toString、clone 和 finalize)都有显式的通用约定,因为它们的设计目的是被覆盖。任何一个类在覆盖这些方法的时候,都有责任遵守这些约定。如果做不到这一点,其他依赖于这些约定的类(如 HashMap 和HashSet)就无法结合该类一起正常工作。
Item-10:覆盖 equals 方法时应遵守通用约定
(Obey the general contract when overriding equals)
覆盖 equals 方法很简单,但有些时候不应该覆盖equals():
-
类的每个实例本质上都是唯一的。
对于像 Thread 这样表示活动实体类而不是值类来说也是如此。Object 提供的 equals 实现对于这些类具有完全正确的行为。 -
该类不需要提供「逻辑相等」测试。
例如,java.util.regex.Pattern
可以覆盖 equals 来检查两个 Pattern实例是否表示完全相同的正则表达式,但设计人员认为客户端不需要或不需要这个功能。在这种情况下,从Object 继承的 equals 完全足够。 -
超类已经覆盖了 equals,超类行为适合于这个类。
例如,大多数 Set 的实现从 AbstractSet 继承其对等实现,List 从 AbstractList 继承实现,Map 从 AbstractMap 继承实现。 -
类是私有的或包私有的,并且你确信它的 equals 方法永远不会被调用。
如果你想要规避风险,你可以覆盖equals 方法,以确保它不会意外调用:
|
|
那么什么时候应该覆盖squals方法呢?如果类具有自己特有的"逻辑相等"概念(不同于对象相等),而且超类还没有覆盖squals。这属于值类
的情况,值类只是表示值的类,例如 Integer 或 String。程序员在利用equals方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是想知道它们是否指向同一个对象。覆盖 equals 方法不仅是为了满足程序员的期望,它还使实例能够作为 Map 的键或 Set 元素时,具有可预测的、理想的行为。
有一个表示状态的内部类。没有覆盖 equals 方法时,equals 的结果与 s1==s2 相同,为 false,即两者并不是相同对象的引用。
|
|
覆盖 equals 方法后,以业务逻辑来判断是否相同,具备相同 status 字段即为相同。在使用去重功能时,也以此作为判断依据。
|
|
枚举类不需要覆盖 equals 方法,在枚举类中,每个值至多只存在一个对象。对枚举类而言,逻辑相同和对象相同是一回事,因此对象的equals 方法函数与逻辑 equals 方法相同。
当你覆盖 equals 方法时,你必须遵守它的通用约定。以下是具体内容,来自 Object 规范:
equals 方法实现了等价关系,其属性如下:
- 自反性(reflexive):对于任何非null的引用值x,x.equals(x)必须返回true。
- 对称性(symmetric):对于任何非null的引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。
- 传递性(transitive):对于任何非null的引用值x、y和z,如果x.equals(y)返回true,并且y.equals(z)也返回true,那么x.equals(z)也必须返回true。
- 一致性(consistent):对于任何非null的引用值x和y,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就会一致地返回true,或者一致地返回false。
- 对于任何非null的引用值x,x.equals(null)必须返回false。
下面逐一解释。
自反性
对象必须等于其自身。
对称性
如果a等于b,那么b也要等于a。举例说明
|
|
这个类中的 equals 方法尝试与普通字符串进行互操作。假设有一个不区分大小写的字符串和一个普通字符串:
|
|
正如预期的那样,cis.equals(s) 返回 true。问题是,虽然 CaseInsensitiveString 中的 equals 方法知道普通字符串,但是 String 中的 equals 方法对不区分大小写的字符串不知情。因此,s.equals(cis) 返回 false,这明显违反了对称性。
为了消除这个问题,只需从 equals 方法中删除与 String 互操作的错误尝试。
|
|
传递性
如果a等于b,且b等于c,那么a也要等于c。示例如下
|
|
继承上边的类,添加颜色属性,实现equals
|
|
创建并比较
|
|
这显然不是我们想要的。下面对ColorPoint的equals做改动
|
|
这种方法确实提供了对称性,但牺牲了传递性:
|
|
现在,p1.equals(p2) 和 p2.equals(p3) 返 回 true,而 p1.equals(p3) 返回 false,这明显违反了传递性。前两个比较忽略颜色信息,而第三个比较考虑了颜色信息。
同样,这种方法会导致无限的递归:假设有两个点的子类,比如 ColorPoint 和 SmellPoint,每个都使用这种equals 方法。然后调用 myColorPoint.equals(mySmellPoint) 会抛出 StackOverflowError。
那该怎么解决呢?事实上,这是面向对象语言中关于等价关系的一个基本问题。我们无法在扩展可实例化的类的同时,既增加新的值组件,同时又保留equals约定,除非愿意放弃面向对象的抽象所带来的优势。
里氏替换原则指出:任何父类可以出现的地方,子类一定可以出现。LSP 是继承复用的基石,只有当衍生类可以替换掉父类,软件单位的功能不受到影响时,父类才能真正被复用,而衍生类也能够在父类的基础上增加新的行为。
一致性
如果a和b相等,那么它们要始终保持相等,除非它们被修改了。换句话说,可变对象可以等于不同时间的不同对象,而不可变对象不能。当你在编写一个类的时候,应该仔细考虑它是否应该是不可变的,如果认为它是不可变的,那必须保证equals方法满足这样的限制条件:相等的对象始终是相等,而不等的对象始终是不等。
非空性
很难想象o.equals(null)在什么情况下会返回true。许多类的equals方法都通过一个显示的非空判断防止这种情况:
|
|
这项非空判断是不必要的。为了测试其参数的等同性,equals方法必须先把参数转换成适当的类型,以便可以调用它的访问方法,或者访问它的域。在进行转换之前,equals方法必须使用instanceof操作符,检查其参数的类型是否正确:
|
|
如果漏掉了这一步的类型检查,并且传递给equals方法的参数又是错误的类型,那么equals方法将会抛出ClassCastException异常,这就违反了equals约定。但是,如果instanceof的第一个操作数为null,那么,不管第二个操作数是哪种类型,instanceof操作符都指定应该返回false。因此,如果把null传给equals方法,类型检查就会返回false,所以不需要显式的null检查。
综上所述,构建高质量 equals 方法的秘诀:
-
使用 == 运算符检查参数是否是对该对象的引用。
如果是,返回 true。这只是一种性能优化,但如果比较的代价可能很高,那么这种优化是值得的。 -
使用 instanceof 运算符检查参数是否具有正确的类型。 如果不是,返回 false。
一般来说,所谓正确的类型,是指equals方法所在的那个类。有时候,它是由这个类实现的某个接口。如果类实现了一个接口,该接口对 equals 约定进行了改进,以允许在实现了该接口的类之间进行比较,则使用接口。集合接口,如 Set、List、Map 和 Map.Entry 具有这样的特性。 -
将参数转换为正确的类型。
因为在这个强制类型转换之前有一个instanceof测试,所以转换肯定会成功。 -
对于类中的每个「重要」字段,检查参数的字段是否与该对象的相应字段匹配。
如果所有这些测试都成功,返回 true;否则返回 false。如果第 2 步中的类型是接口,则必须通过接口方法访问参数的字段;如果是类,你可以根据字段的可访问性直接访问它们。
对于既不是float也不是double类型的基本类型域,可以使用 == 操作符进行比较;
对于对象引用域,可以递归地调用equals方法;
对于float域,可以使用静态方法Float.compare(float,float)
;
对于double域,则使用Double.compare(double,double)
。
对float和double域进行特殊的处理是有必要的,因为存在着Float.NaN、-0.0f以及类似的double常量;虽然可以用静态方法Float.equals
和Double.equals
对float和double域进行比较,但是每次比较都要进行自动装箱,这会导致性能下降。
对于数组域,则要把以上这些指导原则应用到每一个元素上。
如果数组域中的每个元素都很重要,就可以使用其中一个Arrays.equals方法。
有些对象引用域包含null可能是合法的,所以,为了避免可能导致NullPointerException异常,则使用静态方法Objects.equals(Object,Object)来检查这类域的等同性。
对于有些类,比如前面提到的 CaseInsensitivestring类,域的比较要比简单的等同性测试复杂得多。
如果是这种情况,可能希望保存该域的一个“范式”,这样 equals方法就可以根据这些范式进行低开销的精确比较,而不是高开销的非精确比较。这种方法对于不可变类是最为合适的;如果对象可能发生变化,就必须使其范式保持最新。
字段的比较顺序可能会影响 equals 方法的性能。
为了获得最佳的性能,应该最先比较最有可能不一致的字段,或者是开销最低的字段,最理想的情况是两个条件同时满足的字段。
不应该比较那些不属于对象逻辑状态的字段,例如用于同步操作的Lock字段。
也不需要比较衍生字段,因为这些域可以由“关键字段”计算获得,但是这样做有可能提高 equals方法的性能。
如果衍生字段代表了整个对象的综合描述,比较这个字段可以节省在比较失败时去比较实际数据所需要的开销。
例如,假设有一个多边形类,并缓存了该多边形的面积。如果两个多边形有着不同的面积,就没有必要去比较它们的边和顶点。
写完 equals 方法后,问自己三个问题:它具备对称性吗?具备传递性吗?具备一致性吗? 不要只问自己,要编写单元测试来检查,除非使用 AutoValue来生成 equals 方法,在这种情况下,你可以安全地省略测试。如果属性不能保持,请找出原因,并相应地修改 equals 方法。当然,equals 方法还必须满足其他两个属性(反身性和非无效性),但这两个通常会自己处理。
在这个简单的 PhoneNumber 类中,根据前面的方法构造了一个 equals 方法:
|
|
总结:
-
当你覆盖 equals 时,也覆盖 hashCode。
-
不要企图让 equals() 方法过于智能。如果只是简单地测试域中的值是否相等,则不难做到遵守 equals约定。
如果想过度地去寻求各种等价关系,则很容易陷人麻烦之中。 -
不要将 equals声明中的 Object 对象替换为其他的类型。程序员编写出下面这样的equals方法并不少见,这会使程序员花上几个小时都搞不清为什么它不能正常工作
|
|
问题在于,这个方法没有覆盖object类的equals方法,因为object类的 equals 的参数应该是Object类型,这里其实是重载了object类的 equals方法,这会导致子类重写equals方法时,不知道重写哪个equals方法。规范的使用@Override注解,可以防止这种错误。
|
|
编写和测试 equals 和 hashCode 方法很乏味,生成的代码也很单调。谷歌的开源 AutoValue 框架,可以自动生成equals 和 hashCode方法,在大多数情况下,AutoValue 生成的方法与你自己编写的方法基本相同。
IDE 也有生成 equals 和 hashCode 方法的功能,但是生成的源代码比使用 AutoValue 的代码更冗长,可读性更差,不会自动跟踪类中的变化(比如加减字段),因此需要进行测试。即便这样,也比手动实现 equals和 hashCode方法更可取,因为 IDE 不会出错,而人会。
总之,除非必须,否则不要覆盖 equals 方法:在许多情况下,从 Object 继承而来的实现符合我们的需要。如果你确实覆盖了 equals,那么一定要比较类的所有重要字段,并且遵守上边的5条约定。
Item-11:如果覆盖了 equals 方法,那么必须同时覆盖 hashCode 方法
(Always override hashCode when you override equals)
如果覆盖了 equals 方法,那么必须同时覆盖 hashCode 方法。 如果你没有这样做,该类将违反 hashCode 方法的约定,这将阻止该类在 HashMap 和 HashSet 等集合中正常运行。约定内容如下,摘自Object规范:
-
在应用程序的执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对同一个对象的多次调用,hashCode方法都必须始终返回同一个值。在一个应用程序与另一个程序的执行过程中,执行hashCode方法所返回的值可以不一致。
-
如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中的hashCode方法都必须产生同样的整数结果。
-
如果两个对象根据equals()方法比较是不相等的,那么调用这两个对象中的hashCode方法,则不一定要求hashCode方法必须产生不同的结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表(hashtable)的性能。
当你无法覆盖 hashCode 方法时,将违反第二个关键条款:相等的对象必须具有相等的散列码(hash code)。根据类的 equals 方法,两个不同的实例在逻辑上可能是相等的,但是对于对象的 hashCode 方法来说,它们只是两个没有共同点的对象。因此,Object 的 hashCode 方法返回两个看似随机的数字,而不是约定要求的两个相等的数字。查看如下示例:
|
|
此时,你可能期望m.get(new PhoneNumber(707,867,5309))
会返回"Jenny",但它实际上返回的是null。
注意,这里涉及两个PhoneNumber实例:第一个被插入HashMap中,第二个实例与第一个相等,用于从Map中根据PhoneNumber去获取用户名字。
由于PhoneNumber类没有覆盖hashCode方法,从而导致两个相等的实例具有不相等的散列码,违反了hashCode的约定。put方法把电话号码对象存放在一个散列桶(hashbucket)中,get方法却在另一个散列桶中查找这个电话号码。
即使这两个实例正好被放到同一个散列桶中,get方法也必定会返回null,因为HashMap有一项优化,可以将与每个项相关联的散列码缓存起来,如果散列码不匹配,也就不再去检验对象的等同性。
修正这个问题非常简单,只需为PhoneNumber类提供一个适当的hashCode方法即可。那么,hashCode方法应该是什么样的呢?下面的方法虽然合法,但不合理
|
|
这么写是合法的,因为它确保了相等的对象具有相同的散列码。同时它也很糟糕,因为它使每个对象都有相同的散列码。因此,每个对象都分配到同一个桶中,散列表退化为链表。这样,原本应该在线性阶 O(n) 运行的程序将在平方阶 O(n^2) 运行。对于大型散列表,这会关系到能否正常工作。
一个好的散列函数通常倾向于“为不相等的对象产生不相等的散列码”。这正是hashcode约定中第三条的含义。理想情况下,散列函数应该把集合中不相等的实例均匀地分布到所有可能的int值上。要想完全达到这种理想的情形是非常困难的。幸运的是,相对接近这种理想情形则并不太困难。下面给出一种简单的解决办法:
- 声明一个名为 result 的 int 变量,并将其初始化为对象中第一个重要字段的散列码 c。
- 对象中剩余的重要字段 f,执行以下操作:
- 为字段计算一个整数散列码 c:
- 如果字段是基本数据类型,计算 Type.hashCode(f),其中 type 是与 f 类型对应的包装类。
- 如果字段是对象引用,并且该类的 equals 方法通过递归调用 equals 方法来比较字段,则递归调
用字段上的 hashCode 方法。如果字段的值为空,则使用 0(或其他常数,但 0 是惯用的)。 - 如果字段是一个数组,则将其每个重要元素都视为一个单独的字段。也就是说,通过递归地应用
这些规则计算每个重要元素的散列码,并将每个步骤的值组合起来。如果数组中没有重要元素,则
使用常量,最好不是 0。如果所有元素都很重要,那么使用 Arrays.hashCode。
- 将步骤 2.a 中计算的散列码 c 合并到 result 变量,如下所示:
result = 31 * result + c;
- 为字段计算一个整数散列码 c:
- 返回 result 变量。
选择 31 是因为它是奇素数。如果是偶数,乘法运算就会溢出,信息就会丢失,因为乘法运算等同于移位。使用素数的好处不太明显,但它是传统用法。31 有一个很好的特性,可以用移位和减法来代替乘法,从而在某些体系结构上获得更好的性能:31 * i == (i <<5) – i
。现代虚拟机自动进行这种优化。将前面的方法应用到 PhoneNumber 类:
|
|
因为这个方法返回一个简单的确定的计算结果,它的唯一输入是 PhoneNumber 实例中的三个重要字段,所以很明显,相等的 PhoneNumber 实例具有相等的散列码。实际上,这个方法是 PhoneNumber 的一个非常好的 hashCode 方法实现,与 Java 库中的 hashCode 方法实现相当。它很简单,速度也相当快,并且合理地将不相等的电话号码分散到不同的散列桶中。
Objects 类有一个静态方法,它接受任意数量的对象并返回它们的散列码。这个名为 hash 的方法允许你编写只有一行代码的 hashCode 方法,这些方法的质量可以与本条目中提供的编写方法媲美。不幸的是,它们运行得更慢,因为它们需要创建数组来传递可变数量的参数,如果任何参数是原始类型的,则需要进行装箱和拆箱。推荐只在性能不重要的情况下使用这种散列算法。下面是使用这种技术编写的 PhoneNumber 的散列算法:
|
|
如果一个类是不可变的,并且计算散列码的开销也比较大,就应该考虑把散列码缓存在对象内部,而不是每次请求的时候都重新计算散列码。如果你觉得这种类型的大多数对象会被用作散列键(hashkeys),就应该在创建实例的时候计算散列码。
否则,可以选择“延迟初始化”散列码,即一直到hashCode被第一次调用的时候才初始化。
虽然我们的PhoneNumber类不值得这样处理,但是可以通过它来说明这种方法该如何实现。
注意hashCode域的初始值(在本例中是0)一般不能成为创建的实例的散列码:
|
|
不要试图从散列码计算中排除重要字段,以提高性能。 虽然得到的散列算法可能运行得更快,但其糟糕的质量可能会将散列表的性能降低到无法使用的程度。特别是,散列算法可能会遇到大量实例,这些实例在你选择忽略的不同区域。如果发生这种情况,散列算法将把所有这些实例映射很少一部分散列码,使得原本应该在线性阶 O(n) 运行的程序将在平方阶 O(n^2) 运行。
不要为 hashCode 返回的值提供详细的规范,这样客户端就不能理所应当的依赖它。这(也)给了你更改它的余地。 Java 库中的许多类,例如 String 和 Integer,都将 hashCode 方法返回的确切值指定为实例值的函数。这不是一个好主意,而是一个我们不得不面对的错误:它阻碍了在未来版本中提高散列算法的能力。如果你保留了未指定的细节,并且在散列算法中发现了缺陷,或者发现了更好的散列算法,那么你可以在后续版本中更改它。
总之,每次覆盖 equals 方法时都必须覆盖 hashCode 方法,否则程序将无法正确运行。你的 hashCode 方法必须遵守 Object 中指定的通用约定,并且必须合理地将不相等的散列码分配给不相等的实例。AutoValue 框架提供了一种能很好的替代手动编写 equals 方法和 hashCode 方法的功能,IDE 也提供了这种功能。
Item-12:始终覆盖 toString 方法
提供好的toString实现可以让类使用起来更加舒适,使用这个类的系统也更容易调试。
Object类提供了toString的实现:
|
|
但是这样显示并不理想,所以建议所有子类都覆盖这个方法。
当对象被传递给println、printf、+(字符串品频接)、assert等时,toString方法会被自动调用。
toString 方法应该返回对象中包含的所有有用信息。如果对象太大,难以用字符串表述信息,那么toString应该返回摘要信息。
无论是否指定格式,都应该在文档中表明这个toString的返回意图。
类应该提供途径获取toString返回值中包括的每个信息,例如给每个信息(成员变量)都增加一个get方法。
Item-13:谨慎的覆盖clone
- 事实上,实现cloneable接口的类是为了提供一个功能适当的共有clone方法。
- 不可变的类永远都不应该提供clone方法。
- clone就是另一个构造器,必须确保他不会伤害到原始的对象,并正确的创建被克隆对象中的约束条件。
- cloneable架构与引用可变对象的final域的正常用法是相互冲突的,除非原对象和克隆对象中此变量可以
共享,否贼为了使类可以被克隆,有必要从某些域中去掉final修饰符 - 对象拷贝的更好方法是采用拷贝构造器或者拷贝工厂(详见第一章第一条静态工厂),因为他们不存在clone的约束
Item-14:考虑实现 Comparable 接口
compareTo 是 Comparable 接口中的唯一方法,一个类实现了 Comparable接口,就表明该类具有内在的排序关系。对实现 Comparable 的对象数组进行排序非常简单:
Arrays.sort(a);
对存储在集合中的对象(实现了Comparable接口),进行搜索、计算极限值也很简单:
|
|
一个类实现了Comparable接口,它就可以跟许多泛型算法,以及依赖于该接口的集合实现进行协作。Java 中的所有值类以及所有枚举类型都实现了 Comparable接口。如果你正在编写一个值类,它具有非常明显的内在排序关系,如按字母、数值或者年代排序,那么应该实现 Comparable接口。
|
|
compareTo方法的定义如下:
将这个对象与指定对象进行比较。
- 当该对象小于指定对象:返回负整数
- 当该对象等于指定对象:返回零
- 当该对象大于指定对象:返回正整数
如果该对象和指定对象类型不一致,则抛出ClassCastException。
jdk7之后,所有的装箱基本类型中都增加了compare方法,因此在compareTo方法中不建议使用>,<,=
这样的比较符是非常繁琐的且容易出错。
|
|
chapter_04 类和接口(Classes and Interfaces)
类和接口是JAVA的核心,是抽象的基本单位。
Item-15:使类和成员的可访问性最小化
(Minimize the accessibility of classes and members)
一个系统通常是由多个模块组成的,模块之前通过API进行通信,一个模块不需要知道其他模块的实现细节。这就是封装,是软件设计的基本原则之一。封装可以有效的解除模块之前的耦合关系,即解耦。这些模块可以独立的开发、测试、优化、使用、修改和理解。这加快了系统开发进度,因为组件可以并行开发。也减轻了维护的负担,因为组件可以被更快地理解、调试或替换,而不必担心会损害其他组件。
Java 有许多工具来帮助隐藏信息。访问控制机制 [JLS, 6.6] 指定了类、接口和成员的可访问性。实体的可访问性由其声明的位置以及声明中出现的访问修饰符(private、protected 和 public)决定。正确使用这些修饰符是信息隐藏的关键。
经验法则很简单:让每个类或成员尽可能不可访问。换句话说,在不影响软件正常功能时,使用尽可能低的访问级别。
对于顶级(非嵌套)类和接口,只有两个可能的访问级别:包私有和公共。如果用 public 修饰符声明一个顶级类或接口,它将是公共的;否则,它将是包私有的。如果顶级类或接口可以设置为包私有,那么就应该这么做。通过将其设置为包私有,可以使其成为实现的一部分,而不是导出的 API 的一部分,并且可以在后续版本中修改、替换或删除它,而不必担心损害现有的客户端。如果将其公开,就有义务永远提供支持,以保持兼容性。
如果包级私有顶级类或接口只被一个类使用,那么可以考虑:在使用它的这个类中,将顶级类设置为私有静态嵌套类(Item-24)。对于包中的所有类以及使用它的类来说,这降低了它的可访问性。但是,降低公共类的可访问性比减少包级私有顶级类的可访问性重要得多:公共类是包 API 的一部分,而包级私有顶级类已经是实现的一部分。
对于成员(字段、方法、嵌套类和嵌套接口),有四个可能的访问级别,这里按可访问性依次递增的顺序列出:
- private 私有,成员只能从声明它的顶级类内部访问。
- package-private 包级私有,成员可以从包中声明它的任何类访问。技术上称为默认访问,即如果没有指定访问修饰符(接口成员除外,默认情况下,接口成员是公共的),就会得到这个访问级别。
- protected 保护,成员可以通过声明它的类的子类(会受一些限制 [JLS, 6.6.2])和声明它的包中的任何类访问。
- public 公共,该成员可以从任何地方访问。
在仔细设计了类的公共 API 之后,你应该本能的使所有成员都是私有的。只有当同一包中的另一个类确实需要访问一个成员时,你才应该删除 private 修饰符,使成员变为包级私有。如果你发现自己经常这样做,那么你应该重新确认系统的设计,看看是否有其他方式能产生更好地相互解耦的类。也就是说,私有成员和包级私有成员都是类实现的一部分,通常不会影响其导出的 API。但是,如果类实现了 Serializable(Item-86 和 Item-87),这些字段可能会「泄漏」到导出的 API 中。
对于公共类的成员来说,当访问级别从包级私有变为保护时,可访问性会有很大的提高。保护成员是类导出 API 的一部分,必须永远支持。此外,导出类的保护成员表示对实现细节的公开承诺(Item-19)。需要保护成员的场景应该相对少见。
有一个关键规则限制了你降低方法的可访问性。如果一个方法覆盖了超类方法,那么它在子类中的访问级别就不能比超类 [JLS, 8.4.8.3] 更严格。这对于确保子类的实例在超类的实例可用的任何地方都同样可用是必要的(Liskov 替换原则,请参阅 Item-15)。如果违反此规则,编译器将在尝试编译子类时生成错误消息。这个规则的一个特例是,如果一个类实现了一个接口,那么该接口中的所有类方法都必须在类中声明为 public。
为了便于测试代码,你可能会试图使类、接口或成员更容易访问。这在一定程度上是好的。为了测试,将公共类成员由私有变为包私有是可以接受的,但是进一步提高可访问性是不可接受的。换句话说,将类、接口或成员作为包导出 API 的一部分以方便测试是不可接受的。幸运的是,也没有必要这样做,因为测试可以作为包的一部分运行,从而获得对包私有元素的访问权。
公共类的实例字段很少采用 public 修饰(Item-16)。如果实例字段不是 final 的,或者是对可变对象的引用,那么将其公开,你就放弃了限制字段中可以存储的值的能力。这意味着你放弃了强制包含字段的不可变的能力。此外,你还放弃了在修改字段时采取任何操作的能力,因此 带有公共可变字段的类通常不是线程安全的。 即使一个字段是 final 的,并且引用了一个不可变的对象,通过将其公开,你放弃了切换到一个新的内部数据表示的灵活性,而该字段并不存在。
同样的建议也适用于静态字段,只有一个例外。你可以通过公共静态 final 字段公开常量,假设这些常量是类提供的抽象的组成部分。按照惯例,这些字段的名称由大写字母组成,单词以下划线分隔(Item-68)。重要的是,这些字段要么包含基本数据类型,要么包含对不可变对象的引用(Item-17)。包含对可变对象的引用的字段具有非 final 字段的所有缺点。虽然引用不能被修改,但是引用的对象可以被修改会导致灾难性的后果。
请注意,非零长度的数组总是可变的,因此对于类来说,拥有一个公共静态 final 数组字段或返回该字段的访问器是错误的。如果一个类具有这样的字段或访问器,客户端将能够修改数组的内容。这是一个常见的安全漏洞来源:
|
|
要注意的是,一些 IDE 生成了返回私有数组字段引用的访问器,这恰恰会导致这个问题。有两种方法可以解决这个问题。你可以将公共数组设置为私有,并添加一个公共不可变 List:
|
|
或者,你可以将数组设置为私有,并添加一个返回私有数组副本的公共方法:
|
|
如何在这些备选方案中进行选择,请考虑客户可能会如何处理结果。哪种返回类型更方便?哪种表现会更好?
对于 Java 9,作为模块系统的一部分,还引入了另外两个隐式访问级别。模块是包的分组单位,就像包是类的分组单位一样。模块可以通过模块声明中的导出声明显式地导出它的一些包(按照约定包含在名为 module-info.java
的源文件中)。模块中未导出包的公共成员和保护成员在模块外不可访问;在模块中,可访问性不受导出声明的影响。通过使用模块系统,你可以在模块内的包之间共享类,而不会让整个世界看到它们。未导出包中的公共类和保护成员产生了两个隐式访问级别,它们是正常公共级别和保护级别的类似物。这种共享的需求相对较少,通常可以通过重新安排包中的类来解决。
与四个主要的访问级别不同,这两个基于模块的级别在很大程度上是建议级别。如果将模块的 JAR 文件放在应用程序的类路径上,而不是模块路径上,模块中的包将恢复它们的非模块行为:包的公共类的所有公共成员和保护成员都具有正常的可访问性,而不管模块是否导出包 [Reinhold,1.2]。严格执行新引入的访问级别的一个地方是 JDK 本身:Java 库中未导出的包在其模块之外确实不可访问。
对于典型的 Java 程序员来说,访问保护不仅是有限实用的模块所提供的,而且本质上是建议性的;为了利用它,你必须将包以模块分组,在模块声明中显式地声明它们的所有依赖项,重新安排源代码树,并采取特殊操作以适应从模块中对非模块化包的任何访问 [Reinhold, 3]。现在说模块能否在 JDK 之外得到广泛使用还为时过早。与此同时,除非你有迫切的需求,否则最好还是不使用它们。
总之,你应该尽可能减少程序元素的可访问性(在合理的范围内)。在仔细设计了一个最小的公共 API 之后,你应该防止任何游离的类、接口或成员成为 API 的一部分。除了作为常量的公共静态 final 字段外,public 类应该没有公共字段。确保公共静态 final 字段引用的对象是不可变的。
Item-16:要在公有类而非公有域中使用访问方法
(In public classes, use accessor methods, not public fields)
有时,你可能会编写一些没什么功能的类,它没有别的目的,只是用来将一些实例字段放在一起而已:
|
|
因为这些类的数据字段是直接访问的,所以这些类没有提供封装的好处。不改变 API 就不能改变表现形式,不能实现不变量,也不能在访问字段时采取辅助操作。坚持面向对象思维的程序员会认为这样的类是令人厌恶的,应该被使用私有字段和公共访问方法 getter 的类所取代,对于可变类,则是赋值方法 setter:
|
|
当然,当涉及到公共类时,强硬派是正确的:如果类可以在包之外访问,那么提供访问器方法来保持更改类内部表示的灵活性。如果一个公共类公开其数据字段,那么改变其表示形式的所有希望都将落空,因为客户端代码可以广泛分发。
但是,如果一个类是包级私有的或者是私有嵌套类,那么公开它的数据字段并没有什么本质上的错误(假设它们能够很好地描述类提供的抽象)。无论是在类定义还是在使用它的客户端代码中,这种方法产生的视觉混乱都比访问方法少。虽然客户端代码与类的内部表示绑定在一起,但这段代码仅限于包含该类的包。如果想要对表示形式进行更改,你可以在不接触包外部任何代码的情况下进行更改。对于私有嵌套类,更改的范围进一步限制在封闭类中。
Java 库中的几个类违反了公共类不应该直接公开字段的建议。突出的例子包括 java.awt
包中的 Point 和 Dimension。这些类不应被效仿,而应被视为警示。正如 Item-67 所述,公开 Dimension 类的内部结构导致了严重的性能问题,这种问题至今仍存在。
虽然公共类直接公开字段从来都不是一个好主意,但是如果字段是不可变的,那么危害就会小一些。你不能在不更改该类的 API 的情况下更改该类的表现形式,也不能在读取字段时采取辅助操作,但是你可以实施不变量。例如,这个类保证每个实例代表一个有效的时间:
|
|
总之,公共类不应该公开可变字段。对于公共类来说,公开不可变字段的危害要小一些,但仍然存在潜在的问题。然而,有时候包级私有或私有嵌套类需要公开字段,无论这个类是可变的还是不可变的。
Item-17:减少可变性(Minimize mutability)
不可变类是实例不能被修改的类。每个实例中包含的所有信息在对象的生命周期内都是固定的,因此永远不会观察到任何更改。Java 库包含许多不可变的类,包括 String、基本类型的包装类、BigInteger 和 BigDecimal
。这么做有很好的理由:不可变类比可变类更容易设计、实现和使用。它们不太容易出错,而且更安全。
要使类不可变,请遵循以下 5 条规则:
- 不要提供修改对象状态的方法
- 确保类不能被继承。 这可以防止粗心或恶意的通过子类实例对象状态可改变的方式,损害父类的不可变行为。防止子类化通常用 final 修饰父类,但是还有一种替代方法,我们将在后面讨论。
- 所有字段用 final 修饰。 这清楚地表达了意图,并由系统强制执行。同样,如果在没有同步的情况下,引用新创建的实例并从一个线程传递到另一个线程,那么就有必要确保正确的行为,就像内存模型中描述的那样 [JLS, 17.5;Goetz06, 16]。
- 所有字段设为私有。 这将阻止客户端访问字段引用的可变对象并直接修改这些对象。虽然在技术上允许不可变类拥有包含基本类型或对不可变对象的引用的公共 final 字段,但不建议这样做,因为在以后的版本中无法更改内部表示(Item-15 和 Item-16)。
- 确保对任何可变组件的独占访问。 如果你的类有任何引用可变对象的字段,请确保该类的客户端无法获得对这些对象的引用。永远不要向提供对象引用的客户端初始化这样的字段,也不要从访问器返回字段。在构造函数、访问器和 readObject 方法(Item-88)中创建防御性副本(Item-50)。
前面条目中的许多示例类都是不可变的。其中一个类是 Item-11 中的 PhoneNumber,它的每个属性都有访问器,但没有对应的修改器。下面是一个稍微复杂的例子:
|
|
这个类表示一个复数(包含实部和虚部的数)。除了标准的 Object 方法之外,它还为实部和虚部提供访问器,并提供四种基本的算术运算:加法、减法、乘法和除法。值得注意的是,算术操作创建和返回一个新的 Complex 实例,而不是修改这个实例。这种模式称为函数式方法,因为方法返回的结果是将函数应用到其操作数,而不是修改它。将其与过程式或命令式方法进行对比,在这种方法中,方法将一个计算过程应用于它们的操作数,从而导致其状态发生变化。注意,方法名是介词(如 plus),而不是动词(如 add)。这强调了这样一个事实,即方法不会改变对象的值。BigInteger 和 BigDecimal 类不遵守这种命名约定,这导致了许多使用错误。
如果不熟悉函数式方法,那么它可能看起来不自然,但它实现了不变性,这么做有很多优势。 不可变对象很简单。 不可变对象可以保持它被创建时的状态。如果能够确保所有构造函数都建立了类不变量,那么就可以保证这些不变量将一直保持,而无需你或使用类的程序员做进一步的工作。另一方面,可变对象可以具有任意复杂的状态空间。如果文档没有提供由修改器方法执行的状态转换的精确描述,那么就很难或不可能可靠地使用可变类。
不可变对象本质上是线程安全的;它们不需要同步。 它们不会因为多线程并发访问而损坏。这无疑是实现线程安全的最简单方法。由于任何线程都无法观察到另一个线程对不可变对象的任何影响,因此 可以自由共享不可变对象。 同时,不可变类应该鼓励客户端尽可能复用现有的实例。一种简单的方法是为常用值提供公共静态 final 常量。例如,Complex 类可能提供以下常量:
|
|
这种方法可以更进一步。不可变类可以提供静态工厂(Item-1),这些工厂缓存经常请求的实例,以避免在现有实例可用时创建新实例。所有包装类和 BigInteger 都是这样做的。使用这种静态工厂会导致客户端共享实例而不是创建新实例,从而减少内存占用和垃圾收集成本。在设计新类时,选择静态工厂而不是公共构造函数,这将使你能够灵活地在以后添加缓存,而无需修改客户端。
不可变对象可以自由共享这一事实的结果之一是,你永远不需要对它们进行防御性的复制(Item-50)。事实上,你根本不需要做任何拷贝,因为拷贝将永远等同于原件。因此,你不需要也不应该在不可变类上提供克隆方法或复制构造函数(Item-13)。这在 Java 平台的早期并没有得到很好的理解,因此 String 类确实有一个复制构造函数,但是,即使有,也应该少用(Item-6)。
你不仅可以共享不可变对象,而且可以共享它们的内部实现。 例如,BigInteger 类在内部使用符号大小来表示。符号由 int 表示,大小由 int 数组表示。negate 方法产生一个新的 BigInteger,大小相同,符号相反。即使数组是可变的,也不需要复制;新创建的 BigInteger 指向与原始数组相同的内部数组。
不可变对象可以很好的作为其他对象的构建模块, 无论是可变的还是不可变的。如果知道复杂对象的组件对象不会在其内部发生更改,那么维护复杂对象的不变性就会容易得多。这个原则的一个具体的例子是,不可变对象很合适作为 Map 的键和 Set 的元素:你不必担心它们的值在 Map 或 Set 中发生变化,从而破坏 Map 或 Set 的不变性。
不可变对象自带提供故障原子性(Item-76)。他们的状态从未改变,所以不可能出现暂时的不一致。
不可变类的主要缺点是每个不同的值都需要一个单独的对象。 创建这些对象的成本可能很高,尤其是对象很大的时候。例如,假设你有一个百万位的 BigInteger,你想改变它的低阶位:
|
|
flipBit 方法创建了一个新的 BigInteger 实例,也有百万位长,只在一个比特上与原始的不同。该操作需要与 BigInteger 的大小成比例的时间和空间。与 java.util.BitSet
形成对比。与 BigInteger 一样,BitSet 表示任意长的位序列,但与 BigInteger 不同,BitSet 是可变的。BitSet 类提供了一种方法,可以让你在固定的时间内改变百万位实例的单个位的状态:
|
|
如果执行多步操作,在每一步生成一个新对象,最终丢弃除最终结果之外的所有对象,那么性能问题就会被放大。有两种方法可以解决这个问题。第一种方法是猜测通常需要哪些多步操作,并将它们作为基本数据类型提供。如果将多步操作作为基本数据类型提供,则不可变类不必在每个步骤中创建单独的对象。在内部,不可变类可以任意聪明。例如,BigInteger 有一个包私有的可变「伴随类」,它使用这个类来加速多步操作,比如模块化求幂。由于前面列出的所有原因,使用可变伴随类要比使用 BigInteger 难得多。幸运的是,你不必使用它:BigInteger 的实现者为你做了艰苦的工作。
如果你能够准确地预测客户端希望在不可变类上执行哪些复杂操作,那么包私有可变伴随类方法就可以很好地工作。如果不是,那么你最好的选择就是提供一个公共可变伴随类。这种方法在 Java 库中的主要示例是 String 类,它的可变伴随类是 StringBuilder(及其过时的前身 StringBuffer)。
既然你已经知道了如何创建不可变类,并且了解了不可变性的优缺点,那么让我们来讨论一些设计方案。回想一下,为了保证不变性,类不允许自己被子类化。可以用 final 修饰以达到目的,但是还有另外一个更灵活的选择,你可以将其所有构造函数变为私有或包私有,并使用公共静态工厂方法来代替公共的构造函数(Item-1)。Complex 类采用这种方式修改后如下所示:
|
|
这种方式通常是最好的选择。它是最灵活的,因为它允许使用多个包私有实现类。对于驻留在包之外的客户端而言,不可变类实际上是 final 类,因为不可能继承自另一个包的类,因为它缺少公共或受保护的构造函数。除了允许多实现类的灵活性之外,这种方法还通过改进静态工厂的对象缓存功能,使得后续版本中调优该类的性能成为可能。
当编写 BigInteger 和 BigDecimal 时,不可变类必须是有效的 final 这一点没有被广泛理解,因此它们的所有方法都可能被重写。遗憾的是,在保留向后兼容性的情况下,这个问题无法得到纠正。如果你编写的类的安全性依赖于来自不受信任客户端的 BigInteger 或 BigDecimal 参数的不可变性,那么你必须检查该参数是否是「真正的」BigInteger 或 BigDecimal,而不是不受信任的子类实例。如果是后者,你必须防御性的复制它,假设它可能是可变的(Item-50):
|
|
这个条目开头的不可变类的规则列表指出,没有方法可以修改对象,它的所有字段必须是 final 的。实际上,这些规则过于严格,可以适当放松来提高性能。实际上,任何方法都不能在对象的状态中产生外部可见的更改。然而,一些不可变类有一个或多个非 final 字段,它们在第一次需要这些字段时,就会在其中缓存昂贵计算的结果。如果再次请求相同的值,则返回缓存的值,从而节省了重新计算的成本。这个技巧之所以有效,是因为对象是不可变的,这就保证了重复计算会产生相同的结果。
例如,PhoneNumber 的 hashCode 方法(Item-11,第 53 页)在第一次调用时计算哈希代码,并缓存它,以备再次调用。这个技术是一个延迟初始化的例子(Item-83),String 也使用这个技术。
关于可序列化性,应该提出一个警告。如果你选择让不可变类实现 Serializable,并且该类包含一个或多个引用可变对象的字段,那么你必须提供一个显式的 readObject 或 readResolve 方法,或者使用 ObjectOutputStream.writeUnshared 或 ObjectInputStream.readUnshared 方法,即使默认的序列化形式是可以接受的。否则攻击者可能创建类的可变实例。Item-88详细讨论了这个主题。
总而言之,不要急于为每个 getter 都编写 setter。类应该是不可变的,除非有很好的理由让它们可变。 不可变类提供了许多优点,它们唯一的缺点是在某些情况下可能出现性能问题。你应该始终使小的值对象(如 PhoneNumber 和 Complex)成为不可变的。(Java 库中有几个类,比如 java.util.Date
和 java.awt.Point
,应该是不可改变的,但事实并非如此。)也应该认真考虑将较大的值对象(如 String 和 BigInteger)设置为不可变的。只有确认了实现令人满意的性能是必要的,才应该为不可变类提供一个公共可变伴随类(Item-67)。
对于某些类来说,不变性是不切实际的。如果一个类不能成为不可变的,那么就尽可能地限制它的可变性。 减少对象可能存在的状态数可以更容易地 reason about the object 并减少出错的可能性。因此,除非有令人信服的理由,否则每个字段都应该用 final 修饰。将本条目的建议与 Item-15 的建议结合起来,你自然会倾向于 声明每个字段为私有 final,除非有很好的理由不这样做。
构造函数应该创建完全初始化的对象,并建立所有的不变量。 除非有充分的理由,否则不要提供与构造函数或静态工厂分离的公共初始化方法。类似地,不要提供「重新初始化」的方法,该方法允许复用对象,就好像它是用不同的初始状态构造的一样。这些方法通常只提供很少的性能收益,而代价是增加了复杂性。
CountDownLatch 类体现了这些原则。它是可变的,但是它的状态空间故意保持很小。创建一个实例,使用它一次,它就完成了使命:一旦倒计时锁存器的计数达到零,你可能不会复用它。
关于本条目中 Complex 类的最后一点需要补充的说明。这个例子只是为了说明不变性。它不是一个工业级强度的复数实现。它使用了复杂乘法和除法的标准公式,这些公式没有被正确地四舍五入,并且为复杂的 NaNs 和 infinities 提供了糟糕的语义 [Kahan91, Smith62, Thomas94]。
Item-18:复合优先于继承(Favor composition over inheritance)
继承是实现代码复用的一种强大方法,但它并不总是最佳的工具。使用不当会导致软件变得脆弱。在同一个包中使用继承是安全的,其中子类和超类实现由相同的程序员控制。在对专为扩展而设计和文档化的类时使用继承也是安全的(Item-19)。然而,对普通的具体类进行跨越包边界的继承是危险的。作为提醒,本书使用「继承」一词来表示实现继承(当一个类扩展另一个类时)。本条目中讨论的问题不适用于接口继承(当类实现接口或一个接口扩展另一个接口时)。
// todo
Item-19:要么设计继承并提供文档说明,要么禁止继承
(Design and document for inheritance or else prohibit it)
对于专门为了继承而设计的类, 需要具有良好的文档,该类的文档必须精确地描述覆盖每个方法所带来的影响。对于那些并非为了安全地进行子类化而设计和编写文档的类, 要禁止子类化。
- 把类声明为final
- 把所有的构造器都变成私有的,或者包级私有的,并增加一些公有的静态工厂来替代构造器
Item-20:接口优于抽象类(Prefer interfaces to abstract classes)
Item-21:为后代设计接口(Design interfaces for posterity)
Item-22:接口只用于定义类型(Use interfaces only to define types)
Item-23:类层次优于标签类(Prefer class hierarchies to tagged classes)
Item-24:静态成员类优于非静态成员类(Favor static member classes over nonstatic)
Item-25:源文件仅限有单个顶层类(Limit source files to a single top-level class)
chapter05 泛型(Generics)
泛型是JAVA 5的新特性,在泛型出现之前,从集合中读取的每个对象都必须进行强制转换,如果有人不小心插入了错误类型的对象,强制类型转换可能在运行时失败。泛型出现之后,你可以告知编译器在每个集合中允许哪些类型的对象。编译器会自动为你进行强制转换与插入的操作,如果你试图插入类型错误的对象,编译器会在编译时告诉你。这样的程序更加安全和清晰。
Item-26:不要使用原始类型(Don’t use raw types)
Item-27:消除 unchecked 警告(Eliminate unchecked warnings)
Item-28:list 优于数组(Prefer lists to arrays)
Item-29:优先考虑泛型(Favor generic types)
Item-30:优先使用泛型方法(Favor generic methods)
Item-31:使用有界通配符增加 API 的灵活性(Use bounded wildcards to increase API flexibility)
Item-32:同时使用可变参数和泛型要谨慎(Combine generics and varargs judiciously)
Item-33:考虑类型安全的异构容器(Consider typesafe heterogeneous containers)
chapter06 枚举和注解(Enums and Annotations)
JAVA 支持两种特殊用途的引用类型:一种称为枚举类型的类,以及一种称为注解类型的接口。
Item-34:用枚举类型代替 int 常量(Use enums instead of int constants)
Item-35:使用实例字段替代序数(Use instance fields instead of ordinals)
Item-36:用 EnumSet 替代位字段(Use EnumSet instead of bit fields)
Item-37:使用 EnumMap 替换序数索引(Use EnumMap instead of ordinal indexing)
Item-38:使用接口模拟可扩展枚举(Emulate extensible enums with interfaces)
Item-39:注解优于命名模式(Prefer annotations to naming patterns)
Item-40:坚持使用 @Override 注解(Consistently use the Override annotation)
Item-41:使用标记接口定义类型(Use marker interfaces to define types)
chapter07 Lambda 和 Stream
JAVA8新增了函数式接口、Lambda表达式和方法引用,使创建函数对象变的容易。还增加了stream相关API,为处理 List、Map等容器,提供了类库级别的支持。
Item-42:lambda优先于匿名类(Prefer lambdas to anonymous classes)
Item-43:方法引用优先于lambda(Prefer method references to lambdas)
Item-44:优先使用标准函数式接口(Favor the use of standard functional interfaces)
Item-45:谨慎使用stream(Use streams judiciously)
Item-46:优先使用Stream中无副作用的函数(Prefer side-effect-free functions in streams)
Item-47:优先选择 Collection 而不是流作为返回类型(Prefer Collection to Stream as a return type)
Item-48:谨慎使用stream并行(Use caution when making streams parallel)
chapter08 方法
本章讨论了方法设计的几个方面:如何处理参数和返回值,如何设计方法签名,以及如何编写方法文档。本章的大部分内容不仅适用于普通方法,也适用于构造函数。
Item-49:检查参数的有效性(Check parameters for validity)
Item-50:必要时进行保护性拷贝(Make defensive copies when needed)
Item-51:谨慎设计方法签名(Design method signatures carefully)
Item-52:慎用重载(Use overloading judiciously)
Item-53:慎用可变参数(Use varargs judiciously)
Item-54:返回空集合或数组,而不是 null(Return empty collections or arrays, not nulls)
Item-55:谨慎返回optinal(Return optionals judiciously)
Item-56:为所有公开的 API 元素编写文档注释(Write doc comments for all exposed API elements)
chapter09 通用程序设计(General Programming)
Item-57:将局部变量的作用域最小化(Minimize the scope of local variables)
Item-58:for-each 循环优于传统的 for 循环(Prefer for-each loops to traditional for loops)
Item-59:了解和使用类库(Know and use the libraries)
Item-60:若需要精确答案就应避免使用 float 和 double 类型(Avoid float and double if exact answers are required)
Item-61:基本数据类型优于包装类(Prefer primitive types to boxed primitives)
Item-62:其他类型更合适时应避免使用字符串(Avoid strings where other types are more appropriate)
Item-63:当心字符串连接引起的性能问题(Beware the performance of string concatenation)
Item-64:通过接口引用对象(Refer to objects by their interfaces)
Item-65:接口优于反射(Prefer interfaces to reflection)
Item-66:谨慎的使用本地方法(Use native methods judiciously)
Item-67:谨慎的进行优化(Optimize judiciously)
Item-68:遵守被广泛认可的命名约定(Adhere to generally accepted naming conventions)
chapter-10 异常
充分发挥异常的优点,可以提高程序的可读性、可靠性和可维护性。若使用不当,异常也会带来负面影响。
Item-69:只在确认有异常的情况下才使用异常(Use exceptions only for exceptional conditions)
Item-70:对可恢复情况使用 checked 异常,对编程错误使用运行时异常(Use checked exceptions for recoverable conditions and runtime exceptions for programming errors)
Item-71:避免不必要地使用 checked 异常(Avoid unnecessary use of checked exceptions)
Item-72:优先使用标准的异常(Favor the use of standard exceptions)
Item-73:抛出能用抽象解释的异常(Throw exceptions appropriate to the abstraction)
Item-74:每个方法抛出的异常都要建立文档(Document all exceptions thrown by each method)
Item-75:异常详细消息中应包含捕获失败的信息(Include failure capture information in detail messages)
Item-76:让失败保持原子性(Strive for failure atomicity)
Item-77:不要忽略异常(Don’t ignore exceptions)
chapter-11 并发(Concurrency)
多线程允许系统同时进行多个活动。多线程程序设计比单线程程序设计要难的多,但是你无法避免并发,因为我们所做的大部分事情都需要并发。
Item-78:同步访问共享的可变数据(Synchronize access to shared mutable data)
Item-79:避免过度同步(Avoid excessive synchronization)
Item-80:Executor、task、streams优于直接使用线程(Prefer executors, tasks, and streams to threads)
Item-81:并发实用工具优于 wait 和 notify(Prefer concurrency utilities to wait and notify)
Item-82:文档应包含线程安全内容(Document thread safety)
Item-83:谨慎地使用延迟初始化(Use lazy initialization judiciously)
Item-84:不要依赖线程调度器(Don’t depend on the thread scheduler)
序列化(Serialization)
内存中的数据对象只有转换为二进制流才可以进行数据持久化和网络传输。将数据对象转换为二进制流的过程称为对象的序列化( Serialization )。
反之,将二进制流恢复为数据对象的过程称为反序列化( Deserialization )。序列化需要保留充分的信息以恢复数据对象,但是为了节约存储空间和网络带宽,序列化后的二进制流又要尽可能小。