概述
Java自1.5后提供了两个写时复制的容器,分别是CopyOnWriteArrayList和CopyOnWriteArraySet。其思路就是在执行会改变底层数据的结构时,首先加锁,然后复制得到一个新的数据,在这个数据上做修改,最后再将原来的数据引用指向这个新的数据,最后释放锁;而读操作则不需要修改。这是一种读写分离的思想,读和写不同的容器,读的是旧容器,写的是新容器。
由于CopyOnWriteArrayList的实现原理和CopyOnWriteArraySet类似,所以就以CopyOnWriteArrayList抛个砖。
下图是CopyOnWriteArrayList的类继承关系,如下图:
可以发现实现了List接口,直接继承了Object类,而没有像很多List一样继承AbstractList类。CopyOnWriteArrayList是一个自动扩容的List,允许任何元素,包括null。
源码分析
重要字段
CopyOnWriteArrayList名中含有“ArrayList”,所以其内部是基于数组实现的一个列表。为了使读写分离,所以不能在类自身上锁,内部含有一把锁,字段如下:
/** The lock protecting all mutators */
final transient ReentrantLock lock = new ReentrantLock();
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
add(E e)方法
下面看一下添加一个元素的方法:
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();
}
}
从上面的代码可以看到添加一个元素add方法的执行流程:
1. 对Lock加锁
2. 一旦得到锁后,根据旧数据创建新数据
3. 更改新数据
4. 将引用指向新数据
上面代码中的setArray()方法就是将array引用指向新创建的数组,该方法的实现如下:
final void setArray(Object[] a) {
array = a;
}
remove(int index)方法
下面再看一下rmeove(int index)方法,如果index超出了索引,那么会抛出IndexOutBoundsException,实现如下:
public E remove(int index) {
final ReentrantLock lock = this.lock;
//加锁
lock.lock();
try {
//得到旧数据
Object[] elements = getArray();
int len = elements.length;
//获得元素,该方法可能会抛出IndexOutBoundsException
E oldValue = get(elements, index);
//计算左移数据个数
int numMoved = len - index - 1;
//如果不需要左移,更新引用指向
if (numMoved == 0)
setArray(Arrays.copyOf(elements, len - 1));
//需要左移
else {
//创建新数据
Object[] newElements = new Object[len - 1];
//复制左半边
System.arraycopy(elements, 0, newElements, 0, index);
//复制右半边
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
//更新引用
setArray(newElements);
}
return oldValue;
} finally {
//释放锁
lock.unlock();
}
}
从上面的代码可以看到,remove()方法的执行流程和add()相同,都是:
1. 加锁
2. 复制旧数据,修改新数据
3. 将引用指向新数据
4. 释放锁
写方法总结
CopyOnWriteArrayList中的add、set、remove等方法会修改底层数组,所以当执行这些方法时会首先获取Lock锁,也就意味着同一时刻只能有一个线程在修改数据。
get(int index)方法
下面看一下get()方法的实现:
public E get(int index) {
return get(getArray(), index);
}
private E get(Object[] a, int index) {
return (E) a[index];
}
final Object[] getArray() {
return array;
}
可以看到该方法没有加锁。
size()方法
下面是size()方法的实现:
public int size() {
return getArray().length;
}
读方法总结
CopyOnWriteArrayList中的get、size、isEmpty等方法不会修改底层数据,所以当执行这些方法时不会获取Lock锁,直接对旧数据进行读取。
拓展
Java中提供了CopyOnWriteArrayList和CopyOnWriteArraySet,而没有提供CopyOnWriteMap,但是经过上面的源码分析,我们已经知道了COW的原理,那就是写时加锁,读时不加锁。按照这种思想实现了一个CopyOnWriteMap,如下:
public class CopyOnWriteMap<K, V> implements Map<K, V>{
private volatile Map<K, V> internalMap;
private ReentrantLock lock=new ReentrantLock();
public CopyOnWriteMap() {
internalMap = new HashMap<K, V>();
}
//。。。带参数的构造方法,配置HashMap
public V put(K key, V value) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Map<K,V> newMap=new HashMap(internalMap);
V v=newMap.put(key,value);
internalMap=newMap;
return v;
} finally {
lock.unlock();
}
}
public V remove(Object key) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Map<K, V> newMap = new HashMap<K, V>(internalMap);
V val = newMap.remove(key);
internalMap = newMap;
return val;
} finally {
lock.unlock();
}
}
public int size() {
return internalMap.size();
}
public boolean isEmpty() {
return internalMap.isEmpty();
}
public V get(Object key) {
return internalMap.get(key);
}
...其他方法
}
CopyOnWriteMap实现了Map接口,Map中的每个方法利用内部的HashMap实现,对于写操作对Lock加锁,而读操作则不加锁。
总结
使用场景
CopyOnWrite并发容器用于读多写少的并发场景。比如白名单,黑名单,商品类目的访问和更新场景,假如有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单当中,黑名单每天晚上更新一次。当用户搜索时,会检查当前关键字在不在黑名单当中,如果在,则提示不能搜索。
缺点
CopyOnWrite容器有很多优点,不过也有两个缺点:
1. 内存占用问题: 由于每次写操作都会复制出一个对象出来,如果旧数据已经很大,那么复制的数据也会很大,可能会导致过多的GC
2. 数据一致性问题: COW容器只能确保最终数据的一致性,不能保证实时数据一致性。如果你希望写入的数据,马上能被读到,请不要使用COW容器。
原理
读写分离,写时加锁,读时不加锁。