通常我们重用一个单独对象而不是创建一个新的有着相同功能的对象。重用可以更快并更优雅。一个对象总是可以被重用,只要它是不可变的(item17)。
作为一个极端的例子,思考一下以下的声明:
这个声明在每次执行时创建了一个新的String类型的实例,但是这些实力的创建是没有必要的。String的构造方法(“bikini”)本身就是一个实例,在功能上与所有用构造方法创建的实例完全相同。如果这种用法发生在一个循环中或一个频繁调用的方法中,数以百万的String实例会被没有必要地创建出来。
改进的版本很简单,如下:
这个版本使用一个单一的String实例,而不是在每次执行的时候都创建一个新的。此外,能够保证该对象将被在同一虚拟机内的任意代码重复使用当他们包含相同的字符时。
通常你可以利用静态工厂方法(static factory method)(item1) 而不是提供两者的不可变类的构造方法来避免创建没有必要的对象。比如,工厂方法Boolean.valueOf(String)比构造方法Boolean(String)要好,后者在Java9中已被弃用。
实际上,构造方法在每次调用的时候都必须创建一个新的对象,但是工厂方法从来都不需要这样,以后也不需要。除了重用不可变对象以外,你也可以重用可变对象,只要你知道他们将不会被修改。
一些对象的创建的开销很大。如果你反复需要这样的“高开销对象”,建议将其缓存以便重复使用。不幸的是,当你创建这样的对象并不总是明显。假设你想要编写一个方法来确定一个字符串是否是一个有效的罗马数字,最简单的方法就是使用正则表达式,如下:
这样的实现的问题在于这个方法依赖于String.matches方法。虽然String.matches是最简单的方式来检查一个字符串是否匹配一个正则表达式,但是它不适合在高性能需要的情况下重复使用。因为这个方法为了正则表达式在内在创建了一个Pattern的实例并且只使用一次,之后就被垃圾回收器回收了。创建一个Pattern实例是高开销的,因为它需要将正则表达式编译为有限状态机。
为了提高性能,显式编译正则表达式为Pattern实例(不可变)作为类初始化的一部分,对其进行缓存,每次调用isRomanNumeral方法时重用相同实例。
isRomanNumeral的升级版在频繁调用的情况下显著地提升了性能。在我的机器上,原始版本在输入8个字符的字符串后消耗了1.1 µs,升级版消耗了 0.17µs,是6.5倍的提升。不仅是性能提高了,而且可以认为这个方法更清晰了。使一个静态final字段并且外部不可见的Pattern实例拥有了给与了命名,它比正则表达式本身更有可读性。
如果类初始化包含了isRomanNumeral方法的改进版,但是这个方法从未被调用,ROMAN初始化得没有必要。通过懒初始化字段(lazily initializing)(item83)在isRomanNueral方法第一次调用时建立初始化是可行的,但是这是不推荐的。与延迟初始化通常的情况一样,它会使实现复杂化,而没有可测量的性能提升 (item67)。
当一个对象是不可变的,明显它可以被安全地复用。但是在其他情况下不那么明显,甚至是反常的。考虑适配器(adapters)的情况,它也叫做views。一个适配器是一个委托背后的对象的对象,提供了一个可替代的接口。因为一个适配器没有状态多于它背后的对象的情况,所以没有必要为给定的对象创建多于一个给定的适配器实例。
举个例子,Map接口的keySet方法返回了这个Map对象的一个Set视图,这个视图包含了这个map的所有key。你可能会天真地认为每次调用keySet方法都将不得不创建一个新的Set实例,但是每次调用keySet方法时只会返回这个Map对象的相同Set实例。虽然返回的Set实例一般来说是可变的,但是所有返回对象在功能上都是相同的:当返回对象中的一个改变了,所有其他对象也改变了,因为他们都基于相同的Map实例。虽然创建大量keySet实例视图对象很大程度上是无害的,但是这么做说没有必要的,也没有更好好处。
另一个创建了不必要对象的方式是自动装箱,这允许程序员混合原始类型和原始类型的包装类,自动装箱和拆箱是有必要的。自动装箱模糊但并未擦除了原始类型和包装类的区别。他们有着微妙的语义区别但是没有那么微妙的性能差异(item61)。考虑到下面的方法,计算所有int类型的正值总和。为了做到这个方法的功能,程序不得不使用long类型计算,因为一个int类型不足以保有所有int正值的总和:
这个程序得到了正确的回答,但是它比它本该有的效率要更慢,仅仅是因为一个字符的印刷错误。变量sum被声明为Long而不是long,这意味着程序构造了 2^31个没有必要的Long实例(大概每一次long i被add到Long sum)。改变sum的声明从Long到long,在我的机器上运行时间从6.3秒降低到了0.59秒。这个教训很明显:使用原始类型而不是装箱类型,并且要关注于无意识的自动装箱。
这个条目不应该被误解为创建对象开销很大而应该避免使用。相反,创建和回收小对象时,其构造方法做一点明确工作时的开销很小,特别在现代JVM的实现上。创建额外的对象来提高可读性,更简单,程序更有力量,这通常是个好办法。
相反,除非池中的对象非常重量级,否则通过维护自己的对象池来避免创建对象是一个坏主意。一个经典的例子举例一个对象的确需要对象池就是数据库连接。它建立连接的开销相当大,所以重用这些对象是有意义的。一般来说,维护自己的对象池使得代码混乱,增加内存占用,并损害性能。现代JVM的实现高度优化了垃圾回收机制,在轻量级对象上轻松胜过此类对象池。
这个条目的对比是item50 防御性复制。本条目描述的是:“不要创建一个新的对象当你重用一个已存在的对象的时候”。条目50说的是,“当你创建一个新对象时,不要重用已存在的对象”。请注意,调用防御性复制时重用对象的代价远远大于不必要地创建重复对象的代价。未在必要时制作防御性副本会导致潜在的bug和安全漏洞,而创建不必要的对象仅仅影响样式和性能。
本文写于2018.11.27,历时44天