Java 多线程编程模式实战指南(二):Immutable Object 模式

阅读数:11045 2015 年 1 月 16 日 07:03

多线程共享变量的情况下,为了保证数据一致性,往往需要对这些变量的访问进行加锁。而锁本身又会带来一些问题和开销。Immutable Object 模式使得我们可以在不使用锁的情况下,既保证共享变量访问的线程安全,又能避免引入锁可能带来的问题和开销。

Immutable Object 模式简介

多线程环境中,一个对象常常会被多个线程共享。这种情况下,如果存在多个线程并发地修改该对象的状态或者一个线程读取该对象的状态而另外一个线程试图修改该对象的状态,我们不得不做一些同步访问控制以保证数据一致性。而这些同步访问控制,如显式锁和 CAS 操作,会带来额外的开销和问题,如上下文切换、等待时间和 ABA 问题等。Immutable Object 模式的意图是通过使用对外可见的状态不可变的对象(即 Immutable Object),使得被共享对象“天生”具有线程安全性,而无需额外的同步访问控制。从而既保证了数据一致性,又避免了同步访问控制所产生的额外开销和问题,也简化了编程。

所谓状态不可变的对象,即对象一经创建其对外可见的状态就保持不变,例如 Java 中的 String 和 Integer。这点固然容易理解,但这还不足以指导我们在实际工作中运用 Immutable Object 模式。下面我们看一个典型应用场景,这不仅有助于我们理解它,也有助于在实际的环境中运用它。

一个车辆管理系统要对车辆的位置信息进行跟踪,我们可以对车辆的位置信息建立如清单 1 所示的模型。

清单 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 所示。

清单 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 所示。

清单 3. 在使用不可变对象的情况下更新车辆的位置信息

public class VehicleTracker {

	private Map<String, Location> locMap 
		= new ConcurrentHashMap();

	public void updateLocation(String vehicleId, Location newLocation) {
		locMap.put(vehicleId, newLocation);
	}

}
,>

因此,所谓状态不可变的对象并非指被建模的现实世界实体的状态不可变,而是我们在建模的时候的一种决策:现实世界实体的状态总是在变化的,但我们可以用状态不可变的对象来对这些实体进行建模。

Immutable Object 模式的架构

Immutable Object 模式的主要参与者有以下几种。其类图如图 1 所示。

图 1. Immutable Object 模式的类图

  • ImmutableClass:负责存储一组不可变状态的类。该类不对外暴露任何可以修改其状态的方法,其主要方法及职责如下:

    getStateXgetStateN:这些 getter 方法返回该类所维护的状态相关变量的值。这些变量在对象实例化时通过其构造器的参数获得值。

    getStateSnapshot:返回该类维护的一组状态的快照。

  • Manipulator:负责维护 ImmutableClass 所建模的现实世界实体状态的变更。当相应的现实世界实体状态变更时,该类负责生成新的 ImmutableClass 的实例,以反映新的状态。

    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)。

Immutable Object 模式实战案例

某彩信网关系统在处理由增值业务提供商(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 参与者实例。通过使用不可变对象,我们既可以应对路由表、彩信中心这些不是非常频繁的变更,又可以使系统中使用路由表的代码免于并发访问控制的开销和问题。

Immutable Object 模式的评价与实现考量

不可变对象具有天生的线程安全性,多个线程共享一个不可变对象的时候无需使用额外的并发访问控制,这使得我们可以避免显式锁(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 模式的意图及架构。并结合笔者工作经历提供了一个实际的案例用于展示使用该模式的典型场景,在此基础上对该模式进行了评价并分享在实际运用该模式时需要注意的事项。

参考资源

  • 本文的源代码在线阅读:https://github.com/Viscent/JavaConcurrencyPattern/
  • Brian Göetz,Java theory and practice: To mutate or not to mutate?:http://www.ibm.com/developerworks/java/library/j-jtp02183/index.html
  • Brian Göetz et al.,Java Concurrency In Practice
  • Mark Grand,Patterns in Java,Volume 1, 2nd Edition
  • Java Language Specification,17.5. final Field Semantics http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html

作者简介

黄文海,有多年敏捷项目管理经验和丰富的技术指导经验。关注敏捷开发、Java 多线程编程和 Web 开发。在 InfoQ 中文站和 IBM DeveloperWorks 上发表过多篇文章。其博客: http://viscent.iteye.com/


感谢张龙对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。

收藏

评论

微博

用户头像
发表评论

注册/登录 InfoQ 发表评论