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

在需要一个对象时,恰当的做法是尽可能重用这个对象而非创建一个功能完全一样的新对象。重用既比较快又比较流行。如果一个对象是不可变的(Item 17),那么它总是可以被重用的。

作为一个绝对不要这么做的极端示例,考虑如下语句:

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

这个语句在每次执行时都会创建一个新的字符串实例,而这些对象创建其实都是不必要的。String构造方法的参数(“bikini“)本身就是个String实例,与构造函数创建的所有对象功能相同。如果这种用法出现在一个循环语句中或一个被频繁调用的方法中,那么可能会创建百万个没必要的String实例。

改进后的版本简单如下:

1
String s = "bikini";

这个版本使用单个的字符串实例,而不是每次执行时创建一个新的。此外,它还确保了运行在同一个虚拟机中并且包含了相同字符串字面值的其他代码能够重用该对象 。如果不变类既提供了静态工厂方法(Item 1),也提供了构造方法,那么你就可以通过前者来避免创建不必要的对象。例如,工厂方法Boolean.valueOf(String)比构造函数Boolean(String)更可取,后者在Java 9中已经被弃用。每一次调用构造方法,一定会创建一个新的对象,然而工厂方法完全没必要这么做,而且实践当中也不会这么做。除了重用不可变对象之外,如果你知道不会修改这个可变对象,那么你也可以重用这个可变对象。

有些对象创建要比其他对象昂贵得多。如果你以后会反复需要这样一个“昂贵的对象”,那么最好将其缓存以供重用。不幸的是,在创建这样的对象时,并不总是显而易见的。假设你想写一个方法来确定字符串是否是有效的罗马数字。这里有个最简单的方法就是,使用正则表达式来做这件事情:

1
2
3
4
5
// 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实例非常昂贵,因为它需要将正则表达式编译成一个有限状态机。

为了改进性能,请在类的初始化过程中手动将正则表达式编译为Pattern实例(它是不可变的),然后将其缓存起来,并在每次调用isRomanNumeral方法时重用这个实例:

1
2
3
4
5
6
7
8
9
// 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微秒,而改进版的方法执行了0.17微秒,比原来快了6.5倍。不仅性能得到了改善,而且可以认为代码也变得更清晰了。使用一个静态final字段来表示原本不可见的Pattern的实例,这样允许我们给这个字段取个名字,这样做比正则表达式本身可读性高多了。

如果包含改进版的isRomanNumeral方法的类被初始化了,但是没有主动调用这个方法,那么字段ROMAN依然会被没必要地初始化。当第一次调用isRomanNumeral方法时,我们可以通过惰性初始化字段的方法来消除上面的没必要地初始化。但是不推荐这样做。因为对于延迟初始化来说,它常常会导致实现变得复杂,并且不能带来很大的性能提升。当一个对象是不可变的时,很明显它可以安全地被重用,但是在其他情况下,它就不那么明显了,甚至是违反直觉的。考虑适配器的情况,也称为视图。所谓适配器,指的是委托给支撑对象的对象,并提供了另外的接口。由于适配器除了支撑对象的状态外,它自身是没有状态的,因此对于给定的对象来说,没必要为其创建多个适配器实例 。

例如,Map接口的keySet方法返回Map对象的Set视图,其中包含映射中的所有键。直觉上,似乎每个对keySet的调用都必须创建一个新的Set实例,不过实际情况却是,每次调用给定Map对象的keySet都只会返回同一个Set实例 。虽然返回的Set实例通常是可变的,但所有返回的对象在功能上是相同的: 当其中一个返回的对象发生更改时,其他的对象也会发生变化,因为它们都是由同一个Map实例支持的。虽然创建keySet视图对象的多个实例基本上是无害的,但它是不必要的,没有任何好处。

另一种创建不必要对象的方法是自动装箱 ,它允许程序员把原生类型和原生类型的包装类混合用,并且会根据需要自动地拆箱和装箱。自动装箱使原生类型和包装类之间界限变得模糊,但是并没有消除原生类型和原生类型的包装类之间的区别。这里存在一些微小的语义上的差别以及稍微有点大的性能上的差别(Item 61)。考虑如下方法,它会计算所有正整型int值的和。为了做到这一点,程序需要使用long运算,因为int不足以容纳所有正整型int值的和:

1
2
3
4
5
6
7
// 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实例(大概每一次longiLong类型的sum相加都会创建一个实例)。在我的机器上,将sum声明由Long改为long则会将运行时间由6.3s减少到0.59s。这个例子很清楚:相对包装类型,优先使用原生类型,并且注意无意识地自动装箱

不应该误解该条款,以为对象创建是非常昂贵的,应该避免。相反地,小对象的创建和回收是廉价的,因为他们的构造函数没有做什么明显的工作,特别是在现代JVM实现中 。创建额外的对象来增强程序的清晰度、简单性或程序的能力通常是一件好事。相反,除非池中的对象非常重量级,否则通过维护自己的对象池来避免对象创建是一个坏主意。真正需要对象池的对象的一个典型示例就是数据库连接。建立连接的成本非常高,因此重用这些对象是有意义的。然而,一般来说,维护自己的对象池会使代码混乱,增加内存占用,并损害性能。现代JVM实现具有高度优化的垃圾收集器,它们在轻量级对象上轻松胜过此类对象池。

与本条款对应的是关于防御式拷贝的Item 50。当前的条款说,“当应该重用一个已经存在的对象时,就不应该创建新的对象”。然而,第50条说,“当您应该创建一个新的对象时,不要重用现有的对象。”注意,在需要进行防御性复制时重用对象的惩罚远远大于不必要地创建重复对象的惩罚。如果不能在需要的地方创建防御复制,可能会导致潜在的bug和安全漏洞;而不必要地创建对象只会影响样式和性能。