Skip to content

Latest commit

 

History

History
91 lines (62 loc) · 9.02 KB

第 6 条:避免创建不必要的对象.md

File metadata and controls

91 lines (62 loc) · 9.02 KB

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

一般而言,最好能重用一个对象,而不是在每次需要时创建一个功能相同的新对象。重用方式既快速,也更流行。如果一个对象是不可变的(immutable 第 17 条),那它总是可以被重用。

作为一个极端的反面例子,考虑下面的语句:

String s = new String("bikini");  // 千万不要这么做!

该语句在每次执行时都创建一个新的 String 实例,但是这些对象的创建都是不必要的。传递给 String 构造器的参数("bikini") 本身就是一个 String 实例,功能上与构造器创建的所有对象相同。如果这种用法出现在循环或频繁调用的方法中,就会创建成千上百万个不必要的 String 实例。

改进后的版本如下:

String s = "bikini";

这个版本只使用了一个 String 实例,而不是在每次执行时都创建一个新的实例。此外,它可以保证,对于所有在同一虚拟机中运行的代码,只要它们包含相同的字符串字面量 [JLS, 3.10.5],该对象就会被重用。

对于同时提供了静态工厂方法(第 1 条)和构造器的不可变类,通常可以使用静态工厂方法而不是构造器来避免创建不必要的对象。譬如,工厂方法 Boolean.valueOf(String) 就比构造器 Boolean(String) 更可取,后者在 Java 9 中已被弃用。构造器在每次被调用时都会创建一个新的对象,而工厂方法则从来没有要求这样做,事实上也不会。除了可以重用不可变对象之外,也可以重用那些已知不会被修改的可变对象。

某些对象的创建要比其他对象花费的代价高得多。如果反复需要这样一个“创建代价高昂的对象”(expensive object),那最好将其缓存以供重用。不幸的是,在创建这样的对象时,并不总是显而易见的。假设你要编写一个方法来判定一段字符串是否是有效的罗马数字。下面是使用正则表达式来进行此操作的最简单的方法:

// 性能还可以被大大提高!
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 是检查字符串是否与正则表达式匹配的最简单方法,但是它不适合在性能关键的情况下重复使用。**问题在于,String.matches 方法会在内部为正则表达式创建一个 Pattern 实例,但只使用它一次,之后它就具备了被垃圾回收的资格。创建一个 Pattern 实例代价高昂,因为需要将正则表达式编译成一个有限状态机。

为了提高性能,可以显式地将正则表达式编译为一个 Pattern 实例(它是不可变的),将其作为类初始化的一部分,对它进行缓存,并在每次调用 isRomanNumeral 方法时重用同一个实例:

// 重用创建代价高昂的对象以提高性能
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 字段允许我们给它一个名称,而这个名称要比正则表达式本身可读性强得多。

如果包含改进版本的 isRomanNumeral 方法的类被初始化,但该方法从未被调用,那么 ROMAN 字段将不必要地被初始化。可以通过在第一次调用 isRomanNumeral 方法时,延迟初始化(lazily initializing)字段(第 83 条)的方式来避免不必要地初始化,但是不建议这样做。延迟初始化通常会出现这样的情况,它会使实现复杂化,并且不会带来可观的性能改进(第 67 条)。

当一个对象是不可变的,显然它可以被安全地重用,但是在其他情况下,就不那么明显,甚至违反直觉。考虑适配器(adapter),也称为视图(view)的情况 [Gamma95]。适配器是指提供了可替代的接口,来代表支撑对象(backing object)的对象。由于适配器除了它的支撑对象之外没有其他状态,所以不需要为给定对象创建给定适配器的多个实例。

例如,Map 接口的 keySet 方法会返回 Map 对象的一个 Set 视图,其中包含该 map 中所有的 key。初看起来,好像每次对 keySet 方法的调用都要创建一个新的 Set 实例,但事实上,对于一个给定的 Map 对象,每次对 keySet 方法的调用都可能返回相同的 Set 实例。虽然返回的 Set 实例通常是可变的,但是所有返回的对象在功能上是等同的:当其中一个返回对象发生变化时,其他的对象也会发生变化,因为它们都由相同的 Map 实例所支撑。虽然创建多个 keySet 视图对象的实例在很大程度上没有害处,但这样做既没有必要,也没有好处。

另一种会创建不必要对象的方法是自动装箱(autoboxing),它允许程序员将基本类型和基本类型的包装类型(boxed primitive type)混用,按需进行自动装箱和拆箱。自动装箱机制使得基本类型和包装类型之间的差别变得模糊起来,但并没有完全消除。它们在语义上还有微妙的差异,在性能上也有比较明显的差别(第 61 条)。考虑下面的方法,它要计算所有正整数的和。为了做到这一点,程序必须使用 long 进行运算,因为 int 没有大到足以容纳所有 int 正值的总和:

// 出奇的慢!你能发现对象的创建吗?
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 i 添加到 Long sum 中时构造一个)。在我的机器上,将 sum 的声明从 Long 改为 long 会将运行时间从 6.3 秒减少到 0.59 秒。结论很明显:要优先使用基本类型而不是基本类型的包装类型,当心无意识的自动装箱。

不要误解为本条目所介绍的内容暗示着“对象的创建代价高昂,应该尽可能避免”。相反,小对象的构造器只做少量的显式工作,因而小对象的创建和回收成本是非常廉价的,特别在现代的 JVM 实现上更是如此。通过创建附加的对象来提升程序的清晰度、简洁性或功能,这通常是件好事。

反之,通过维护自己的对象池(object pool)来避免创建对象不是个明智的做法,除非池中的对象是非常重量级的。正确使用对象池的典型例子是数据库连接。建立连接的成本非常高,因此重用这些对象是有意义的。但是,一般来说,维护自己的对象池会把代码弄得很乱,增加内存占用(memory footprint),并会损害性能。现代的 JVM 实现具有高度优化的垃圾回收器,其性能很容易胜过轻量级对象池的性能。

与本条目对应的是第 50 条中有关保护性拷贝的内容(defensive copying)。本条目提及“当你应该重用现有的对象时,不要创建新的对象”,而第 50 条则说“当你应该创建新对象的时候,不要重用现有的对象”。注意,在提倡使用保护性拷贝的时候,因重用对象的付出的代价要远远大于不必要地创建重复对象所付出的代价。未能在需要的地方实施保护性拷贝,将会导致潜在的错误和安全漏洞;而不必要地创建对象只会影响程序的风格和性能。

[Gamma95]
Gamma, Erich, Richard Helm, Ralph Johnson, and John Vlissides. 1995.
Design Patterns: Elements of Reusable Object-Oriented Software.
Reading, MA: Addison-Wesley. ISBN: 0201633612.

翻译:Angus

校对:Inno