本文由本人首次发布在infoq中文站上:http://www.infoq.com/cn/articles/java-multithreaded-programming-mode-immutable-object。转载请注明作者: 黄文海 出处:http://viscent.iteye.com。
?
多线程共享变量的情况下,为了保证数据一致性,往往需要对这些变量的访问进行加锁。而锁本身又会带来一些问题和开销。Immutable Object模式使得我们可以在不使用锁的情况下,既保证共享变量访问的线程安全,又能避免引入锁可能带来的问题和开销。
多线程环境中,一个对象常常会被多个线程共享。这种情况下,如果存在多个线程并发地修改该对象的状态或者一个线程读取该对象的状态而另外一个线程试图修改该对象的状态,我们不得不做一些同步访问控制以保证数据一致性。而这些同步访问控制,如显式锁和CAS操作,会带来额外的开销和问题,如上下文切换、等待时间和ABA问题等。Immutable Object模式的意图是通过使用对外可见的状态不可变的对象(即Immutable Object),使得被共享对象“天生”具有线程安全性,而无需额外的同步访问控制。从而既保证了数据一致性,又避免了同步访问控制所产生的额外开销和问题,也简化了编程。
所谓状态不可变的对象,即对象一经创建其对外可见的状态就保持不变,例如Java中的String和Integer。这点固然容易理解,但这还不足以指导我们在实际工作中运用Immutable Object模式。下面我们看一个典型应用场景,这不仅有助于我们理解它,也有助于在实际的环境中运用它。
?一个车辆管理系统要对车辆的位置信息进行跟踪,我们可以对车辆的位置信息建立如清单1所示的模型。
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方法来更新位置信息。显然,清单1中setXY是非线程安全的,因为对坐标数据x和y的写操作不是一个原子操作。setXY被调用时,如果在x写入完毕,而y开始写之前有其它线程来读取位置信息,则该线程可能读到一个被追踪车辆根本不曾经过的位置。为了使setXY方法具备线程安全性,我们需要借助锁进行访问控制。虽然被追踪车辆的位置信息总是在变化,但是我们也可以将位置信息建模为状态不可变的对象,如清单2所示。
public final class Location { public final double x; public final double y; public Location(double x, double y) { this.x = x; this.y = y; } }
使用状态不可变的位置信息模型时,如果车辆的位置发生变动,则更新车辆的位置信息是通过替换整个表示位置信息的对象(即Location实例)来实现的。如清单3所示。
public class VehicleTracker { private Map<String, Location> locMap = new ConcurrentHashMap<string, location="" style="margin: 0px; border: 0px; padding: 0px;">(); public void updateLocation(String vehicleId, Location newLocation) { locMap.put(vehicleId, newLocation); } }
因此,所谓状态不可变的对象并非指被建模的现实世界实体的状态不可变,而是我们在建模的时候的一种决策:现实世界实体的状态总是在变化的,但我们可以用状态不可变的对象来对这些实体进行建模。
Immutable Object模式的主要参与者有以下几种。其类图如图1所示。
图 1. Immutable Object模式的类图
getStateX,getStateN:这些getter方法返回该类所维护的状态相关变量的值。这些变量在对象实例化时通过其构造器的参数获得值。
getStateSnapshot:返回该类维护的一组状态的快照。
changeStateTo:根据新的状态值生成新的ImmutableClass的实例。
不可变对象的使用主要包括以下几种类型:
获取单个状态的值:调用不可变对象的相关getter方法即可实现。
获取一组状态的快照:不可变对象可以提供一个getter方法,该方法需要对其返回值做防御性拷贝或者返回一个只读的对象,以避免其状态对外泄露而被改变。
生成新的不可变对象实例:当被建模对象的状态发生变化的时候,创建新的不可变对象实例来反映这种变化。
Immutable Object模式的典型交互场景如图2所示:
图 2. Immutable Object模式的序列图
1~4、客户端代码获取ImmutableClass的各个状态值。
5、客户端代码调用Manipulator的changeStateTo方法来更新应用的状态。
6、Manipulator创建新的ImmutableClass实例以反映应用的新状态。
7~9、客户端代码获取新的ImmutableClass实例的状态快照。
一个严格意义上不可变对象要满足以下所有条件:
1)?类本身使用final修饰:防止其子类改变其定义的行为;
2)?所有字段都是用final修饰的:使用final修饰不仅仅是从语义上说明被修饰字段的引用不可改变。更重要的是这个语义在多线程环境下由JMM(Java Memory Model)保证了被修饰字段的所引用对象的初始化安全,即final修饰的字段在其它线程可见时,它必定是初始化完成的。相反,非final修饰的字段由于缺少这种保证,可能导致一个线程“看到”一个字段的时候,它还未被初始化完成,从而可能导致一些不可预料的结果。
3)?在对象的创建过程中,this关键字没有泄露给其它类:防止其它类(如该类的匿名内部类)在对象创建过程中修改其状态。
4) 任何字段,若其引用了其它状态可变的对象(如集合、数组等),则这些字段必须是private修饰的,并且这些字段值不能对外暴露。若有相关方法要返回这些字段值,应该进行防御性拷贝(Defensive Copy)。
某彩信网关系统在处理由增值业务提供商(VASP,Value-Added Service Provider)下发给手机终端用户的彩信消息时,需要根据彩信接收方号码的前缀(如1381234)选择对应的彩信中心(MMSC,Multimedia Messaging Service Center),然后转发消息给选中的彩信中心,由其负责对接电信网络将彩信消息下发给手机终端用户。彩信中心相对于彩信网关系统而言,它是一个独立的部件,二者通过网络进行交互。这个选择彩信中心的过程,我们称之为路由(Routing)。而手机号前缀和彩信中心的这种对应关系,被称为路由表。路由表在软件运维过程中可能发生变化。例如,业务扩容带来的新增彩信中心、为某个号码前缀指定新的彩信中心等。虽然路由表在该系统中是由多线程共享的数据,但是这些数据的变化频率并不高。因此,即使是为了保证线程安全,我们也不希望对这些数据的访问进行加锁等并发访问控制,以免产生不必要的开销和问题。这时,Immutable Object模式就派上用场了。
维护路由表可以被建模为一个不可变对象,如清单4所示。
清单 4. 使用不可变对象维护路由表
public final class MMSCRouter { // 用volatile修饰,保证多线程环境下该变量的可见性 private static volatile MMSCRouter instance = new MMSCRouter(); //维护手机号码前缀到彩信中心之间的映射关系 private final Map<String, MMSCInfo> routeMap; public MMSCRouter() { // 将数据库表中的数据加载到内存,存为Map this.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、支持的最大附件尺寸也被建模为一个不可变对象。如清单5所示。
清单 5. 使用不可变对象表示彩信中心信息
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来反映这种变更。如清单6所示。
清单 6. 处理彩信中心、路由表的变更
/** * 与运维中心(Operation and Maintenance Center)对接的类 * */ public class OMCAgent extends Thread{ @Override public 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的相关方法获取路由信息时也无需加锁。
从图1的类图上看,OMCAgent类(见清单6)是一个Manipulator参与者实例,而MMSCInfo、MMSCRouter是一个ImmutableClass参与者实例。通过使用不可变对象,我们既可以应对路由表、彩信中心这些不是非常频繁的变更,又可以使系统中使用路由表的代码免于并发访问控制的开销和问题。
不可变对象具有天生的线程安全性,多个线程共享一个不可变对象的时候无需使用额外的并发访问控制,这使得我们可以避免显式锁(Explicit Lock)等并发访问控制的开销和问题,简化了多线程编程。
Immutable Object模式特别适用于以下场景。
被建模对象的状态变化不频繁:正如本文案例所展示的,这种场景下可以设置一个专门的线程(Manipulator参与者所在的线程)用于在被建模对象状态变化时创建新的不可变对象。而其它线程则只是读取不可变对象的状态。此场景下的一个小技巧是Manipulator对不可变对象的引用采用volatile关键字修饰,既可以避免使用显式锁(如synchronized),又可以保证多线程间的内存可见性。
同时对一组相关的数据进行写操作,因此需要保证原子性:此场景为了保证操作的原子性,通常的做法是使用显式锁。但若采用Immutable Object模式,将这一组相关的数据“组合”成一个不可变对象,则对这一组数据的操作就可以无需加显式锁也能保证原子性,既简化了编程,又提高了代码运行效率。本文开头所举的车辆位置跟踪的例子正是这种场景。
使用某个对象作为安全的HashMap的Key:我们知道,一个对象作为HashMap的Key被“放入”HashMap之后,若该对象状态变化导致了其Hash Code的变化,则会导致后面在用同样的对象作为Key去get的时候无法获取关联的值,尽管该HashMap中的确存在以该对象为Key的条目。相反,由于不可变对象的状态不变,因此其Hash Code也不变。这使得不可变对象非常适于用作HashMap的Key。
Immutable Object模式实现时需要注意以下几个问题:
被建模对象的状态变更比较频繁:此时也不见得不能使用Immutable Object模式。只是这意味着频繁创建新的不可变对象,因此会增加GC(Garbage Collection)的负担和CPU消耗,我们需要综合考虑:被建模对象的规模、代码目标运行环境的JVM内存分配情况、系统对吞吐率和响应性的要求。若这几个方面因素综合考虑都能满足要求,那么使用不可变对象建模也未尝不可。
使用等效或者近似的不可变对象:有时创建严格意义上的不可变对象比较难,但是尽量向严格意义上的不可变对象靠拢也有利于发挥不可变对象的好处。
防御性拷贝:如果不可变对象本身包含一些状态需要对外暴露,而相应的字段本身又是可变的(如HashMap),那么在返回这些字段的方法还是需要做防御性拷贝,以避免外部代码修改了其内部状态。正如清单4的代码中的getRouteMap方法所展示的那样。
本文介绍了Immutable Object模式的意图及架构。并结合笔者工作经历提供了一个实际的案例用于展示使用该模式的典型场景,在此基础上对该模式进行了评价并分享在实际运用该模式时需要注意的事项。
黄文海,有多年敏捷项目管理经验和丰富的技术指导经验。关注敏捷开发、Java多线程编程和Web开发。在InfoQ中文站和IBM DeveloperWorks上发表过多篇文章。其博客:http://viscent.iteye.com/
感谢张龙对本文的审校。