第6项:避免创建不需要的对象

2次阅读

共计 3370 个字符,预计需要花费 9 分钟才能阅读完成。

  一般来说,最好能重用对象而不是在每次需要的时候就创建一个相同功能的新对象。重用的方式既快速,有流行。如果对象是不可变 (immutable) 的(第 17 项),那么就能重复使用它。
  作为一个极端的反面例子,考虑下面的语句:
String s = new String(“bikini”); // DON’T DO THIS!
  该语句每次被执行的时候都创建一个新的 String 实例,但是这些对象的创建并不都是必要的。传递给 String 构造器的参数 (“bikini”) 本身就是一个 String 实例,功能方面等同于构造器创建的所有对象。如果这种方法是用在一个循环中,或者是在一个被频繁调用的方法中,就会创建出成千上万的不必要的 String 实例。
  改进后的版本如下:
String s = “bikini”;
  这个版本只用了一个 String 实例,而不是每次执行时都创建一个新的实例。除此之外,它可以保证,对于所有在同一台虚拟机中运行的代码,只要它们包含相同的字符串字面常量,该对象就会被重用 [JLS, 3.10.5]。
  对于同时提供了静态工厂方法 (第 1 项) 和构造器的不可变类,通常可以使用静态工厂方法而不是构造器,这样可以经常避免创建不必要的对象。例如,这个静态工厂方法 Boolean.valueOf(String)总是优先于在 Java 9 中抛弃的构造器 Boolean(String)。构造函数必须在每次调用时创建一个新对象,而工厂方法从不需要这样做,也不会在实践中。除了重用不可变对象之外,如果你知道它们不会被修改,你还可以重用可变对象。
  有些对象的创建比其他对象的代价大,如果你需要反复创建这种代价大的对象,建议将其缓存起来以便重复使用。不幸的是,当你创建这样一个对象时,并不总是很明显。假设你想编写一个方法来确定一个字符串是否是一个有效的罗马数字。使用正则表达式是最简单的方法:
// Performance can be greatly improved!
static boolean isRomanNumeral(String s) {
return s.matches(“^(?=.)M*(C[MD]|D?C{0,3})”
+ “(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$”);
}
  此实现的问题在于它依赖于 String.matches 方法。虽然 String.matches 是检查字符串是否与正则表达式匹配的最简单方法,但它不适合在性能关键的情况下重复使用。问题是它在内部为正则表达式创建了一个 Pattern 实例,并且只使用它一次,之后它就可能会被垃圾回收机制回收。创建 Pattern 实例的代价很大,因为它需要将正则表达式编译为有限状态机(because it requires compiling the regular expression into a finite state machine)。
  为了提高性能,将正则表达式显式编译为 Pattern 实例 (不可变) 作为类初始化的一部分,对其进行缓存,并在每次调用 isRomanNumeral 方法时重用相同的实例:
// Reusing expensive object for improved performance
public class RomanNumerals {
private static final Pattern ROMAN = Pattern.compile(
“^(?=.)M*(C[MD]|D?C{0,3})”
+ “(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$”);
static boolean isRomanNumeral(String s) {
return ROMAN.matcher(s).matches();
}
}
  如果经常调用的话,改进版本的 isRomanNumeral 可以显着提高性能。在我的机器上,原始版本在 8 个字符的输入字符串上需要 1.1μs,而改进版本需要 0.17μs,这是 6.5 倍的速度。不仅提高了性能,而且功能更加明了。为不可见的 Pattern 实例创建一个静态的 final 字段,我们可以给它一个名字,它比正则表达式本身更具有可读性。
  如果初始化包含改进版本的 isRimanNumberal 方法的类时,但是从不调用该方法,则不需要初始化字段 ROMAN。在第一次调用 isRimanNumberal 方法时,可以通过延迟初始化字段 (第 83 项) 来消除使用时未初始化的影响,但不建议这样做。延迟初始化的做法通常都有一个情况,那就是它会把实现复杂化,从而导致无法测试它的性能改进情况。
  当一个对象是不可变的,那么就可以安全地重用它,但是在其他情况下,它并不是那么明显,甚至违反直觉。这时候可以考虑使用适配器 [Gamma95],也称为视图。适配器是委托给支持对象的对象(An adapter is an object that delegates to a backing object),它提供一个备用接口。因为适配器的状态不超过其支持对象的状态,所以不需要为给定对象创建一个给定适配器的多个实例。
  例如,Map 接口的 keySet 方法返回 Map 对象的 Set 视图,该视图由 Map 中的所有键组成。看起来,似乎每次调用 keySet 都必须创建一个新的 Set 实例,但是对给定 Map 对象上的 keySet 的每次调用都可能返回相同的 Set 实例。尽管返回的 Set 实例通常是可变的,但所有返回的对象在功能上都是相同的:当其中一个返回的对象发生更改时,所有其他对象也会发生更改,因为它们都由相同的 Map 实例支持。虽然创建 keySet 视图对象的多个实例在很大程度上是无害的,但不必要这样做,并且这样做没有任何好处。
  创建不必要的对象的另一种方式是自动装箱,它允许程序猿将基本类型和装箱基本类型 (Boxed Primitive Type) 混用,按需自动装箱和拆箱。自动装箱使得基本类型和装箱基本类型之间的差别变得模糊起来,但是并没有完全消除。它们在语义上还有微妙的差别,在性能上也有着比较明显的差别(第 61 项)。请考虑以下方法,该方法计算所有正整数值的总和,为此,程序必须使用 long 类型,因为 int 类型无法容纳所有正整数的总和:
// Hideously slow! Can you spot the object creation?
private static long sum() {
Long sum = 0L;
for (long i = 0; i <= Integer.MAX_VALUE; i++)
sum += i;
return sum;
}
  这段程序算出的答案是正确的,但是比实际情况要更慢一些,只因为错打了一个字符。变量 sum 被声明成 Long 而不是 long,意味着程序构造了大约 2^31 个多余的 Long 实例(大约每次往 Long sum 中增加 long 时构造一个实例)。将 sum 的声明从 Long 改成 long,在我的机器上运行时间从 43 秒减少到了 6 秒。结论很明显:要优先使用基本类型而不是装箱基本类型,要当心无意识的自动装箱。
  不要错误地认为本项所介绍的内容暗示着“创建对象的代价非常昂贵,我们就应该尽可能地避免创建对象”。相反,由于小对象的构造器只做很少量的显示工作,所以,小对象的创建和回收动作是非常廉价的,特别是在现代的 JVM 实现上更是如此。通过创建附加的对象,提升程序的清晰性、简洁性和功能性,这通常是件好事。
  反之,通过维护自己的对象池 (object pool) 来避免创建对象并不是一种好的做法,除非池中的对象是非常重量级的。真正正确使用对象池的经典对象示例就是数据库连接池。建立数据库连接的代价是非常昂贵的,因此重用这些对象非常有意义。但是,通常来说,维护自己的对象池必定会把代码弄得很乱,同时增加内存占用,而且会影响性能。现代的 JVM 实现具有高度优化的垃圾回收器,其性能很容易就会超过轻量级对象池的性能。
  与本项对应的是第 50 项的“保护性拷贝”的内容。该项说得是:你应该重用已经存在的对象,而不是去创建一个新的对象。然而第 50 项说的是:你应该创建一个新的对象而不是重用一个已经存在的对象。注意,在提倡使用保护性拷贝的时候,因重用对象而付出的代价要远远大于因创建重复对象而付出的代价。必要时如果没能实施保护性拷贝,将会导致潜在的错误和安全漏洞,而不必要地创建对象则只会影响程序的风格和性能。

正文完
 0