Effective-Java-5避免创建不必要的对象

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

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

1
String s = new String("stringgette"); // DON'T DO THIS!

该语句每次执行的时候都创建一个新的String实例,但是这些创建对象的动作全都是不必要的。传递给String构造器的参数”stringgette”本身就是个String实例,功能方面等同于构造器创建的所有对象。如果这种用法是在一个循环中,或者是在一个被频繁调用的方法中,就会创建出成千上万不不要的String实例。

改进后的版本如下:

1
String s = "stringgette";

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

对于同时提供了静态工厂方法(见第1条)和构造器的不可变类,通常可以使用静态工厂方法而不是构造器,以避免创建不必要的对象。例如,静态工厂方法Boolean.valueOf(String)几乎总是优先于构造器Boolean(String)。构造器在每次调用的时候都会创建一个新的对象,而静态工厂方法则从来不要求这样做,实际上也不会这样做。

除了重用不可变对象之外,也可以重用那些一直不会被修改的可变对象。下面是一个比较微妙,也比较常见的反面例子,其中涉及可变的Date对象,它们的值一旦计算出来之后就不在变化。这个类建立了一个模型,其中有一个人,并有一个isBabyBoomer方法,用来检验这个人是否为一个”baby boomer”(婴儿潮时期出生的婴儿),也就是检验这个人是否出生于1946-1964年。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// DON'T DO THIS!
public class Person {
private final Date birthDate;

public Person(Date birthDate) {
this.birthDate = birthDate;
}

// Other fields, methods and constructor omitted
public boolean isBabyBoomer() {
// Unnecessary allocation of expensive object
Calendar gmtCal = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
gmtCal.set(1946, Calendar.JANUARY, 1, 0, 0, 0);
Date boomStart = gmtCal.getTime();
gmtCal.set(1964, Calendar.JANUARY, 1, 0, 0, 0);
Date boomEnd = gmtCal.getTime();
return birthDate.compareTo(boomStart) >= 0 && birthDate.compareTo(boomEnd) < 0;
}
}

isBabyBoomer 每次别调用的时候,都会新建一个Calendar ,一个 TimeZone 和两个 Date 实例,这是不必要的。下面的版本用一个静态的初始化器(initializer)

,避免了这种效率低下的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Person {
private final Date birthDate;
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(1964, Calendar.JANUARY, 1, 0, 0, 0);
BOOM_END = gmtCal.getTime();
}

public Person(Date birthDate) {
this.birthDate = birthDate;
}

// Other fields, methods and constructor omitted

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

改进后的Person类只在初始化的时候创建Calender,TimeZone和Date实例一次,而不是在每次调用isBabyBoomer的时候都创建这些实例。如果isBabyBoomer方法被频繁的调用,这种方法将会显著地提高性能 (作者: 在我的机器上,每调用一千万次,原来的版本需要32 000ms,而改进后的版本只需 130ms)。除了提高性能之外,代码的含义也更加清晰了。把boomStart和boomEnd从局部变量改为final静态域,这些如期显然是被作为常量对待,从而使得代码更易于理解。但是,这种优化带来的效果并不总是那么明显,因为Calender实例创建代价特别昂贵。

如果改进后的Person类被初始化了,isBabyBoomer方法却永远不会被调用,那就没有必要初始化BOOM_START和BOOM_END域。通过延迟初始化(lazily initializing)(见第71条),即把对这些域的初始化延迟到isBabyBoomer方法第一次被调用的时候进行,则有可能消除这些不必要的初始化工作,但是不建议这样做。正如延迟初始化中常见的情况一样,这样做会使方法的实现更加复杂,从而无法将性能显著提高到超过已经达到的水平(见55条)。

在本条目前面的例子中,所讨论的对象显然都是能够被重用的,因为它们被初始化之后不会再改变。其他的情形则并不总是这么明显了。考虑适配器(adapter)的情形 [Gamma95, p.139],有时也叫做视图 (view)。适配器是指这样一个对象:它把功能委托给一个后备对象 (backing object),从而为后备对象提供一个可以替代的接口。由于适配器除了后备对象之外,没有其他的状态信息,所以针对某个给定对象的特定适配器而言,它不需要创建多个适配器实例。

例如,Map接口的keySet方法返回该Map对象的Set视图,其中包含该Map中所有的键 (key)。看起来好像每次调用keySet都应该创建一个新的Set实例,但是,对于一个给定的Map对象,实际上每次调用keySet都返回同样的Set实例。虽然返回的Set实例一般是可改变的,但是所有返回的对象在功能上是等同的:当其中一个返回对象发生变化时,所有其他的返回对象也要发生变化,因为它们是由同一个Map实例支撑的。虽然创建KeySet视图对象的多个实例并无害处,但也无必要。

在Java 1.5发行版本中,有一种创建多余对象的新方法,称作自动装箱 (auto boxing),它允许程序员将基本类型和装箱基本类型 (Boxed Primitive Type) 混用,按需要自动装箱和拆箱。自动装箱使得基本类型和装箱基本类型之间的区别变得模糊起来,但是并没有完全消除。它们在语义上还有着微妙的差别,在性能上也有着比较明显的差别 (见第49条)。考虑下面的程序,他计算所有int正值的总和。为此,程序必须使用long,因为int不够大,无法容纳所有int正值的总和:

1
2
3
4
5
6
7
8
// Hideously slow program! Can you spot the object creation?
public static void main(String[] args) {
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
System.out.println(sum);
}

这段程序是正确的,但是要比预期的情况要更慢,因为这里打错了字符大写了L。变量sum 由 long类型变为了Long类型,意味着程序构造了231个多余的Long实例。将sum的声明从Long改为long (作者: 在我的机器上运行时间从43秒降低到了6.8秒)。结论很明显:要优先使用基本类型而不是装箱基本类型,要当心无意识的自动装箱。

以下是我自己尝试的代码 时间分别是 50 6598 ms 和 50 827 ms 性能差了约10倍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private static long TIME_SPEND = System.currentTimeMillis();
private static long TIME_SPEND_TOTAL = 0;

public static void main(String[] args) {
// Avoid unnecessary boxing and unboxing.
// Long sum = 0L; // TEST_COUNT = 50 6598 ms
long sum = 0L; // TEST_COUNT = 50 827 ms
int TEST_COUNT = 10;
for (int j = 0; j < TEST_COUNT; j++) {
sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
TIME_SPEND_TOTAL += System.currentTimeMillis() - TIME_SPEND;
System.out.println("No." + (j + 1) + " : " + (System.currentTimeMillis() - TIME_SPEND) + " ms " +sum);
TIME_SPEND = System.currentTimeMillis();
}
System.out.println(TIME_SPEND_TOTAL / TEST_COUNT + " ms");
}

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

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

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