我们都清楚,ArrayList并不是线程安全的,当读线程在读取ArrayList的时候,如果有写线程在写数据,基于fast-fail机制,会抛出ConcurrentModificationException异常,也就是说ArrayList并不是一个线程安全的容器。当然我们可以用Vector或者Collections的静态方法将ArrayList包装成一个线程安全的类,但是这些方式都是采用java关键字synchronzied对方法进行修饰,利用独占式锁来保证线程安全的,很显然这种方式效率并不是太高。
在业务场景中,有很多业务往往是读多写少的。比如系统配置的信息,除了在初始进行系统配置的时候需要写入数据,其他大部分时刻对系统信息只需要进行读取。又比如白名单、黑名单等配置,大部分时刻只需要读取名单配置然后检测当前用户是否在该配置范围以内。类似的还有很多业务场景,它们都是属于读多写少的场景。如果在这种情况使用Vector、Collections转换这些方式是不合理的。
通过之前的学习,我们会联想到ReentrantReadWriteLock,通过读写分离的思想,使得读读之间不会阻塞,性能会大大提升不少。但是,如果仅仅是将list通过读写锁(ReentrantReadWriteLock)进行再一次封装的话,由于读写锁的特性,当写锁被写线程获取后,读写线程都会被阻塞。如果仅仅使用读写锁对list进行封装的话,此时仍然存在读线程在读数据的时候被阻塞的情况。如果我们保证读线程无论什么时候都不被阻塞,效率岂不是会更高?Doug Lea大师就为我们提供了CopyOnWriteArrayList容器可以保证线程安全,保证读读之间在任何时候都不会被阻塞,因此CopyOnWriteArrayList也被广泛应用于很多业务场景之中。
CopyOnWriteArrayList是通过Copy-On-Write(COW,写时复制)的思想,通过延时更新的策略来实现数据的最终一致性,并且能够保证读线程间不阻塞。COW通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。对CopyOnWrite容器进行并发的读的时候,不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,延时更新的策略是通过在写的时候写入不同的数据容器来实现的,放弃数据实时性达到数据的最终一致性。
// 用于修改时加锁
final transient ReentrantLock lock = new ReentrantLock();
// 真正存储元素的地方,只能通过get/set方法访问
private transient volatile Object[] array;
二者均使用transient修饰,表示不自动序列化。
array使用volatile修饰,保证可见性。(注意,此处仅仅是修饰的数组引用)
public CopyOnWriteArrayList() {
setArray(new Object[0]);
}
public CopyOnWriteArrayList(Collection<? extends E> c) {
Object[] elements;
// 如果c也是CopyOnWriteArrayList类型,那么直接把它的数组拿过来使用
if (c.getClass() == CopyOnWriteArrayList.class)
elements = ((CopyOnWriteArrayList<?>)c).getArray();
// 否则调用其toArray()方法将集合元素转化为数组
else {
elements = c.toArray();
// c.toArray might (incorrectly) not return Object[] (see 6260652)
// 这里c.toArray()返回的不一定是Object[]类型,详细原因见ArrayList里面的分析
if (elements.getClass() != Object[].class)
elements = Arrays.copyOf(elements, elements.length, Object[].class);
}
setArray(elements);
}
public CopyOnWriteArrayList(E[] toCopyIn) {
setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
}
public E get(int index) {
// 获取元素不需要加锁
// 直接返回index位置的元素(这里是没有做越界检查的, 因为数组本身会做越界检查)
return get(getArray(), index);
}
private E get(Object[] a, int index) {
return (E) a[index];
}
final Object[] getArray() {
return array;
}
可以看出来get方法实现非常简单,没有对多线程添加任何的线程安全控制,也没有加锁、CAS操作等,原因是,所有的读线程只是会读取数据容器中的数据,并不会进行修改。
●获取元素数组。
●返回数组指定索引位置的元素。
// 添加一个元素
public boolean add(E e) {
final ReentrantLock lock = this.lock;
// 加锁
lock.lock();
try {
// 获取原数组
Object[] elements = getArray();
int len = elements.length;
// 将原数组元素拷贝到新数组中,新数组大小是原数组大小加1
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 将元素放在最后一位
newElements[len] = e;
setArray(newElements);
return true;
} finally {
// 释放锁
lock.unlock();
}
}
代码主要流程如下:
●加锁。
●获取原数组。
●新建一个数组,大小为原数组长度加1,并把原数组元素拷贝到新数组。
●把新添加的元素放到新数组的末尾。
●array指向新数组,覆盖原数组。
●解锁。
// 在指定索引处添加一个元素
public void add(int index, E element) {
final ReentrantLock lock = this.lock;
// 加锁
lock.lock();
try {
// 获取原数组
Object[] elements = getArray();
int len = elements.length;
// 检查是否越界(可以等于len)
if (index > len || index < 0)
throw new IndexOutOfBoundsException("Index: "+index+
", Size: "+len);
Object[] newElements;
int numMoved = len - index;
// 如果插入的位置是最后一位
if (numMoved == 0)
// 拷贝一个n+1的数组, 其前n个元素与原数组一致
newElements = Arrays.copyOf(elements, len + 1);
// 如果插入的位置不是最后一位
else {
// 新建一个n+1的数组
newElements = new Object[len + 1];
// 拷贝原数组前index的元素到新数组中
System.arraycopy(elements, 0, newElements, 0, index);
// 将index及其之后的元素往后挪一位拷贝到新数组中(正好空出index位置)
System.arraycopy(elements, index, newElements, index + 1,
numMoved);
}
// 将元素放置在index处
newElements[index] = element;
setArray(newElements);
} finally {
// 释放锁
lock.unlock();
}
}
代码主要流程如下:
●加锁。
●获取原数组。
●检查索引是否合法,如果不合法抛出IndexOutOfBoundsException异常(注意这里index等于len也是合法的)。
●如果索引等于数组长度(也就是数组最后一位再加1),那就拷贝一个len+1的数组。
●如果索引不等于数组长度,那就新建一个len+1的数组,并按索引位置分成两部分,索引之前(不包含)的部分拷贝到新数组索引之前(不包含)的部分,索引之后(包含)的位置拷贝到新数组索引之后(不包含)的位置,空出索引所在位置。
●把索引位置赋值为待添加的元素。
●array指向新数组,覆盖原数组。
●解锁。
public boolean addIfAbsent(E e) {
// 快照原数组
Object[] snapshot = getArray();
// 检查如果元素不存在,直接返回false;如果存在则调用addIfAbsent()方法添加元素
return indexOf(e, snapshot, 0, snapshot.length) >= 0 ? false :
addIfAbsent(e, snapshot);
}
private boolean addIfAbsent(E e, Object[] snapshot) {
final ReentrantLock lock = this.lock;
// 加锁
lock.lock();
try {
// 重新获取原数组
Object[] current = getArray();
int len = current.length;
// 如果快照与刚获取的数组不一致,说明有修改
if (snapshot != current) {
// Optimize for lost race to another addXXX operation
// 重新检查元素是否在刚获取的数组里
int common = Math.min(snapshot.length, len);
for (int i = 0; i < common; i++)
// 元素不在快照里面,返回false
if (current[i] != snapshot[i] && eq(e, current[i]))
return false;
if (indexOf(e, current, common, len) >= 0)
return false;
}
// 拷贝一份n+1的数组
Object[] newElements = Arrays.copyOf(current, len + 1);
// 将元素放在最后一位
newElements[len] = e;
setArray(newElements);
return true;
} finally {
// 释放锁
lock.unlock();
}
}
代码主要流程如下:
●快照原数组,判断元素是否存在,不存在直接返回false。
●加锁。
●如果当前数组不等于传入快照,说明有修改,检查待添加的元素是否存在于当前数组中,如果不存在直接返回false。
●拷贝一个新数组,长度等于原数组长度加1,并把原数组元素拷贝到新数组中。
●把新添加的元素放到新数组的末尾。
●array指向新数组,覆盖原数组。
●释放锁。
public E remove(int index) {
final ReentrantLock lock = this.lock;
// 加锁
lock.lock();
try {
// 获取原数组
Object[] elements = getArray();
int len = elements.length;
E oldValue = get(elements, index);
int numMoved = len - index - 1;
// 如果移除的是最后一位
if (numMoved == 0)
// 直接拷贝一份n-1的新数组, 最后一位就自动删除了
setArray(Arrays.copyOf(elements, len - 1));
// 如果移除的不是最后一位
else {
// 新建一个n-1的新数组
Object[] newElements = new Object[len - 1];
// 将前index的元素拷贝到新数组中
System.arraycopy(elements, 0, newElements, 0, index);
// 将index后面(不包含)的元素往前挪一位,这样正好把index位置覆盖掉了, 相当于删除了
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
setArray(newElements);
}
return oldValue;
} finally {
// 释放锁
lock.unlock();
}
}
代码主要流程如下:
●加锁。
●获取指定索引位置元素的旧值。
●如果移除的是最后一位元素,则把原数组的前len-1个元素拷贝到新数组中,并把新数组赋值给当前对象的数组属性。
●如果移除的不是最后一位元素,则新建一个len-1长度的数组,并把原数组除了指定索引位置的元素全部拷贝到新数组中,并把新数组赋值给当前对象的数组属性。
●返回旧值。
●释放锁。
相同点:
●都是通过读写分离的思想实现。
●读线程间都是互不阻塞的。
不同点:
●对读线程而言,为了实现数据实时性,在写锁被获取后,读线程会等待或者当读锁被获取后,写线程会等待,从而解决“脏读”等问题。也就是说如果使用读写锁依然会出现读线程阻塞等待的情况。
●COW则牺牲了数据实时性而保证数据最终一致性,即读线程对数据的更新是延时感知的,因此读线程不会存在等待的情况。
●内存占用多:在写操作时需要复制一个新的数组,使得内存占用为原来的两倍左右。
●数据不实时一致:读操作不能读取实时数据,因为部分写操作的数据可能还未同步到读的数组中。
数组是被volitile修饰的,为什么需要复制一个新数组呢?这里回到上面的关键属性部分,volatile修饰的仅仅只是数组引用,数组中的元素的修改是不能保证可见性的。
●CopyOnWriteArrayList使用ReentrantLock加锁,保证线程安全。
●CopyOnWriteArrayList的写操作都要先拷贝一份新数组,在新数组中做修改,修改完了再用新数组替换老数组,所以空间复杂度是O(n),性能比较低下。
●CopyOnWriteArrayList的读操作支持随机访问,时间复杂度为O(1)。
●CopyOnWriteArrayList采用读写分离的思想,读操作不加锁,写操作加锁,且写操作占用较大内存空间,所以适用于读多写少的场合。
●CopyOnWriteArrayList只保证最终一致性,不保证实时一致性。
数组是被volitile修饰的,为什么需要复制一个新数组呢?
●这里回到上面的关键属性部分,volatile修饰的仅仅只是数组引用,数组中的元素的修改是不能保证可见性的。
与大多数Set不同,CopyOnWriteArraySet底层是使用CopyOnWriteArrayList存储元素的,并不是使用Map来存储元素的。
CopyOnWriteArrayList中元素是可以重复的,而CopyOnWriteArraySet中元素不能重复,因此,在添加元素时,CopyOnWriteArraySet调用了CopyOnWriteArrayList的addIfAbsent()方法来保证元素不重复。
// 内部使用CopyOnWriteArrayList存储元素
private final CopyOnWriteArrayList<E> al;
public CopyOnWriteArraySet() {
al = new CopyOnWriteArrayList<E>();
}
// 将集合c中的元素初始化到CopyOnWriteArraySet中
public CopyOnWriteArraySet(Collection<? extends E> c) {
if (c.getClass() == CopyOnWriteArraySet.class) {
// 如果c是CopyOnWriteArraySet类型,直接调用CopyOnWriteArrayList的构造方法初始化
@SuppressWarnings("unchecked") CopyOnWriteArraySet<E> cc =
(CopyOnWriteArraySet<E>)c;
al = new CopyOnWriteArrayList<E>(cc.al);
}
else {
// 如果c不是CopyOnWriteArraySet类型,调用CopyOnWriteArrayList的addAllAbsent()方法初始化
al = new CopyOnWriteArrayList<E>();
al.addAllAbsent(c);
}
}
public boolean isEmpty() {
return al.isEmpty();
}
public boolean contains(Object o) {
return al.contains(o);
}
public void clear() {
al.clear();
}
public boolean remove(Object o) {
return al.remove(o);
}
public boolean add(E e) {
return al.addIfAbsent(e);
}
public boolean removeIf(Predicate<? super E> filter) {
return al.removeIf(filter);
}
可以看出,基本上都是在调用CopyOnWriteArrayList中的方法,此处不在赘述。
private static boolean eq(Object o1, Object o2) {
return (o1 == null) ? o2 == null : o1.equals(o2);
}
public boolean equals(Object o) {
// 如果二者是同一对象,返回true
if (o == this)
return true;
// 如果o不是Set对象,返回false
if (!(o instanceof Set))
return false;
Set<?> set = (Set<?>)(o);
Iterator<?> it = set.iterator();
// Uses O(n^2) algorithm that is only appropriate
// for small sets, which CopyOnWriteArraySets should be.
// Use a single snapshot of underlying array
// 集合元素数组的快照
Object[] elements = al.getArray();
int len = elements.length;
// Mark matched elements to avoid re-checking
boolean[] matched = new boolean[len];
int k = 0;
// 从o这个集合开始遍历
outer: while (it.hasNext()) {
// 如果k>len了,说明o中元素多了,返回false
if (++k > len)
return false;
// 取值
Object x = it.next();
// 遍历检查是否在当前集合中
for (int i = 0; i < len; ++i) {
if (!matched[i] && eq(x, elements[i])) {
matched[i] = true;
continue outer;
}
}
// 如果不在当前集合中,返回false
return false;
}
return k == len;
}
这里判断两个Set是否相等时,逻辑感觉可以优化:
●如果两个集合的元素个数不相等,则可以直接判定不相等。
●一次双层循环,其中设定一个标记数组,标记某个位置的元素是否找到过。
●CopyOnWriteArraySet是用CopyOnWriteArrayList实现的。
●CopyOnWriteArraySet是有序的。
●CopyOnWriteArraySet是并发安全的,而且实现了读写分离。
●CopyOnWriteArraySet通过调用CopyOnWriteArrayList的addIfAbsent()方法来保证元素不重复。
JUC探险-1、初识概貌
JUC探险-2、synchronized
JUC探险-3、volatile
JUC探险-4、final
JUC探险-5、原子类
JUC探险-6、Lock & AQS
JUC探险-7、ReentrantLock
JUC探险-8、ReentrantReadWriteLock
JUC探险-9、Condition
JUC探险-10、常见工具、数据结构
JUC探险-11、ConcurrentHashMap
JUC探险-12、CopyOnWriteArrayList
JUC探险-13、ConcurrentLinkedQueue
JUC探险-14、ConcurrentSkipListMap
JUC探险-15、BlockingQueue
JUC探险-16、ThreadLocal
JUC探险-17、线程池