本文原载于我的博客,地址:https://blog.guoziyang.top/archives/62/
在ArrayList
类的注释中,就已经提到了,ArrayList是线程不安全的类,不建议作为线程的共享变量使用。那么,是否有线程安全的List呢?当然有,那就是Vector……才怪
Vector类的方法仅仅是将所有的方法都加上的synchronized关键字,强制将并发转为串行,效率低下。好在,JDK在java.util.concurrent
包(即常说的JUC)下,提供了一个线程安全的另一个List,即CopyOnWriteArrayList
。
PS:并发状态下使用List也可以使用Collections.synchronizedList
方法。
这个类的签名如下:
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
类注释中是这样说的:
这个类是ArrayList的一个线程安全的版本,所有的修改操作,如add、set等,都是通过复制底层数组实现的。
CopyOnWrite,简称COW,即写时复制,如果学过操作系统的同学可能就会知道,在Linux系统底层并发相关的部分,大量使用了这个思想。
在CopyOnWriteArrayList内部,通过锁 + 数组拷贝 + volatile关键字保证了线程安全。而数组拷贝,通常发生在修改时,即CopyOnWrite。
根据类注释,我们就知道,我们只需要关注修改相关的操作即可。
CopyOnWriteArrayList类中有两个比较重要的成员变量,如下:
/** The lock protecting all mutators */
final transient ReentrantLock lock = new ReentrantLock();
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
一个是JUC中的可重入锁,ReentrantLock对象,用于修改操作时的加锁,另一个就是一个Object数组,很显然,它用来实际存储内容,即“底层数组”。
Object数组对象array,被volatile关键字修饰,保证数组在修改时立刻会被其他线程感知到,因为使用该关键字变量在使用时都必须从内存中获取最新的,而不允许使用缓存。
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();
}
}
该方法的第一件事即加锁,注意所有的修改方法的第一件事都是加锁,这样就保证了同一时刻只能有一个线程在修改数组,即修改操作是串行化的。
try子句中,首先获取到了elements,即List的底层数组,接着在第7行,通过Arrays.copyOf方法,将原数组拷贝到了一个新的数组中,这个数组的长度是原数组长度加一。第8行的添加操作也是在原数组上完成的,最终,通过setArray方法使用新数组替换原数组的引用,setArray方法也很简单:
/**
* Sets the array.
*/
final void setArray(Object[] a) {
array = a;
}
那么原数组哪去了呢?原数组可能会在下一次垃圾回收的时候被回收掉。
这就是一个很明显的CopyOnWrite的体现,那么,为什么要将数组复制一份,而不是直接在原来的数组上操作呢?有一个很重要的地方,就是volatile关键字。volatile关键字修饰的是数组,即数组的这个引用,如果只是修改数组中的元素时,可见性是无法保证的,所以必须要修改数组的地址,即创建一个新的数组。而且,在新的数组上进行添加等操作,对老数组没有影响,只有拷贝完全后,外界才能访问到,降低了在赋值过程中,老数组数据变动的影响。
这是添加到尾部的代码,而添加到某个固定位置的方法,即add(int index, E element)
方法的核心实现是这样的(try子句的内容):
Object[] elements = getArray();
int len = elements.length;
if (index > len || index < 0)
throw new IndexOutOfBoundsException("Index: "+index+
", Size: "+len);
Object[] newElements;
int numMoved = len - index;
if (numMoved == 0)
newElements = Arrays.copyOf(elements, len + 1);
else {
newElements = new Object[len + 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index, newElements, index + 1,
numMoved);
}
newElements[index] = element;
setArray(newElements);
注意,需要判断一下有没有越界。而且,如果numMoved,也就是index后面的元素个数为0的话,只需要复制一次,不为0的话,即向中间插入,就需要分两次复制到新数组了。
有人可能就问了,哎,怎么没有扩容啊?
众所周知,普通的ArrayList最耗时的操作就是扩容了,所以ArrayList在每次扩容的时候都会预留出一部分空间,以尽量减少扩容的次数。那么扩容为什么耗时呢?因为数组拷贝。现在使用CopyOnWriteArrayList,每次add都要进行数组拷贝,即使预留空间了也要数组拷贝,这个预留空间的意义就不大了,不如每次都放到大小正好的数组里了。
所以,CopyOnWriteArrayList的底层数组,在任何时候,都是没有空位置了,不只是添加,删除后也不会有空位置(顺便填上了)。
set与add类似,只是复制到相同大小的数组里就行了。
remove方法如下:
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)
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();
}
}
方法开头,很常规的加锁。接着计算numMoved,这个值是被删除元素的后面元素的个数。如果这个数为0,也就是删除结尾的元素,只需要进行一次数组复制,复制到长度减1的数组即可。否则,说明是删除了中间的元素,就需要分两次进行复制了。
完成了删除操作后,底层数组的长度减1,即没有产生空位。
List的批量删除操作,即removeAll(),通过传入一个集合,删除原集合中所有存在于这个集合中的元素。实现如下:
public boolean removeAll(Collection<?> c) {
if (c == null) throw new NullPointerException();
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
if (len != 0) {
// temp array holds those elements we know we want to keep
int newlen = 0;
Object[] temp = new Object[len];
for (int i = 0; i < len; ++i) {
Object element = elements[i];
if (!c.contains(element))
temp[newlen++] = element;
}
if (newlen != len) {
setArray(Arrays.copyOf(temp, newlen));
return true;
}
}
return false;
} finally {
lock.unlock();
}
}
这个实现很有趣,它没有查找要删除的元素,然后一个一个删除。而是遍历一遍数组,把所有不需要删除的元素挑出来,放到新的数组中,变相地实现了删除元素。
我们通过iterator方法可以获得一个List的迭代器:
public Iterator<E> iterator() {
return new COWIterator<E>(getArray(), 0);
}
这里返回的类名为COWIterator,COW即CopyOnWrite,这是一个专为CopyOnWriteArrayList实现的迭代器。构造方法如下:
private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}
后续的遍历都是对elements数组进行遍历。
注意,在遍历过程中,如果进行修改的话,和ArrayList不同的是,并不会抛出ConcurrentModification异常。为什么呢?
我们注意到,通过iterator方法创建的迭代器,传入的是底层数组的引用,那么在迭代过程中,如果产生了修改,因为使用了COW技术,是由一个新的数组替换了老的数组的引用,但是此时,迭代器内部仍然使用的是老数组,也就是说,整个迭代期间,迭代器都会使用创建迭代器时的底层数组。如果在迭代过程中进行了多次修改,只有最后一次才会生效。
CopyOnWriteArrayList的实现中,读取时不需要对对象加锁,只有修改时需要加锁,而且修改时需要拷贝数组,性能较差,所以CopyOnWriteArrayList适用于读多写少对情景。而且,由于在修改时是对新数组进行修改,接着替换引用,那么在并发状态下,读线程可能会读取到旧的数据。