概述
引入
在很多时候,为了构建线程安全类,我们常常要对向外提供的类进行包装,以方便进行线程安全的统一控制。
Collections
中以装饰器模式统一提供了一大堆常用java的集合对象的线程安全封装,并进行了很大的优化。
但是还有一些特别的类,也通过自己的方法进行了线程安全的封装。它们使用了一些特别的处理方法,使得在某些情况下能获得更好的执行效率。本文记录的CopyOnWriteArrayList
就是其中的一个。它依赖的载体是数组而非集合类。
摘要
本文介绍了
-
CopyOnWriteArrayList
的实现原理 - 结合
CopyOnWriteArrayList
的原理,介绍了其有点及不足,以及使用场景 - 抛出了在阅读
CopyOnWriteArrayList
中遇到的一些问题及猜测 - 总结这段时间学习java并发编程的收获
由于读源码没有啥大问题,本文不再一个方法一个方法的进行介绍,仅对自己阅读代码中遇到的问题进行详述。
类介绍
类定位介绍
本类的实现原理是在对数组结构进行修改操作【改引用的指向、增、删】时先进行数组的复制,修改完成后修改数组引用的指向以完成数组的更新操作。
虽然在修改操作中使用了可重入互斥锁以避免并发修改。但是在复制及修改的同时,读线程仍然能正常读取数组并进行相关操作,这样在读操作远大于写操作的情况下就大大提升了并发的效率。
注意
CopyOnWriteArrayList
适用的场景和ReentrantReadWriteLock
差不多,都是读写操作同时存在且读操作频率远大于写操作频率。
CopyOnWriteArrayList
因为要进行数组复制,所以在数据量较大时,修改操作开销会很大,这里一定要注意。
单从访问效率上说:
-
ReentrantReadWirteLock
在写线程操作时来的读线程是被阻塞的,虽然造成读线程效率下降,但是能保证读线程读取的时间点是最新的 -
CopyOnWriteArrayList
在修改操作时是在新复制的数组上操作的,所以读线程可以正常操作,但是直到修改操作完成并修改数组引用后,读线程才能获得最新的数组内容
源码解读
CopyOnWriteArrayList
实现的核心思路
数组存储及共享
源码
/** The lock protecting all mutators */
final transient ReentrantLock lock = new ReentrantLock();
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
介绍
lock
:
- 使用可重入互斥锁对修改操作进行限制,使用
final
以解决相同CopyOnWriteArrayList
的线程安全问题
array
:
- 数据结构使用
volatile
修饰,以实现在修改数组指向后能及时进行线程间的同步- 使用数组而非集合类,在数组复制时使用
System.arrayCopy()
会比自己写的的遍历复制效率高。在读操作时也会更加方便
数据读
源码
/**
* Gets the array. Non-private so as to also be accessible
* from CopyOnWriteArraySet class.
*/
final Object[] getArray() {
return array;
}
介绍
读操作,没有任何限制,因为改动是发生在拷贝副本的,所以不必担心读到脏数据。
但是修改操作占用时间长的情况下可能会造成一些读操作的更新不是很及时。【小问题,毕竟现在硬件这么快,jvm优化这么叼】
数据写
源码
因为这里操作很多,所以我们直接用伪代码来标识:
public void xxx(){
if(参数校验不合规){
return / throw .....;
}
lock.lock();// 上锁,保证修改操作是独占进行的
try{
数组赋值一份新的副本;
对副本中进行种种操作;
setArray(新的副本地址);
}finally{
lock.unlock();//接触独占,使对象对其他的写线程开放
}
}
介绍
问: 既然已经使用了锁,为什么还要将array
设置成volatile
,锁的释放后不会将数据同步到主内存嘛?
将array
设置成volatile
是有一些用途的:
对于读线程来说:
- 使用
volatile
修饰array
后此变量每次读取会从主内存读,而非工作内存,保证每次能获得该变量最新的值,也就是最新的数组副本的地址对于写线程来说:
- 使用
vloatile
没有什么必要性,因为Lock
锁和Synchronized
锁都能保证在锁的获取时获得改变量的最新值。具体介绍见扩展——锁是如何保证数据可见性的。
CopyOnWriteArrayList
的一些特殊的子类
CopyOnWriteArrayList
提供了一个遍历内部数组的ListIterator
实现类——COWIterator
,它的实现是通过记录array
的引用以及一个表示当前位置的cursor
,他没有实现删除相关的函数,也就是说这个Iterator
只能遍历,不能修改。
CopyOnWriteArrayList
定义了一个子类COWSubList
,用于在sublist
时返回,为了少写代码,此类继承了AbstractList
,它有以下特点:
- 没有使用
modCount
来记录是否有其他修改,而是通过一种更巧妙的方式——它记录了对当前数组的引用,如果当前数组的引用和CopyOnWriteArrayList.getArray()
结果不同,表示有人创建了新的副本并进行了修改操作 - 此类没有专门做优化,所以
addAll()
之类的批量操作方法是通过批量调用add()
实现的,增加10个值可能会连续创建10个副本,知道这里有性能缺陷就行,既然官方说没问题,我们也没必要去纠结那么多
CopyOnWriteArrayList
为内部类COWSubList
提供了一个用于遍历的子类COWSubListIterator
,一个标准实现而已,没啥特别的。
使用示例
核心逻辑介绍
由于CopyOnWriteArrayList
内部采用的数组进行数据结构的存储,而且采用写时复制策略,读线程不会被阻塞,所以存在读速度很快的特点。由于写时复制,所以相应的存在修改时效率低下的特点,不过好在修改时的效率低下不会影响读操作【暂时不考虑数组长度巨变引起的越界问题,只要遍历时每次都重新获取,也不会越界】
所以CopyOnWriteArrayList
适合用来做纯读或者绝大时间做读操作的场景,比如数据变动不频繁时为应对高并发做的数据缓存操作。
场景假想
我现在要做一个后端服务校验操作,即通过dubbo提供的api入参都有appId
和token
,如果两者都对我才认为请求有效,予以处理,否则直接将请求丢弃。
场景问题如下:
- 此查询操作相当相当通用,并发很大,但是数据却很少变化,如果直接查库会白白浪费资源。
- 此插件很多应用都要用,如果做的太重,会极大的影响组件灵活性,所以不推荐上Redis
- 因为并发行很高,而且所有请求都依赖此组件,对组件的性能要求很高
场景特点如下:
- 为了方便新应用的接入而不需要重新上线,
appId/token
不设置在项目配置文件中,而放在数据库中,并对其进行复杂的加密保存 - 由于项目接入的应用是可预料的,
appId/token
一般只有几个或者十几个或者几十个,不会上百
思路发散
我们分析如下:
- 通过场景特点的(1),我们明确了一个方向——为了修改的方便,我们将配置配在数据库中
- 通过场景问题的(1),我们明确了一定要做缓存
- 通过场景问题的(2),我们在排除Redis的情况下,结合场景特点的(2),既然配置并不多,还要尽可能灵活,我们可以直接上内存缓存
- 通过场景问题的(3),我们明确不能在请求发现
cache
过期时再更新,那样会引起多个线程同时有更新需求的问题和阻塞更新的问题【虽然多个线程都有更新需求可以通过DCL解决,但是也会造成其他线程的阻塞】- 综上,思路如下:
- 使用一个单独的线程进行更新,保证在更细期间其他线程能读取到上一版本的数据而非阻塞
- 更新完成后能使其他线程及时读取到最新的数据
解决问题
volentile
/**
* @author lipengcheng3 Created date 2019-01-31 10:09
*/
public class Authentication1 {
public volatile List<Object> cache = new ArrayList<>();
// 查询操作,该咋做咋做
public Object get() {
return null;
}
public void update() { // 需要单独起一个线程去做,以实现 “只有一个写线程”的前提
// 该查库查库,该干嘛干嘛
List<Object>xx = new ArrayList<>();
//........
// 对 cache 引用进行赋值,使其直接指向最新的存储链表地址
cache = xx;
}
}
后记
没想到最后还是没用上CopyOnWriteArrayList
,想场景时为了合适想了很多。但是最后毁在了setArray()
方法不对外暴露上,不过想想也是,CopyOnWriteArrayList
的核心逻辑就是写时复制,那么我们如果可以自己设置array
,那不就等于打破了他的封装了嘛。
我们的实现里先对xx
进行各种生成的操作,最后再赋值,不也和CopyOnWriteArrayList
有点像嘛。
实现原理解析
最根本的实现原理:在java的操作中,赋值是一个原子操作【先别给我纠结32/64位机的问题】,所以可以借此和volatile
屏蔽掉复杂操作中的不完全暴露问题,并在修改的同时获得较好的并发读取效率
问题
重复设置volatile
变量
public E set(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
E oldValue = get(elements, index);
if (oldValue != element) {
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len);
newElements[index] = element;
setArray(newElements);
} else {
// Not quite a no-op; ensures volatile write semantics
setArray(elements);
}
return oldValue;
} finally {
lock.unlock();
}
}
Not quite a no-op; ensures volatile write semantics
这个注释翻译过来就是不是一个无操作; 确保易失性写入语义,个人觉得没啥必要,因为本身没有对elements
做任何修改。
扩展
线程安全类 VS 线程安全程序
线程安全类指你对你提供的类进行了线程安全的包装,该暴露出去的暴露出去了,不该暴露的没暴露,在多个线程对你的类进行调用时,能保证你的类能获得和单线程操作一样的结果【正确的结果】。
线程安全程序你的整个程序在多线程跑的时候没问题【整个程序,不是某个类】。
java数组相关操作
新建操作:XX[] xx = new XX[200]
,emmmmmmm,自从用了java后大部分用的都是集合类,很少再用数组了。
System.arraycopy()
还可以复制数组,知道有这个事就行了。
volatile
实现原理及和锁的对比
【由于还没对JVM做很深入的学习。JMM也不太了解,所以我们仅做表面的含糊的分析,大概知道什么意思就好】
volatile
功能一览
制止重排序
volatile
打标的变量在编译时会在变量的操作前后打标,这个标我们叫做内存屏障,它会制止编译器对该变量的操作进行重排序,比如:
X x = new X();
存在三个操作:
- 分配内存
- 对该内存进行
X
的初始化操作- x 指向分配的内存
经过编译器的重排后,可能会变成以下顺序:
分配内存
x 指向分配的内存
对该内存进行
X
的初始化操作这个时候,在多线程执行时就可能会出现问题,因为还没初始化完成,就把该内存的引用暴露出去了,如果有其他线程进行了访问,就会报错,【初始化函数的
this
指针泄漏问题也和这个差不多】。
禁止从本地内存读取变量
volatile
打标的变量在操作的过程中还有一个特点,每次进行变量读取时不从本地内存读取变量,而是从主内存读取。
锁的原理
经过对Lock
源码的解读,我的理解是:锁通过线程的阻塞实现了对竞态资源的顺序访问,在前一个线程完成对竞态资源的写回操作后,因为下一个线程被唤醒后会被重新调入cpu,此操作可能会从主内存加载最新的变量。【可能不对,后面我详细了解后会纠正】
参考文献
COW 那一行代码
https://www.jianshu.com/p/d56baf2d6c1a
http://ifeve.com/copyonwritearraylist-set/
volintine 原理
http://cmsblogs.com/?p=2092
https://www.cnblogs.com/monkeysayhi/p/7654460.html
happens-before模型
https://www.cnblogs.com/chenssy/p/6393321.html
缓存一致性原理
https://blog.csdn.net/muxiqingyang/article/details/6615199
JMM
https://www.jianshu.com/p/26385b1b9a8c
DCL
https://www.cnblogs.com/xz816111/p/8470048.html
网友评论