fxleyu / west-world

This is a repository for the records of books, films, teleplay and so on.
https://fxleyu.github.io/
0 stars 0 forks source link

[阅读笔记][EJ] 第 2 章 创建和销毁对象 #49

Closed fxleyu closed 6 years ago

fxleyu commented 6 years ago

来自《Effective Java》 #43 的阅读笔记。

本章的主题是创建和销毁对象:

本章目录

fxleyu commented 6 years ago

第 1 条:考虑用静态工厂方法代替构造器

对于类而言,为了让客户端获取它本身的一个实例,最常用的方法就是提供一个公有的构造器。还有一种方法,也应该在每个程序员的工具箱中占有一席之地。类可用提供一个公有的 静态工厂方法(static factory method),它只是一个返回类实例的静态方法。

public static Boolean valueOf(boolean b) {
    return (b ? TRUE : FALSE);
}

注意 静态工厂方法与设计模式中的工厂方法模式不同。本条目中所指的静态工厂方法并不直接对应于涉及模式中的工厂方法。

类可以通过静态工厂方法来提供它的客户端,而不是通过构造器。提供静态工厂方法而不是公有的构造器,这样做具有极大优势。

相对于构造器,静态工厂方法的优势

如果构造器的参数本身没有确切地描述正被返回的对象,那么具有恰当名称的静态工厂会更容易使用,产生的客户端代码也更容易阅读。

public static BigInteger probablePrime(int bitLength, Random rnd) {
  if (bitLength < 2)
    throw new ArithmeticException("bitLength < 2");

  // The cutoff of 95 was chosen empirically for best performance
  return (bitLength < SMALL_PRIME_THRESHOLD ?
            smallPrime(bitLength, DEFAULT_PRIME_CERTAINTY, rnd) :
            largePrime(bitLength, DEFAULT_PRIME_CERTAINTY, rnd));
}

当一个类需要多个带有相同签名的构造器时,就用静态工厂方法代替构造器,并且慎重地选择名称以便突出它们之间的区别。

静态工厂方法能够为重复的调用返回相同对象,这样有助于类总能控制在某个时刻哪些实例应该存在。这种类被称为 实例受控的类(instance-controlled)。编写受控的类有几个原因。

静态工厂方法的主要缺点:

静态工厂方法的一些惯用名称:

fxleyu commented 6 years ago

第 2 条:遇到多个构造器参数时要考虑用构建器

静态工厂和构造器有个共同的局限性:它们都不能很好地扩展到大量的可选参数。有如下三种模式:

重叠构造器(telescoping constructor)模式

在这种模式下,你提供第一个只有必要参数的构造器,第二个构造器有一个可选参数,第三个构造器有两个可选参数,依此类推,最后一个构造器包含所有可选参数。

重叠构造器模式可行,但当有很多参数的时候,客户端代码会很难编写,并且仍然较难以阅读。

JavaBean 模式

在这种模式下,调用一个无参数构造器来创建对象,然后调用 setter 方法来设置每个必要的参数,以及每个相关的可选参数。

遗憾的是,JavaBean 模式自身有着很严重的缺点。因为构造过程被分到了几个调用中,在构造过程中 JavaBean 可能处于不一致的状态。类无法仅仅通过检验构造器参数的有效性来保证一致性。与此相关的另一个不足在于,JavaBeans 模式阻止了把类做成不可变的可能,这就需要程序员付出额外的努力来确保它的线程安全。

Builder 模式

不直接生成想要的对象,而是让客户端利用所有必要的参数调用构造器(或者静态工厂),得到一个 builder 对象。然后客户端在 builder 对象上调用类似于 setter 的方法,来设置每个相关的可选参数。最后,客户端调用无参的 build 方法来生成不可变的对象。这个 builder 是它构建的类的静态成员类。

builder 模式模拟了具名的可选参数。

Builder 模式具有易读和一致性保证。

Builder 模式的确也有它自身的不足。为了创建对象,必须先创建它的构造器。虽然创建 builder 的开销在实现中可能不那么明显,但是在某些十分注重性能的情况下,可能就成问题了。

总结

简而言之,如果类的构造器或静态工厂中具有多个参数,设计这种类时,Builder 模式就是中不错的选择,特别是当大多数参数都是可选的时候。与使用传统的重叠构造器模式相比,使用 Builder 模式的客户端将便于阅读和编写,构建器也比 JavaBeans 更加安全。

fxleyu commented 6 years ago

第 3 条:用私有构造器或枚举类型强化 Singleton 属性

Singleton 指仅仅被实例化一次的类。Singleton 通常被用来代表那些本质上唯一的系统组件。使类称为 Singleton 会使它的客户端测试变得十分困难。

