简介
多线程共享变量的情况下,为了保证数据一致性,往往需要对这些变量的访问进行加锁。而锁本身又会带来一些问题和开销。Immutable Object模式使得我们可以在不加锁的情况下,既保证共享变量访问的线程安全,又能避免引入锁可能带来的问题和开销。
多线程环境中,一个对象常常会被多个线程共享,这种情况下,如果存在多个线程并发的修改该对象的状态或者一个线程访问该对象的状态而另外一个线程试图修改该对象的状态,我们不得不做一些同步访问控制以保证数据一致性。而这些同步访问控制,如显式锁(Explicit Lock)和CAS操作,会带来额外的开销和问题,如上下文切换、等待时间、ABA问题。Immutable Object模式的意图是通过使用对外可见的状态不可变的对象
,使得被共享的对象“天生”具有线程安全性,而无需额外的同步访问控制。从而既保证了数据一致性,又避免了同步访问控制所产生的额外开销和问题,也简化了编程。
什么是状态不可变的对象:即对象一经创建,其对外可见的状态就保持不变,通过下面的例子辅助理解。
一个车辆管理系统要对车辆的位置信息进行跟踪,我们可以对车辆的位置信息建立如下所示模型:
public class Location {private double x;private double y;public Location(double x, double y) {this.x = x;this.y = y;}public double getX() {return x;}public double getY() {return y;}public void setXY(double x, double y) {this.x = x;this.y = y;}
}
当系统接收到新的车辆坐标数据时,需要调用Location的setXY方法来更新位置信息。显然,代码中的setXY()是非线程安全的,因为对坐标数据x和y的写操作不是一个原子操作。setXY被调用时,如果在x写入完毕,而y开始写之前有其他线程来读取位置信息,则该线程可能读到一个被追踪车辆根本不曾经过的位置。为了使setXY方法具备线程安全性,我们需要借助锁进行访问控制,虽然被追踪车辆的位置信息总是在变化,但是我们也可以将位置信息建模为状态不可变的对象。
状态不可变的位置信息模型
public final class ImmutableLocation {public final double x;public final double y;public ImmutableLocation(double x, double y) {this.x = x;this.y = y;}
}
使用状态不可变的位置信息模型时,如果车辆的位置发生变动,则更新车辆的位置信息是通过替换表示位置信息的对象(即Location实例)来实现的。因此,所谓状态不可变的对象并非指被建模的现实世界实体的状态不可变,而是我们在建模的时候的一种决策:现实世界实体的状态总是变化的,但我们可以用状态不可变的对象来对这些实体进行建模。
Immutable Object模式的架构
Immutable Object模式将现实世界中状态可变的实体建模为状态不可变的对象,并通过创建不同的状态不可变的对象来反映实体的状态变更。
Immutable Object模式的主要参与者有以下几种。其类图如下
ImmutableObject: 负责存储一组不可变状态,该参与者不对外暴露任何可以修改其状态的方法,主要方法及职责如下:
- getStateX、getStateN:这些getter方法返回其所属ImmutableObject实例所维护的状态相关变量的值,这些变量在对象实例化时通过其构造器的参数获得值。
- getStateSnapshot:返回其所属ImmutableObject实例维护的一组状态的快照。
Manipulator:负责维护ImmutableObject所建模的现实世界实体状态的变更。当相应的现实实体状态变更时,该参与者负责生成新的ImmutableObject实例,以反映新的状态。
- changeStateTo:根据新的状态值生成新的ImmutableObject实例。
不可变对象的使用主要包括以下几种类型:
- 获取单个状态的值;调用不可变对象的相关getter方法即可实现。
- 获取一组状态的快照:不可变对象可以提供一个getter方法,该方法需要对其返回值做防御性复制或者返回一个只读的对象,以避免其状态对外泄漏而被改变。
- 生成新的不可变对象实例:当被建模对象的状态发生变化的时候,创建新的不可变对象实例来反映这种变化。
Immutable Object模式的典型交互场景序列图如下:
第1-4步:客户端代码获取当前ImmutableObject实例的各个状态值。
第5步:客户端代码调用Manipulator的changeStateTo方法来更新应用的状态。
第6-7步:changeStateTo方法创建新的ImmutableObject实例以反映应用的新状态,并返回。
第8-9步:客户端代码获取新的ImmutableObject实例的状态快照。
一个严格意义上的不可变对象要满足以下所有条件
- 类本身使用final修饰,防止其子类改变其定义的行为。
- 所有字段都是final修饰:使用final修饰不仅仅是从语义上说明被修饰字段的引用不可变。更重要的是这个语义在多线程环境下由JMM保证了被修饰字段所引用对象的初始化安全。即final修饰的字段在其他线程可见时,他必定是初始化完成的。相反,非final修饰的字段由于缺少这种保证,可能导致一个线程“看到”一个字段的时候,他还未被初始化完成,从而导致一些不可预料的结果(参考Java类加载过程)。
- 在对象的创建过程中,this关键字没有泄漏给其他类:防止其他类(如该类的内部匿名类)在对象创建过程中修改其状态。
- 任何字段,若其引用了其他状态不可变的对象(如集合、数组等),则这些字段必须是private修饰的。并且这些字段值不能对外暴露。若有相关方法要返回这些字段值,应用进行防御性复制(Defensive Copy)。
Immutable Object模式案例
某彩信网关系统在处理由增值业务提供商下发给手机终端用户的彩信消息时,需要根据彩信接收方号码的前缀选择对应的彩信中心。然后转发消息给选中的彩信中心,由其负责对接电信网络将彩信消息下发给手机终端用户。彩信中心相对于彩信网关系统而言,他是一个独立的部件,二者通过网络进行交互。这个选择彩信中心的过程,我们称之为路由(Routing)。而手机号前缀和彩信中心的这种对应关系,我们称之为路由表。
路由表在软件运维过程中可能发生变化。例如,业务扩容带来的新增彩信中心、为某个号码前缀指定新的彩信中心。虽然路由表在该系统中是由多线程共享的数据,但是这些数据的变化频率并不高。 因此,即使为了保证线程安全,我们也不希望对这些数据的访问进行加锁等并发访问控制,以免产生不必要的开销和问题。这时Immutable Object模式就派上用场了。
使用不可变对象维护路由表,代码清单
package io.github.viscent.mtpattern.ch3.immutableobject;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;/*** 彩信中心路由规则管理器 模式角色:ImmutableObject.ImmutableObject*/
public final class MMSCRouter {// 用volatile修饰,保证多线程环境下该变量的可见性private static volatile MMSCRouter instance = new MMSCRouter();// 维护手机号码前缀到彩信中心之间的映射关系private final Map<String, MMSCInfo> routeMap;public MMSCRouter() {// 将数据库表中的数据加载到内存,存为Mapthis.routeMap = MMSCRouter.retrieveRouteMapFromDB();}private static Map<String, MMSCInfo> retrieveRouteMapFromDB() {Map<String, MMSCInfo> map = new HashMap<String, MMSCInfo>();// 省略其他代码return map;}public static MMSCRouter getInstance() {return instance;}/*** 根据手机号码前缀获取对应的彩信中心信息* * @param msisdnPrefix* 手机号码前缀* @return 彩信中心信息*/public MMSCInfo getMMSC(String msisdnPrefix) {return routeMap.get(msisdnPrefix);}/*** 将当前MMSCRouter的实例更新为指定的新实例* * @param newInstance* 新的MMSCRouter实例*/public static void setInstance(MMSCRouter newInstance) {instance = newInstance;}private static Map<String, MMSCInfo> deepCopy(Map<String, MMSCInfo> m) {Map<String, MMSCInfo> result = new HashMap<String, MMSCInfo>();for (String key : m.keySet()) {result.put(key, new MMSCInfo(m.get(key)));}return result;}public Map<String, MMSCInfo> getRouteMap() {// 做防御性拷贝return Collections.unmodifiableMap(deepCopy(routeMap));}}
而彩信中心的相关数据,如彩信中心设备编号、URL、支持的最大附件尺寸也被建模为一个不可变对象,如下所示:
package io.github.viscent.mtpattern.ch3.immutableobject;/*** 彩信中心信息* * 模式角色:ImmutableObject.ImmutableObject*/
public final class MMSCInfo {/*** 设备编号*/private final String deviceID;/*** 彩信中心URL*/private final String url;/*** 该彩信中心允许的最大附件大小*/private final int maxAttachmentSizeInBytes;public MMSCInfo(String deviceID, String url, int maxAttachmentSizeInBytes) {this.deviceID = deviceID;this.url = url;this.maxAttachmentSizeInBytes = maxAttachmentSizeInBytes;}public MMSCInfo(MMSCInfo prototype) {this.deviceID = prototype.deviceID;this.url = prototype.url;this.maxAttachmentSizeInBytes = prototype.maxAttachmentSizeInBytes;}public String getDeviceID() {return deviceID;}public String getUrl() {return url;}public int getMaxAttachmentSizeInBytes() {return maxAttachmentSizeInBytes;}
}
彩信中心信息变更的频率也同样不高。因此,当彩信网关系统通过网络(Socket连接)被通知到彩信中心信息本身或者路由表变更时,网关系统会重新生成新的MMSCInfo和MMSCRouter来反映这种变更。
package io.github.viscent.mtpattern.ch3.immutableobject;/*** 与运维中心(Operation and Maintenance Center)对接的类<BR>* 模式角色:ImmutableObject.Manipulator*/
public class OMCAgent extends Thread {@Overridepublic void run() {boolean isTableModificationMsg = false;String updatedTableName = null;while (true) {// 省略其他代码/** 从与OMC连接的Socket中读取消息并进行解析, 解析到数据表更新消息后,重置MMSCRouter实例。*/if (isTableModificationMsg) {if ("MMSCInfo".equals(updatedTableName)) {MMSCRouter.setInstance(new MMSCRouter());}}// 省略其他代码}}
}
上述代码会调用MMSCRouter.setInstance方法来替换MMSCRouter的实例为新创建的实例。而新创建的MMSCRouter实例通过其构造器会生成多个新的MMSCInfo的实例。
本案例中,MMSCInfo是一个严格意义上的不可变对象,虽然MMSCRouter对外提供了setInstance方法用于改变其静态字段instance的值,但他仍然可被视作一个等效的不可变对象。这是因为setInstance方法仅仅改变instance变量指向的对象,而instance变量采用volatile修饰保证了其在多线程之间的内存可见性,所以这意味着setInstance对instance变量的改变无须加锁也能保证线程安全。而其他代码在调用MMSCRouter 的相关方法获取路由信息时也无须加锁。
OMCAgent类是一个Manipulator参与者实例,而MMSCInfo和MMSCRouter是一个Immutableobject 参与者实例。通过使用不可变对象,我们既可以应对路由表、彩信中心这些不是非常频繁的变更,又可以使系统中使用路由表的代码免于并发访问控制的开销和问题。
Immutable Object模式评价与实现考量
不可变对象具有天生的线程安全性,多个线程共享一个不可变对象的时候无须使用额外的并发访问控制,使得我们可以避免显式锁等并发访问控制的开销和问题,简化了多线程编程。
Immutable Object模式特别适用于以下场景
- 被建模的对象的状态变化不频繁:这种场景下可以设置一个专门的线程(Manipulator参与者所在的线程)用于在被建模对象状态变化时创建新的不可变对象。而其他线程只是读取不可变对象状态。
- 同时对一组相关的数据进行写操作,因此需要保证原子性:此场景为了保证操作的原子性,通常做法是使用显式锁。但若采用Immutable Object模式,将这一组相关的数据“组合”成一个不可变对象,则对这一组数据的操作就无须加显式锁也能保证原子性。
- 使用某个对象作为安全的HashMap的key:一个对象作为HashMap的key被放入HashMap之后,若该对象状态变化导致了其HashCode 的变化,则会导致后面在用同样的对象作为Key去get的时候无法获取关联的值。由于不可变对象的状态不变,因此其HashCode也不变,使得不可变对象非常适于用作HashMap的key。
Immutable Object模式实现时需要注意以下问题:
- 被建模的对象状态变更频繁:此时也不见得不能使用Immutable Object模式,只是这意味着频繁创建新的不可变对象,因此会增加JVM垃圾回收的负担和CPU消耗,需要综合考虑:被建模对象的规模、代码目标运行环境的JVM内存分配情况、系统对吞吐量和响应性要求。
- 使用等效或者近似的不可变对象:有时创建严格意义上的不可变对象比较难,但是尽量向严格意义上的不可变对象靠拢也有利于发挥不可变对象的好处。
- 防御性复制:如果不可变对象本身包含一些状态需要对外暴露,而相应的字段本身又是可变的(如HashMap)。那么返回这些字段的方法还是需要做防御性复制,以避免外部代码修改了其内部状态。
JDK应用Immutable Object模式实例
在多线程环境中,遍历一个集合对象时,即便被遍历的对象本身是线程安全的,开发人员仍然不得不引入锁,以防止遍历过程中该集合的内部结构被其他线程改变(如删除或者插入一个新的元素)而导致出错。
Vector vector = null;synchronized (vector)(for (int i = 0; i < vector.size ; i++) {.....}
为了保证线程安全而在遍历时对集合对象进行加锁,但这在某些场景下可能并不合适,比如系统中对该集合的插入和删除操作频率远大于便利操作频率。JDK1.5中引入的CopyOnWriteArrayList应用了Immutable Object模式,使得对CopyOnWriteArrayList实例进行遍历时不用加锁也能保证线程安全。他是专门针对遍历操作的频率比添加和删除操作更加频繁的场景设计的(CopyOnWriteArrayList源码解析)。
CopyOnWriteArrayList内部维护了一个array的实例变量用于存储集合的各个元素。在集合中添加一个元素的时候,
CopyOnWriteArrayList会生成一个新的数组,并将集合中所有的元素都复制到新的数组,然后将新的数组的最后一个变量设置为要添加的元素。这个新的数组会直接赋值给array实例变量。这里,array引用的数组就是一个等效的Immutable Object。其内容一旦确定下来就不再被改变,因此,遍历的CopyOnWriteArrayList维护各个元素的时候,直接根据array实例变量生成一个Iterator实例即可,无须加锁。