3.4 Immutable Object模式的评价与实现考量
不可变对象具有天生的线程安全性,多个线程共享一个不可变对象的时候无须使用额外的并发访问控制,这使得我们可以避免显式锁等并发访问控制的开销和问题,简化了多线程编程。
3.4.1 适用场景
Immutable Object模式特别适用于以下场景。
• 被建模对象的状态变化不频繁:正如本章案例所展示的,在这种场景下可以设置一个专门的线程(Manipulator参与者所在的线程),用于在被建模对象状态发生变化时创建新的不可变对象,而其他线程则只用于读取不可变对象的状态。此场景下的一个小技巧是,Manipulator对不可变对象的引用采用volatile关键字修饰,这样既可以避免使用显式锁(如synchronized),也可以保证多线程间的内存可见性。
• 同时对一组相关的数据进行写操作,因此需要保证操作的原子性:为了保证操作的原子性,通常的做法是使用显式锁。但若采用Immutable Object模式将这一组相关的数据“组合”成一个不可变对象,则对这一组数据的操作无须加显式锁也能保证原子性,这既简化了编程,也提高了代码运行效率。本章开头所举的车辆位置跟踪的例子正是这种场景。
• 使用某个对象作为安全的HashMap的Key:我们知道,在一个对象作为HashMap的Key被“放入”HashMap之后,若该对象状态变化导致了其Hash Code变化,则会进一步导致后面在用同样的对象作为Key的时候无法获取关联的值,哪怕该HashMap中的确存在以该对象为Key的条目。相反,由于不可变对象的状态不变,因此其Hash Code也不变,这使得不可变对象非常适合做HashMap的Key。
实现Immutable Object模式时需要注意以下几个问题。
3.4.2 对垃圾回收(Garbage Collection)的影响
使用不可变对象能够对垃圾回收效率产生影响,其影响既有消极的一面也有积极的一面。由于基于不可变对象设计的系统其状态的变更是通过创建新的不可变对象实例来实现的,因此当系统的状态频繁变更或者不可变对象所占用的内存空间比较大时,不可变对象的不断创建会增加垃圾回收的负担。但是,使用不可变对象也可能有利于降低垃圾回收的开销。这是因为创建不可变对象往往导致堆空间年轻代(Young Generation)中的对象(新创建的不可变对象实例)引用年老代(Old Generation)中的对象。而这种对象引用方式相比于使用状态可变的对象所导致的年老代对象引用年轻代对象的引用方式更加有利于降低垃圾回收的开销:当修改一个状态可变对象的引用型实例变量值的时候,如果这个对象已经位于年老代中,那么在垃圾回收器进行下一轮次要回收(Minor Collection)的时候,年老代中包含这个对象的卡片(Card,年老代中存储对象的存储单位,一个卡片的大小为512B)中的所有对象都必须被扫描一遍,以确定年老代中是否有对象对待回收的对象持有引用。因此,年老代对象持有对年轻代对象的引用会导致次要回收的开销增加。实践上,我们也可以采用某些技术来减少不可变对象(尤其是比较大的不可变对象)所占用的内存空间。比如,在创建不可变对象的时候,尽可能地让新的不可变对象与老的不可变对象共享部分内存空间,从而减少了占用的内存空间。综上所述,如果被建模对象的状态变更比较频繁,那么也不见得不能使用Immutable Object模式,我们需要综合考虑被建模对象的规模、代码目标运行环境的Java虚拟机内存分配情况、系统对吞吐率和响应性的要求等。若这几个方面的因素都能满足要求,那么使用不可变对象建模也未尝不可。
3.4.3 使用等效或者近似的不可变对象
有时创建严格意义上的不可变对象比较难,但是尽量向严格意义上的不可变对象靠拢也有利于发挥不可变对象的好处。
3.4.4 防御性复制
如果不可变对象本身包含一些状态需要对外暴露的字段,而这些字段本身又是可变的(如HashMap),那么返回这些字段的方法还需要做防御性复制,以避免外部代码修改了其内部状态。正如清单3-4的代码中的getRouteMap方法所展示的。