在 Java 1.5 发行版本之前,实现 Singleton 有两种方法。这两种方法都要把构造器保持为私有的,并导出公有的静态成员,以便允许客户端能够访问该类的唯一实例。

注意 享有特权的客户端可以借助于 AccessibleObject.setAccessible 方法,通过反射机制调用私有构造器。如果需要抵御这种攻击,可以修改构造器,让它在被要求创建第二个实例的时候抛出异常。

公有域方法的主要好处在于,组成类的成员的声明很清楚地表明了这个类是一个 Singleton:公有的静态域是 final 的,所以该域将总是包含相同的对象引用。公有域方法在性能上不再有任何优势:现代的 JVM 实现机会都能够将静态工厂方法的调用内联化。

工厂方法的优势之一在于,它提供了灵活性:在不改变其 API 的前提下,我们可以改变该类是否应该为 Singleton 的想法。工厂方法返回该类的唯一实例,但是,它可以很容易被修改,比如改成为每个调用该方法的线程返回一个唯一的实例。第二个优势与泛型有关。

单元素的枚举类型已经成为实现 Singleton 的最佳方法。

从 Java 1.5 发行版本起,实现 Singleton 还有第三种方法。只需编写一个包含单个元素的枚举类型。

这种方法在功能上与公有域方法相近,但是它更加简洁,无偿地提高了序列化机制,绝对防止多次实例化,即使是在面对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型已经成为实现 Singleton 的最佳方法。

fxleyu commented 6 years ago

第 4 条:通过私有构造器强化不可实例化的能力

有时候,你可能需要编写只包含静态方法和静态域的类。

这样的工具类(utility class)不希望被实例化,实例对它没有任何意义。

企图通过将类做成抽象类来强制该类不可被实例化,这是行不通的。该类可以被子类化,并且该子类也可以被实例化。这样做甚至会误导用户,以为这种类是专门为了继承而设计的。

public class BusinessUtils {
    private BusinessUtils() {
        throw new AssertionError("不可被实例化");
    }
    // ...
}

由于显示的构造器是私有的,所以不可以在该类的外部访问它。AssertionError 不是必须的,但是它可以避免不小心在类的内部调用构造器。它保证该类在任何情况下都不会被实例化。

fxleyu commented 6 years ago

第 5 条:避免创建不必要的对象

一般来说,最好能重用对象而不是在每次需要的时候就创建一个相同功能的对象。重用方式既快速,又流行。如果对象是不可变(immutable),它就始终可以被重用。

对于同时提供了静态工厂方法和构造器的不可变类,通常可以使用静态工厂方法而不是构造器,以避免创建不必要的对象。例如 Boolean valueOf(boolean b)

除了重用不可变的对象外,也可以重用那些已知不会被修改的可变对象。

private static final Date BOOM_START;
private static final Date BOOM_END;

static {
    Calendar gmtCal = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
    gmtCal.set(1946, Calendar.JANUARY, 1, 0, 0, 0);
    BOOM_START = gmtCal.getTime();
    gmtCal.set(1965, Calendar.JANUARY, 1, 0, 0, 0);
    BOOM_END = gmtCal.getTime();
}

public static boolean isBabyBoomer(Date birthDate) {
    return birthDate.compareTo(BOOM_START) >= 0 && birthDate.compareTo(BOOM_END) < 0;
}

考虑适配器(Adapter)的情形,有时也叫做视图(view)。适配器是指这样一个对象:它把功能委托给一个后备对象之外,没有其他的状态信息,所以针对某个给定对象的特定适配器而言,它不需要创建多个适配器实例。

在 Java 1.5 发行版本中,有一种创建多余对象的新方法,成为自动装箱(autoboxing),它允许程序员将基本类型和装箱基本类型(Boxed Primitive Type)混用,按需要自动装箱和拆箱。自动装箱使得基本类型和装箱基本类型之间的差别变得模糊起来,但是并没有完全消除。它们在语义上还有这微妙的差别,在性能上也有着比较明显的差别。

@Test
public void testAutoboxing() {
    long start = System.currentTimeMillis();
    Long sum = 0L;
    for (int i = 0; i < Integer.MAX_VALUE; i++) {
        sum += i;
    }
    System.out.println("time " + (System.currentTimeMillis() - start));
    // Long 12840ms, long 2ms
}

结论:要优先使用基本类型而不是装箱基本类型,要当心无意识的自动装箱。

由于小对象的构造器只做很少的显式工作,所以,小对象的创建和回收是非常廉价的,特别是在现代的 JVM 实现上更是如此。通常创建附件的对象,提升程序的清晰性、简洁性和功能性,这通常是件好事。

