一、概述
1. 什么是CopyOnWrite
Copy-On-Write简称COW,是一种用于程序设计中的优化策略。其基本思路是,当想要对一块内存进行修改时,不在原有内存块中进行写操作,而是将内存拷贝一份,在新的内存中进行写操作,写完之后,就将指向原来内存指针指向新的内存,原来的内存就可以被回收掉。
这是一种用于程序设计中的优化策略,是一种延时懒惰策略。
2. CopyOnWrite容器
CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
从JDK1.5开始Java并发包里提供了两个使用CopyOnWrite机制实现的并发容器,它们是CopyOnWriteArrayList和CopyOnWriteArraySet。CopyOnWrite容器非常有用,可以在非常多的并发场景中使用到。
3. 为什么使用CopyOnWrite容器
以ArrayList为例, ArrayList 并不是线程安全的,在读线程在读取 ArrayList 的时候如果有写线程在写数据的时候,基于 fast-fail 机制,会抛出ConcurrentModificationException异常,也就是说 ArrayList 并不是一个线程安全的容器,当然可以用 Vector,或者使用 Collections 的静态方法将 ArrayList 包装成一个线程安全的类,但是这些方式都是采用 java 关键字 synchronzied 对方法进行修饰,利用独占式锁来保证线程安全的。但是,由于独占式锁在同一时刻只有一个线程能够获取到对象监视器,很显然这种方式效率并不是太高。
回到业务场景中,有很多业务往往是读多写少的,比如系统配置的信息,除了在初始进行系统配置的时候需要写入数据,其他大部分时刻其他模块之后对系统信息只需要进行读取,又比如白名单,黑名单等配置,只需要读取名单配置然后检测当前用户是否在该配置范围以内。类似的还有很多业务场景,它们都是属于读多写少的场景。如果在这种情况用到上述的方法,使用 Vector,Collections 转换的这些方式是不合理的,因为尽管多个读线程从同一个数据容器中读取数据,但是读线程对数据容器的数据并不会发生发生修改。通过读写分离的思想,使得读读之间不会阻塞,无疑如果一个 list 能够做到被多个读线程读取的话,性能会大大提升不少。但是,如果仅仅是将 list 通过读写锁(ReentrantReadWriteLock)进行再一次封装的话,由于读写锁的特性,当写锁被写线程获取后,读写线程都会被阻塞。如果仅仅使用读写锁对 list 进行封装的话,这里仍然存在读线程在读数据的时候被阻塞的情况,如果想 list 的读效率更高的话,这里就是我们的突破口,如果我们保证读线程无论什么时候都不被阻塞,效率岂不是会更高?
Doug Lea 大师就为我们提供 CopyOnWriteArrayList 容器可以保证线程安全,保证读读之间在任何时候都不会被阻塞,CopyOnWriteArrayList 也被广泛应用于很多业务场景之中,CopyOnWriteArrayList 值得被我们好好认识一番。
4.设计思想
回到上面所说的,如果简单的使用读写锁的话,在写锁被获取之后,读写线程被阻塞,只有当写锁被释放后读线程才有机会获取到锁从而读到最新的数据,站在读线程的角度来看,即读线程任何时候都是获取到最新的数据,满足数据实时性。既然我们说到要进行优化,必然有 trade-off,我们就可以牺牲数据实时性满足数据的最终一致性即可。而 CopyOnWriteArrayList 就是通过 Copy-On-Write(COW),即写时复制的思想来通过延时更新的策略来实现数据的最终一致性,并且能够保证读线程间不阻塞。
COW 通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。对 CopyOnWrite 容器进行并发的读的时候,不需要加锁,因为当前容器不会添加任何元素。所以 CopyOnWrite 容器也是一种读写分离的思想,延时更新的策略是通过在写的时候针对的是不同的数据容器来实现的,放弃数据实时性达到数据的最终一致性。
二、源码解析
下面以为ArrayList 展示fail-fast机制导致的ConcurrentModificationException。另外详细对 CopyOnWriteArrayList 的源码进行解读
1. 问题案例
demo1:
获取list的迭代器,再向list里put值,循环获取迭代器里的值的时候就会发生 ConcurrentModificationException
/**
* @author nowuseeme
*/
public class ArrayListConcurrentModifyDemo {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
Iterator<Integer> iterator = list.iterator();
list.add(4);
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}
}
原因:由于fail-fast机制,ArrayList的 iterator.hasNext()方法每次都会执行checkForComodification()方法检测当前ArrayList里面的 modCount与 迭代器获取时候的modCount是否一致,由于重新put值,导致modCount++,不一致抛出异常
demo2:
多线程操作模拟
/**
* @author nowuseeme
*/
public class ArrayListConcurrentModifyDemo2 {
private static List<Integer> list = new ArrayList<>();
public static void main(String[] args) throws InterruptedException{
list.add(1);
list.add(2);
list.add(3);
// 存放8个线程的线程池
ExecutorService readExecutor = Executors.newFixedThreadPool(4);
ExecutorService putExecutor = Executors.newFixedThreadPool(4);
// 执行4个read任务(当前正在迭代集合(这里模拟并发中读取某一list的场景))
for (int i = 0; i < 4; i++) {
readExecutor.execute(new Runnable() {
@Override
public void run() {
Iterator iterator = list.iterator();
while (iterator.hasNext()) {
try {
//模拟睡眠
Thread.sleep(1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("iterator next value: "+iterator.next());
}
}
});
}
// 执行4个put任务
for (int i = 0; i < 4; i++) {
putExecutor.execute(new Runnable() {
@Override
public void run() {
// 添加数据
list.add(4);
System.out.println("list add value 4");
}
});
}
System.out.println("list: "+ Arrays.toString(list.toArray()));
}
}
2. CopyOnWriteArrayList源码解读
属性与get()方法
private transient volatile Object[] array;
final Object[] getArray() {
return array;
}
final void setArray(Object[] a) {
array = a;
}
private E get(Object[] a, int index) {
return (E) a[index];
}
get 方法实现非常简单,几乎就是一个“单线程”程序,没有对多线程添加任何的线程安全控制,也没有加锁也没有 CAS 操作等等,原因是,所有的读线程只是会读取数据容器中的数据,并不会进行修改。
add()方法源码
public boolean add(E e) {
//1. 重入锁,保证写线程在同一时刻只有一个
// 加锁防止多线程生成多个副本数组
final ReentrantLock lock = this.lock;
lock.lock();
try {
//2. 获取旧数组引用
Object[] elements = getArray();
int len = elements.length;
//3. 创建新的数组,并将旧数组的数据复制到新数组中
Object[] newElements = Arrays.copyOf(elements, len + 1);
//4. 往新数组中添加新的数据
newElements[len] = e;
//5. 将旧数组引用指向新的数组
setArray(newElements);
return true;
} finally {
//解锁
lock.unlock();
}
}
add 方法的逻辑也比较容易理解:
- 采用 ReentrantLock,保证同一时刻只有一个写线程正在进行数组的复制,否则的话内存中会有多份被复制的数据;
- 数组引用是 volatile 修饰的,因此将旧的数组引用指向新的数组,根据 volatile 的 happens-before 规则,写线程对数组引用的修改对读线程是可见的。
- 由于在写数据的时候,是在新的数组中插入数据的,从而保证读写实在两个不同的数据容器中进行操作。
CopyOnWriteArrayList的迭代器
public Iterator<E> iterator() {
return new COWIterator:<E>(getArray(), 0);
}
内部类COWIterator:
static final class COWIterator<E> implements ListIterator<E> {
/** Snapshot of the array */
private final Object[] snapshot;
/** Index of element to be returned by subsequent call to next. */
private int cursor;
private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}
public boolean hasNext() {
return cursor < snapshot.length;
}
public boolean hasPrevious() {
return cursor > 0;
}
@SuppressWarnings("unchecked")
public E next() {
if (! hasNext())
throw new NoSuchElementException();
return (E) snapshot[cursor++];
}
}
很明显,在迭代器内部,先CopyOnWriteArrayList内部的数组引用当成一个快照赋值给COWIterator的属性 snapshot,此时就算其他线程在操作 CopyOnWriteArrayList(add/remove等操作),也是在新数组上做操作,当前迭代器操作的对象数组不是同一个,就不会说当前迭代查询的时候突然数组多了或者少了成员。
三、总结
1. COW vs 读写锁
(1) 相同点
- 两者都是通过读写分离的思想实现
- 读线程间是互不阻塞的
(2) 不同点
对读线程而言,为了实现数据实时性,在写锁被获取后,读线程会等待或者当读锁被获取后,写线程会等待,从而解决“脏读”等问题。也就是说如果使用读写锁依然会出现读线程阻塞等待的情况。而 COW 则完全放开了牺牲数据实时性而保证数据最终一致性,即读线程对数据的更新是延时感知的,因此读线程不会存在等待的情况。
2.缺点
CopyOnWrite 容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题,所以在开发的时候需要注意一下。
(1) 内存占用问题
因为 CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对 象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对 象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比 如说 200M 左右,那么再写入 100M 数据进去,内存就会占用 300M,那么这个时候很有可能造成频繁的 minor GC 和 major GC。
(2)数据一致性问题
CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用 CopyOnWrite 容器。