1. 如何实现一个不可变类
一、案例分析
今天这篇文章我们将通过一个车辆管理系统的案例讲解实现不可变类的思路。在这个案例中,需要对车辆的信息进行跟踪,其中车辆的位置信息的代码如下图(图1):
图中是一个位置信息类,包含代表坐标的变量X和Y,和用来对车辆位置信息进行更新的方法 setXY,接下来我们看下实现车辆信息追踪代码(图2):
当车辆的位置信息发生变更的时候,我们可以调用 updateLocation
方法来更新车辆的位置,另外也可以通过调用 getLocation
方法来获取车辆的信息。
但 Location 类的 setXY 方法不是一个线程安全的方法,我们可以参考下图(图3)做一下具体分析:
如图3所示,一开始某辆车的位置信息为x=1.0 y=1.0,接着线程1调用 updateLocation 方法来更新位置信息为x = 2.0,y = 2.0 ,这时线程1只来得及更新了x的值,y的值还没有更新,好巧不巧,线程2也来读取车辆的位置信息,此时它得到的结果是 x =2.0,y = 1.0。 这可是这个车根本不曾到达过的“诗和远方”。
为了确保车辆信息的更新具备线程安全的特性,我们可以将位置信息类改造为不可变类,如果车辆的位置信息发生变化,咱们通过替换整个 Location 对象来实现,而不是通过setXY方法来实现。
二、如何实现一个不可变类?
那么怎么将一个类改造为不可变类呢?所谓的不可变类是指一个对象一经创建就不再改变。
在我们车辆管理系统中来说就是Location类一旦创建就不能变了,不能改变X的值,也不能改变Y的值。
说到这就有点意思了,如果Location类中的X的值不能变,Y的值也不能变,那么我们是不是可以使用Java的关键字final来修饰这两个字段,通过Java语言的语法特性来保证这两个字段的不可变,如图4:
private final double x;
private final double y;
接着还是说X和Y的值不能改变,这个时候setXY方法的存在是不是不太合理?所以需要将setXY方法也去掉。
三、继续思考:如果当前类被子类继承还是一个不可变类吗?
接着我们再思考一个问题:假设我有一个子类继承了Location,然后重写了它的getX方法怎么办?如图5:
如图5所示,假设有人继承 Location 类,然后重写getX方法。比如说我本来一个Location对象的X值为1的,但是这个子类确返回了 1 + 1 = 2。这很显然不符合不可变对象的行为,因为它的子类可以改变它的方法行为。 为了杜绝这种情况,我们需要将Location类设计为不可继承的,通过final修饰符修饰即可。
那么最终版本的不可变的Location如图6:
public 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;
}
}
接着,如果车辆位置发生变化的时候,通过替换整个Location来表示,这样就能避免前面的问题了。
如图7中,如果车辆位置发生了变化,可以通过替换整个Location从而避免线程安全问题。
四、回头看看:如何将一个类改造成不可变类?
通过上面的例子,我们大概了解了使用可变的类会引发什么样问题,以及如何将一个类改造成不可变类,来解决线程安全问题。最后我们总结一下实现不可变类的一些思路:
- 使用 final 关键字修饰所有成员变量,避免其被修改,也可以保证多线程环境下被 final 关键字修饰的变量所引用的对象的初始化安全,即 final 修饰的字段在其他线程可见时,必定是初始化完成的。
- 使用 private 修饰所有成员变量,可以防止子类及其他地方通过引用直接修改变量值。
- 禁止提供修改内部状态的公开接口(比如我们前面例子中的 setXY 方法)
- 禁止不可变类被外部继承,防止子类改变其定义的方法的行为。
- 如果类中存在数组或集合,在提供给外部访问之前需要做防御性复制
前面4点比较好理解,我们在前面改造 Location 为不可变类的过程中都有运用到,第 5 点则需要另外做一下说明
如图所示,DefensiveReplicaDemo 类中有一个List
注意看红框内的代码,调用了 data.add(4),因为返回的是一个引用,指向的对象和 DefensiveReplicaDemo 类中的 data 指向的对象是同一个,这样就会导致 DefensiveReplicaDemo 类中的data 数据内容改变为1,2,3,4。 为了避免这种情况,我们通常会做防御性复制,如图9:
public List<Integer> getData () {
return Collections.unmodifiableList(new ArrayList<>(data));
}
在返回data之前,创建了一个新的List对象返回,并且使用 Collections.unmodifiableList
方法进行包装,这样能保证外部无法修改我们返回的结果,那么 DefensiveReplicaDemo 的data 集合的值永远会是1,2,3。
2. 百万流量的短信网关系统,如何基于不可变模式解决并发问题
1. 业务背景介绍
首先介绍一下业务背景,有一个每天有百万流量的短信网关系统,这个系统会使用第三方短信服务商(比如说阿里云、腾讯云、百度云等等)的短信发送功能。
短信网关后面对接着多家三方短信服务提供商,当我们需要发送短信的时候,短信网关会根据一定的策略(比如说选择费率最低的、或者到达率最高的)从三方短信厂商中选择一家,调用他们的接口给用户发送短信。
另外因为短信服务厂商市面上有很多,所以我们系统需要根据公司运营情况对服务商进行PK,即对服务商进行多维度的考察,假设某个服务商的考察结果不尽人意,就会被替换掉。
左上角会定时对短信服务上进行PK,如果发现某个服务商不行了,则会在短信网关后台管理服务中更新短信服务商列表,也就是把某些PK中输掉的服务商替换。
2. 短信服务商基本信息
1、先来看看短信服务商的基本信息:
如图3所示,短信服务商信息包括服务商请求的url以及每次发送的字节数量。
3. 短信路由网关
再者短信服务上服务商信息列表是保存在数据库中的,由于这个数据会比较常用,而每次发送短信之前都需要根据一定的策略来选择服务商,所以在系统启动的时候,会将所有的短信服务上列表从数据库中加载出来放在内存里,有一个叫做SmsRouter的类专门去做这个事情,代码如下图(图4)所示:
如图所示,SmsRouter 在构造函数中调用了 loadSmsInfoRouteMapFromDb 方法,把短信服务商信息从数据库中加载到内存中(这里使用模拟的方式),用字段 smsInfoRouteMap 保存。其中smsInfoRouteMap的Key 为服务商排名。
当短信服务商发生变更的时候的时候,会先更新更新数据库,然后去更新内存中的短信服务商信息。
图中代码将服务商排名为3的服务商改为另外一个服务商。但是这里有一个问题,因为这里设置 url 和设置 maxSizeInBytes 并不是一个原子操作,可能出现其中一个线程刚刚设置了URL,另一个线程过来读取服务商排名为3的服务商的场景,这样读取排名为3的服务商得到的一个结果,这个结果是一个中间状态,其中url和maxSizeInBytes并不是属于同一个服务商的,这样很可能会导致程序出现问题。
4. 基于不可变模式改造代码
接下来需要使用不可变模式来改造代码,避免这样的线程安全问题,第一步先将SmsInfo改造为不可变对象,改造后的结果如下:
图中,将SmsInfo改造为不可变对象,声明这个类时使用 final
关键字修饰,表示其不可被继承,另外每个字段都使用了private final
进行修饰。
接着在需要将获取服务商列表的代码改造为防御性复制,如图所示
在图中,在返回 smsInfoRouteMap 数据之前,做了防御性复制,即便外部改变获取到对象的状态,也不会影响 SmsRouter 本身的smsInfoRouteMap数据。
Collections.unmodifiableMap()
只能让 Map 中 value 对象的引用不可变,但是还是可以修改 value 对象的内部值,因此用 deepCopy
方法来进行单独复制处理,防止原对象内容被修改。类似下边代码:返回 value3,map 的值也被改了
List<String> list = new ArrayList<>();
list.add("value1");
list.add("value2");
Map<String, List<String>> map = new HashMap<>();
map.put("key1", list);
Map<String, List<String>> unmodifiableMap = Collections.unmodifiableMap(map);
System.out.println(unmodifiableMap.get("key1").get(0));
unmodifiableMap.get("key1").set(0, "value3");
System.out.println(map.get("key1").get(0));
接着提供一个直接替换 SmsRouter 实例的方法,便于用来刷新整个服务商信息:
图8
在短信服务商发生变更的时候,代码如下:
图9
当短信服务商列表发生变化的时候,我们通过调用 changeRouteInfo 方法,更新数据库中的服务商信息,接着替换整个 SmsRouter 实例。
这样一来,SmsRouter 在构造函数的时候会调用 loadSmsInfoRouteMapFromDb 方法将更新后的短信服务商列表从数据库中读取出来,然后更新到内存中。
到此为止,我们就通过不可变模式避免了短信网关中服务商列表更新的线程安全问题,这归功于短信服务上信息 SmsInfo 的不可变性,从而避免了修改 SmsInfo 在多线程环境下的线程安全问题,另外在 SmsRouter 获取服务上列表的过程中,对服务商列表进行了了防御性复制,避免外部其他的类对 SmsRouter 中的短信服务商列表的进行修改。
6. 总结
当然了,有人可能会想到:解决短信服务上更新的线程安全问题可以加锁啊!通过加锁的方式实现当然也是可以的,但是我们这篇文章主题是通过不可变类来避免线程安全问题,所以这里演示的是通过不可变对象的方式,其实两种方式都可以,本篇文章只是提供一个可行的方案。实现不可变类在实战中比较有用,希望大家能深入理解并灵活使用。
配置的话可以加读写锁:
// 可重入读写锁
private ReadWriteLock lock = new ReentrantReadWriteLock();
try {
lock.writeLock().lock();
readConfigInfo();
} catch (Exception e) {
} finally {
lock.writeLock().unlock();
}
3. 等效不可变对象 CopyOnWriteArrayList
CopyOnWriteArrayList:
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
private static final long serialVersionUID = 8673264195747942595L;
/** The lock protecting all mutators */
final transient ReentrantLock lock = new ReentrantLock();
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
/**
* Gets the array. Non-private so as to also be accessible
* from CopyOnWriteArraySet class.
*/
final Object[] getArray() {
return array;
}
/**
* Sets the array.
*/
final void setArray(Object[] a) {
array = a;
}
// ...
}
可以看到 CopyOnWriteArrayList 源码中维护一个 array 对象数组用于存储集合的每个元素,并且 array 数组只能通过 getArray 和 setArray 方法来访问。
接下来看看 CopyOnWriteArrayList 在进行数据遍历和新增一个元素的方法:
public Iterator<E> iterator() {
return new COWIterator<E>(getArray(), 0);
}
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
在调用 iterator 方法的时候,会通过 getArray()
方法获取 array 数组,然后基于这个数组进行遍历遍历。另外再新增一个元素,调用 add 方法的时候,也是通过 getArray()
获取到对象数组,然后直接新生成一个数组,最后把旧的数组的值复制到新的数组中,然后直接使用新的数组覆盖实例变量 array。
此处,实例变量array就是一个等效不可变对象,实例变量array本质上是一个数组,而数组的各个元素都是一个对象,每个对象内部的状态是可以替换的。因此实例变量并非严格意义上的不可变对象,所以我们称之为等效不可变对象。
写时复制机制主要体现在写操作上,从代码可以看到,在进行写操作的时候,首先是基于 array 这份数据复制出来一份数据,接着在复制出来的数据基础上进行写操作。同理,我们看看另一种写场景: 删除一个元素
在移除一个元素的时候,最终都是基于原有的数组复制一个新的数组,然后直接用新的数据替换掉旧的数组。
CopyOnWriteArrayList 的一个特点:弱一致性。意思就是说线程 1 看到的是某一时刻的一份『快照数据』,无法保证能读取到最新的数据。
**
4. CopyOnWriteArrayList 的使用场景
CopyOnWriteArrayList 适用于读多写少的场景。比如数据库驱动的加载:
图5
第一步需要加载对应的数据库驱动,我们看看 Mysql 的驱动程序:
图6
在图中,Mysql 的 Driver 有一块静态代码块,表示在加载类的时候会执行这段代码,最终执行了 DriverManager 的 registerDriver 程序。
图7
图中 registerDriver
方法,实际上是往 registerDrivers 中添加了一个 DriverInfo 对象,而 registerDrivers 就是一个 CopyOnWriteArrayList。
registerDrivers 就是用来保存不同的数据库驱动的,而通常来说,一个项目上只有一个数据库类型,就算在一些复杂的场景下,可能一个项目对应多个数据库类型,但是无论有多少个数据库类型,数据库的驱动程序一般都是在程序启动的时候加载的,也就是说 registerDriver 方法一般来说都是在程序启动的时候进行调用的,在后续程序运行过程中一般不会再调用这个方法,这种场景完美符合“写少”的定义,基本上在程序运行过程中,不会再进行写操作(也就是add/remove等操作)
什么情况下会对数据库驱动程序进行读取呢?在我们程序中需要调用 JDBC 得到数据库连接的时候,会去遍历所有的 driver,然后找到一个 driver,然后通过那个特定的 driver 来获取连接
图8
JDBC 驱动程序列表这种数据,因为驱动程序变更的情况比较少,遍历这个驱动程序列表的情况比较多,所以是符合读多写少的特性,适合使用 CopyOnWriteArrayList 来维护。