反之,通过维护自己的对象池(object pool)来避免创建对象并不是一种好的做法,除非池中的对象是非常重量级别的。真正正确使用对象池的典型对象示例就是数据库连接池。但是,一般而言,维护自己的对象池必定会把代码弄得很乱,同时增加内存占用(footprint),并且还会损害性能。现在的 JVM 实现具有高度优化的垃圾回收器,其性能很容易就会超过轻量级对象的性能。

本条目提及“当你应该重用现有对象的时候,请不要创建新的对象”,而第 39 条则说“当你应该创建对象的时候,请不要重用现有的对象”。注意,在提倡使用保护性拷贝的时候,因重用对象而付出的代价要远远大于因创建重复对象而付出的代价。必要时如果没能实施保护性拷贝,将会导致潜在的错误和安全漏洞;而不必要地创建对象则只会影响程序的风格和性能。

fxleyu commented 6 years ago

第 6 条:消除过期的对象引用

所谓的过期引用(obsolete reference),是指永远也不会再被解除的引用。

在支持垃圾回收的语言中,内存泄漏是很隐蔽的(称这类内存泄漏为“无意识的对象保持(unintentional object retention)”更为恰当)。如果一个对象引用被无意识地保留起来了,那么,垃圾回收机制不仅不会处理这个对象,而且也不会处理被这个对象所引用的所有其他对象。及时只有少量的几个对象引用被无意识地保留下来,也会有许许多多的对象被排除在垃圾回收之外,从而对性能造成潜在的重大影响。

这类问题的修复方法很简单:一旦对象引用已经过期,只需情况这些引用即可。

清空对象引用应该是一种例外,而不是一种规范行为。 清除过期引用最好的方法是让包含该引用的变量结束其生命周期。如果你是在最紧凑的作用域范围内定义每一个变量,这种情形就会自热而然的发生。

一旦数组元素变成了非活动部分的一部分,程序员就手工清空这些数组元素。

总结

一般而言,只要类是自己管理内存,程序员就应该警惕内存泄漏问题。 一旦元素被释放掉,则该元素中包含的任何对象引用都应该被清空。

内存泄露的另一个常见来源是 缓存。一旦你把对象引用放到缓存中,它就很容易被遗忘,从而使得它不再有用之后很长一段时间内仍然留在缓存中。对于这个问题,有几种可能的解决方案。如果你正好要实现这样的缓存:只要在缓存之外存在对某个项的键的引用,该项就有意义,那么就可以用 WeakHashMap 代替缓存;当缓存中的项过期之后,它们就会自动被删除。记住只有当所要的缓存项的生命周期是由该键的外部引用而不是由值决定时,WeakHashMap 才有用处。

更为常见的情形则时,“缓存项的生命周期是否有意义”并不是很容易确定,随着时间推移,其中的项会变得越来越没有价值。这种清空下,缓存应该时不时地清除掉没用的项。该项清除工作可以由一个后台线程(可能时 Timer 或者 ScheduledThreadPoolExecutor)来完成,或者也可以在给缓存添加新条目的时候随便进行清理。LinkedHashMap 类利用它的 removeEldestEntry 方法可以很容易地实现后一种方案。对于更为复杂的缓存,必须直接使用 java.lang.ref。

内存泄漏的第三个常见来源是监听器和其他回调。确保回调立即被当作垃圾回收的最佳方法是只保存它们的弱引用(weak reference)。

由于内存泄漏通常不会表现成明显的失败,所以它们可以在一个系统中存在很多年。往往只有通过仔细检查代码,或者借助于 Heap 剖析工作(Heap Profiler)才能发现内存泄漏问题。因此,如果能够在内存泄漏发生之前就知道如何预测此类问题,并阻止它们发生,那是最好不过的了。

fxleyu commented 6 years ago

第 7 条:避免使用终结方法

终结方法(finalizer)通常是不可预测的,也是很危险的,一般情况下是不必要的。使用终结方法会导致行为不稳定、降低性能,以及可移植问题。

终结方法的缺点在于不能保证会被及时执行。从一个对象变得不可到达开始到它的终结方法被执行,所花费的这段时间是任意长的。这意味着,注重时间(time-critical)的任务不应该由终结方法来完成。

及时地执行终结方法正是垃圾回收算法的一个主要功能,这种算法在不同的 JVM 实现会大相径庭。

Java 语言规范不仅不保证终结方法会被及时执行,而且根本就不保证它们会被执行。当一个程序终止的时候,某些已经无法访问的对象上的终结方法却根本没有被执行,这是完全有可能的。结论是: 不应该依赖终结方法来更新重要的持久状态。

还有一点: 使用终结方法有一个非常验证(severe)性能损失。

显式的终止方法通常与 try-finally 结构结合起来使用,以确保及时终止。

终结方法有什么好处呢?