1 简介
多线程共享变量的情况下,我们需要做一些访问控制 来保证数据的一致性。而这些访问控制,如,阻塞式锁Lock和CAS(Compare and Swap)操作,会带来额外的开销问题,如上下文切换、等待时间和ABA问题等。
Immutable Object 模式的目的是使用对外可见状态的不可变对象,使得被共享对象具有线程安全性,而无须额外的同步访问控制。
状态不可变的对象并非指被建模的现实世界实体的状态不可变,而是我们在建模时候的一种决策:现实世界实体的状态总是在不断变化的,但我们可以用状态不可变的对象对这些实体进行建模。
下面我们看一个典型场景。一个车辆管理系统要对车辆的位置进行跟踪,我们可以对车辆位置进行建模,如下1-1:
public class LocationMutable {
private double x;
private double y;
public LocationMutable(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;
}
当我们收到新的车辆坐标数据后,需要调用LocationMutable的setXY方法来更新信息。显然这里的setXY方法并不是线程安全的。为了使setXY方便具备线程安全性,需要通过锁进行访问控制。那么有没有其他方式呢?
答案当然是Yes。虽然车辆位置信息总是在变化,但我们可以将位置信息建模为不可变对象,如下1-2:
public final class Location {
private final double x;
private final double y;
public Location(double x, double y) {
this.x = x;
this.y = y;
}
public double getX() {
return x;
}
public double getY() {
return y;
}
}
如果车辆位置信息发生变动,则通过替换整个表示位置信息的对象来实现,如下1-3:
public class VehicleTracker {
private Map<String, Location> locMap = new ConcurrentHashMap<>();
public void updateLocation(String vehicleId, Location location) {
locMap.put(vehicleId, location);
}
}
2 架构
2.1 Immutable Object 模式类图
Immutable Object 模式将现实世界中状态可变的实体建模为状态不可变的对象,并通过创建不同的状态不可变的对象来反映现实世界实体的状态变更。
Immutable Object 主要参与者如下类图2-1所示:
- ImmutableObject:负责存储一组不可变的状态。改参与者不对外暴露任何可以修改其状态的方法,主要方法和职责如下:
- getStateX,getStateN:这些getter方法返回其所属ImmutableObject实例所维护的状态相关变量的值。这些变量在对象实例化时通过构造方法初始化。
- getStateSnapshot:返回其属性ImmutableObject实例维护的一组状态的快照。
- Manipulator:负责维护ImmutableObject所建模的现实世界实体状态的变更。当相应的现实实体状态变更时,改参与者负责生成新的ImmutableObject实例,以反映新的状态。
- changeStateTo:根据新的状态值生成新ImmutableObject实例。
不可比对象的使用主要包括以下几种类型:
- 获取单个状态的值:调用不可变对象的相关getter方法即可实现。
- 获取一组状态的快照:不可比对象可以提供一个getter方法,该方法需要对其返回值做防御性复制或者返回一个只读对象,以避免其状态对外泄露而被改变。
- 生成新的不可变对象实例:当被建模对象的状态发生变化的时候,创建新的不可比对象实例来反映这种变化。
2.2 Immutable Object 模式交互场景序列图
图示:
说明:
- 第1~2步:客户端代码获取ImmutableObject实例的各个状态值。
- 第3步:客户端调用Manipulator的changeStateTo方法来更新应用的状态。
- 第4~5步:changeStateTo方法创建新的ImmutableObject实例以反映应用的新状态,并返回。
- 第6~7步:客户端获取新的ImmutableObject实例的状态快照。
2.3 严格意义的不可变对象
一个严格意义上的不可变对象应该满足以下所有条件:
- 类使用final修饰,防止其之类改变其定义的行为。
- 类中所有字段使用final修饰:使用final修饰字段不仅从语义上说明被修饰字段的引用不可改变。更重要是的这个语义在多线程环境下由JMM(Java Memory Model)保证了被修饰字段所引用对象的初始化安全,即final修饰的字段在其他线程可见时,它必定完成了初始化。
- 在对象的创建过程中,this关键字没有泄露给其他类:防止其他类(如该类的匿名内部类)在对象的创建过程中修改其状态。
- 任何字段,若引用了其他状态可变的对象,则这些字段必须是private修饰的,并且这些字段值不能对外暴露。若有相关方法返回这些字段值,应该进行防御性复制(Defensive Copy)。
3 案例
3.1 Java标准库实例
以java.lang.String为例,String类是不可变类,但是我们不是经常调用substring,replace方法对字符串做出改变 了吗,它怎么就不可变了呢?
-
首先,String用于存储字符串的变量,如下:
private final char value[];
- 用private final修饰
-
以substring方法为例,看下源代码:
public String substring(int beginIndex) { if (beginIndex < 0) { throw new StringIndexOutOfBoundsException(beginIndex); } int subLen = value.length - beginIndex; if (subLen < 0) { throw new StringIndexOutOfBoundsException(subLen); } return (beginIndex == 0) ? this : new String(value, beginIndex, subLen); } public String(char value[], int offset, int count) { if (offset < 0) { throw new StringIndexOutOfBoundsException(offset); } if (count <= 0) { if (count < 0) { throw new StringIndexOutOfBoundsException(count); } if (offset <= value.length) { this.value = "".value; return; } } // Note: offset or count might be near -1>>>1. if (offset > value.length - count) { throw new StringIndexOutOfBoundsException(offset + count); } this.value = Arrays.copyOfRange(value, offset, offset+count); }
- 前面为边界检测,最后如果beginIndex != 0时,返回一个新的String对象,即进行防御性的复制。
3.2 实战案例
某彩信网关系统在处理由增值业务提供商(VASP,Value-Added Service Provider)下发给手机终端用户的彩信消息的时,需要根据彩信接收方的号码前缀选择对应的彩信中心(MMSC,Multimedia Messaging Service Center),然后转发消息给选中的消息中心,由其负责对接电信网络将消息下发给手机终端用户。彩信中心相对于彩信网关系统而言,它是一个独立的部件,二者通过网络交互。这个选择彩信中心的过程,我们称之为路由(Routing)。而手机号前缀和彩信中心的对应关系,被称为路由表。路由表在软件维护过程中可能发生变化。例如,业务扩容带来的新增彩信中心,为某个号码前缀指定新的彩信中心等。虽然路由表在该系统中是由多线程共享的数据,但是这些数据的变化频率并不高,即使是为了保证线程的安全性,我们也不希望对这些数据进行加锁等并发访问控制,以免产生不必要的开销和问题。这时,Immutable Object就派上用场了。
维护路由表可以被建模为一个不可变对象,源代码3.2-1如下:
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
/**
* @author Administrator
* @version 1.0
* @description 彩信中心路由规则管理器
* @date 2022-10-13 10:24
* 模式角色:Immutable Object
*/
public final class MMSCRouter {
/**
* volatile修饰,保证在多线程环境下的可见性 *
*/
private static volatile MMSCRouter instance = new MMSCRouter();
/**
* 维护号码前缀到彩信中心的映射关系
*/
private final Map<String, MMSCInfo> routeMap;
public MMSCRouter() {
// 通过把数据库中的数据初始化routeMap
this.routeMap = MMSCRouter.retrieveRouteMapFromDB();
}
private static Map<String,MMSCInfo> retrieveRouteMapFromDB() {
Map<String, MMSCInfo> map = new HashMap<>();
// 省略其他代码
return map;
}
public static MMSCRouter getInstance() {
return instance;
}
/**
* 根据手机号码前缀获取彩信中心信息
* @param numPrefix 手机号码前缀
* @return 彩信中心
*/
public MMSCInfo getMMSC(String numPrefix) {
return routeMap.get(numPrefix);
}
/**
* 将当前MMSCRouter实例更新为新的实例
* @param newInstance MMSCRouter新实例
*/
public static void setInstance(MMSCRouter newInstance) {
instance = newInstance;
}
public Map<String, MMSCInfo> getRouteMap() {
// 防御性复制
return Collections.unmodifiableMap(deepCopy(routeMap));
}
private static Map<String, MMSCInfo> deepCopy(Map<String, MMSCInfo> original) {
Map<String, MMSCInfo> newMap = new HashMap<>();
for (String key: original.keySet()) {
newMap.put(key, new MMSCInfo(original.get(key)));
}
return newMap;
}
}
而彩信中心相关数据,如彩信中心设备编号、URL等也被建模为一个不可变对象,代码3.2-2如下:
/**
* @author Administrator
* @version 1.0
* @description 彩信中心
* @date 2022-10-13 10:34
* 模式角色:Immutable Object
*/
public class MMSCInfo {
/**
* 设备编号
*/
private final String deviceID;
/**
* 彩信中心URL
*/
private final String url;
public MMSCInfo(String deviceID, String url) {
this.deviceID = deviceID;
this.url = url;
}
public MMSCInfo(MMSCInfo mmscInfo) {
deviceID = mmscInfo.deviceID;
url = mmscInfo.url;
}
public String getDeviceID() {
return deviceID;
}
public String getUrl() {
return url;
}
}
彩信中心的信息变更的频率同样不高。因此,当彩信网关系统通过网络被通知彩信中心变更或者路由表变更时,网关系统会生成新的MMSCInfo和MMSCRouter来反映这种变更。代码3.2-3如下:
/**
* @author Administrator
* @version 1.0
* @description 与运维中心(Operation and Maintenance)对接的类
* @date 2022-10-13 10:52
* 处理彩信中心、路由表的变更
* 模式角色:Manipulator
*/
public class OMCAgent extends Thread{
@Override
public void run() {
boolean isTableMod = false;
String undatedTableName = null;
while (true) {
// 省略其他代码
if (isTableMod) {
if ("MMSCInfo".equals(undatedTableName)) {
MMSCRouter.setInstance(new MMSCRouter());
}
}
// 省略其他代码
}
}
}
本案例中MMSCInfo是一个严格意义上的不可变对象。虽然MMSCRouter提供了setInstance方法用于改变其静态字段instance的值,单它任然可以被视为一个不可变对象。因为setInstance仅仅改变了instance指向的对象,而instance变量用volatile修饰保证了其在多线程之间的内存可见性,所以这意味着setInstance对instance变量的改变无须加锁也能保证线程安全。
OMCAgent是一个Manipulator参与者实例,MMSCRouter和MMSCInfo是ImmutableObject实例。通过不可变模式,我们既可以应对路由表、彩信中心这些不是很频繁的变更,又可以使系统中使用路由表的代码免于并发访问控制的开销和问题。
4 评价和考量
不可变对象具有天生的线程安全性,多个线程共享一个不可变对象无须额外的并发访问控制,这使我们可以避免加锁等并发访问控制的开销和其他问题,简化了多线程编程。
Immutable Object 模式适用于以下场景:
- 被建模对象的状态变化不频繁:对不可变对象的引用使用volatile修饰,可以避免使用锁,又可以保证多线程之间的内存可见性。
- 同时对一组相关数据进行写操作,因此需要保证原子性:采用Immutable Object 模式,将这一组相关数据组合成一个不可变对象,则对这一组数据的操作无须加显示锁也能保证原子性。
- 使用某个对象作为安全的HashMap的key:如果一个对象作为HashMap的key放入HashMap之后,若该对象的状态发生变化导致其Hash code改变,则会导致后面的用用一个对象作为key进行get、set操作的时候出现问题。这里用不可变对象用作HashMap的key就很合适。
Immutable Object 模式实现时注意以下几个问题:
- 被建模对象状态变化频繁:此时也不是不能使用Immutable Object 模式。只是这意味着要频繁创建新的不可变对象,因此会增加JVM垃圾回收的负担和CPU的消耗,我们要综合考虑:被建模对象的规模、代码目标运行环境的JVM内存分配情况、系统对吞吐率和相应性的要求。若这几个方面因素综合考虑都能满足要求,那么使用不可变对象建模也未尝不可。
- 使用等效或者近似的不可变对象:有时创建严格意义上的不可变对象比较难,但是尽量向严格意义上的不可变对象靠拢也有利于发挥不可变对象的好处。
- 防御性复制:如果不可变对象本身包含一些状态需要对外暴露,而响应的字段又是可变的(如HashMap),那么返回这些字段的方法需要做防御性复制,以避免外部代码修改其内部状态。
5 后记
❓QQ:806797785
⭐️源代码仓库地址:https://gitee.com/gaogzhen/concurrent
参考:
[1]黄文海.Java多线程编程实战指南(设计模式篇)[M].北京:电子工业出版社,2015.10.
[1]黑马程序员.黑马程序员深入学习Java并发编程,JUC并发编程全套教程[CP/OL].2020-01-18/2022-10-02.p68.