「如何实现流动式软件发布」线上课堂开课啦,快来报名参与课堂抽奖吧~ 了解详情
写点什么

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

2015 年 1 月 16 日

多线程共享变量的情况下,为了保证数据一致性,往往需要对这些变量的访问进行加锁。而锁本身又会带来一些问题和开销。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<string location="">();
public void updateLocation(String vehicleId, Location newLocation) {
locMap.put(vehicleId, newLocation);
}
}
</string>,>

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

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 模式的意图及架构。并结合笔者工作经历提供了一个实际的案例用于展示使用该模式的典型场景,在此基础上对该模式进行了评价并分享在实际运用该模式时需要注意的事项。

参考资源

作者简介

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


感谢张龙对本文的审校。

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

2015 年 1 月 16 日 07:0311490

评论

发布
暂无评论
发现更多内容

架构师训练营 - 第五周学习总结

joshuamai

Thread.start() ,它是怎么让线程启动的呢?

小傅哥

Java 线程 JVM 小傅哥 Thread

SpringBoot-技术专题-如何提高吞吐量

李浩宇/Alex

以 Kubernetes 为代表的容器技术,已成为云计算的新界面

阿里巴巴云原生

云计算 Kubernetes 容器 云原生

跨语言跨平台聚合OpenAPI文档从来没有这么简单过

Trust Me

OpenAPI Knife4j Knife4jAggregation 微服务聚合OpenAPI

开源认证和访问控制的利器keycloak使用简介

程序那些事

程序那些事 授权框架 开源认证框架 keycloak 认证授权

推荐几款MySQL相关工具

Simon

MySQL 工具 percona server

京东千亿订单背后的纵深安全防御体系

京东科技开发者

安全 网络 云服务 云安全

MyBatis-技术专题-拦截器原理探究

李浩宇/Alex

前端高效开发必备的 js 库梳理

徐小夕

Java GitHub 前端 js

Architecture Phase1 Week10:Summarize

phylony-lu

极客大学架构师训练营

面试者必看:Java8中的默认方法

Silently9527

java8 默认方法

802.11抓包软件对比之Microsoft Network Monitor

IoT云工坊

wifi 嵌入式 抓包

关于 AWS Lambda 中的冷启动,你想了解的信息都在这!

donghui

Serverless Faas 函数计算

「干货总结」程序员必知必会的十大排序算法

bigsai

排序 排序算法 快速排序

“奋斗者”号下潜10909米:我们为什么要做深海探索?

脑极体

Alibaba官方发文:阿里技术人的成长路径与方法论

Java架构师迁哥

Java踩坑记系列之BigDecimal

Java老k

BigDecimal

java: Compilation failed: internal java compiler error解决办法

LSJ

IDEA

讯飞推出充电宝式便携拾音器,重新定义传统拾音

Talk A.I.

熬夜不睡觉整理ELK技术文档,从此摆脱靠百度的工作(附源码)

996小迁

Java 编程 架构 面试 ELK

甲方日常 57

句子

工作 随笔杂谈 日常

Java踩坑记系列之Arrays.AsList

Java老k

Java

成德眉资现代农业园区大联动促发展,“1链3e”引领四市农业产业数字化建设

CNG农业公链

肝了一周的 UDP 基础知识终于出来了。

cxuan

计算机网络 计算机基础

大厂经验:一套Web自动曝光埋点技术方案

阿亮

埋点 曝光埋点 点击埋点 自动化埋点

计算机核心课程必读书目——《高级数据结构:理论与应用》

计算机与AI

数据结构 算法

架构师训练营 W06 作业

Geek_f06ede

架构师训练营 - 第五周课后练习

joshuamai

表格控件Spread.NET V14.0 发布:支持 .NET 5 和 .NET Core 3.1

Geek_Willie

《华为数据之道》读书笔记:第 4 章 面向“业务交易”的信息架构建设

方志

数据中台 数字化转型 数据治理

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