1. 前言
提到Java语言中的List容器,最先想到的应该是ArrayList和LinkedList,使用频率最高了。但是很可惜,它俩都不是线程安全的容器类,如果多线程并发操作同一个容器对象,就会出现问题。
【如何创建一个线程安全的List?】
有两种方式,一种是使用Collections.synchronizedList()
将线程不安全的List包装成线程安全的。它的原理很简单,使用【装饰者模式】将不安全的方法加上synchronized
关键字来保证同步。优点是实现简单,缺点是性能较差。
第二种方式就是我们今天要说的主角CopyOnWriteArrayList
,它用到了一种被叫作【写时复制】的技术,使得读操作永远不会加锁,写操作时进行一次自我复制,写入完成后再完成数据的替换。这样只有写写才会冲突等待,读读、读写都不会冲突。
因此,CopyOnWriteArrayList更适合【读多写少】的场景,它的读操作是无锁的,效率非常高,但是写操作会进行数据的复制,如果本身存储的数据量非常的大,复制本身就是一个开销很大的操作,而且会给GC带来额外的压力。使用时,需要确保写操作的频率不会太高,而且可以接受写操作的耗时。
2. 源码分析
通过阅读源码来揭开CopyOnWriteArrayList的神秘面纱。
2.1 类图
实现了List接口,代表它是一个有序的列表容器,支持迭代遍历等操作。实现了Cloneable接口,代表它支持克隆,使用的是【浅拷贝】模式。实现了RandomAccess接口,代表它支持【快速随机访问】,因为它底层数据结构是数组,支持通过下标快速访问。
2.2 成员变量
// 写操作竞争的锁对象
final transient ReentrantLock lock = new ReentrantLock();
// 基于数组实现的,只能通过getArray/setArray来读写
private transient volatile Object[] array;
- lock:写操作时竞争的锁对象,使用的是JDK提供的显式锁ReentrantLock。
- array:数组对象,底层使用数组来存储元素。
属性还是非常简单的,很好理解。
接下来看方法,CopyOnWriteArrayList源码很长,篇幅原因,这里只贴核心方法。
2.3 构造函数
CopyOnWriteArrayList提供了三个构造函数,分别如下:
// 默认构造函数,创建一个空数组
public CopyOnWriteArrayList();
// 给定一个集合容器创建
public CopyOnWriteArrayList(Collection<? extends E> c);
// 给定一个数组创建
public CopyOnWriteArrayList(E[] toCopyIn);
默认的空参构造函数会创建一个长度为0的数组,因为CopyOnWriteArrayList没有扩容的概念,array指向的永远是一个不可变数组。每次add
操作都会拷贝一个原数组长度加1的新数组,再把数据拷贝到新数组中。
另外两个构造函数,无非就是将Collection容器或给定数组做一个浅拷贝后赋值给array,这里不做解释。
需要注意的是,Collection类有一个toArray()
方法,它可以获取Collection容器内部的数组对象,但是它返回的不一定是Object[]
,例如Arrays.asList()
就可能返回Integer[]
,这就会出现类型不兼容的问题,因此CopyOnWriteArrayList会基于它创建一个新的Object[]
再复制数据。
public CopyOnWriteArrayList(Collection<? extends E> c) {
Object[] elements;
if (c.getClass() == CopyOnWriteArrayList.class)
elements = ((CopyOnWriteArrayList<?>)c).getArray();
else {
elements = c.toArray();
/*
Collection.toArray()部分子类的实现可能不会返回Object[],例如:Arrays.asList()可能会返回Integer[]
因此这里必须做处理,如果返回的不是Object[],则通过Arrays.copyOf()拷贝一个Object[]
*/
if (elements.getClass() != Object[].class)
elements = Arrays.copyOf(elements, elements.length, Object[].class);
}
setArray(elements);
}
2.4 核心方法
由于CopyOnWriteArrayList源码实在太长了,篇幅原因不可能贴出所有代码,因此这里只记录一些比较重要的核心方法。
2.4.1 add
向容器中添加元素时,需要竞争锁,同一时刻最多只有一个线程可以操作。因为是【写时复制】的,写入数据时不应该影响其他线程的读取,因此不会直接在array数组上操作,而是拷贝一个新的数组,元素设置完成后再覆盖旧数组。
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);
// 填充要追加的元素e
newElements[len] = e;
// 覆盖旧数组
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
2.4.2 get
通过下标获取元素,代码不能再简单了,直接从array数组中取就行了。因为是【写时复制】的,可能在访问时已经有新的元素加入,或者有元素被删除,这是会存在延迟的,不是实时的,这也算是它的一个缺点。
public E get(int index) {
// getArray()获取的就是array
return get(getArray(), index);
}
private E get(Object[] a, int index) {
return (E) a[index];
}
2.4.3 remove
remove也是写操作,只有竞争到锁的线程才能执行。它先是取出对应下标的旧元素,然后新建了一个原数组长度减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)
// 删除的是最后一个元素,直接拷贝一个长度-1的数组写回array即可
setArray(Arrays.copyOf(elements, len - 1));
else {
// 删除的是中间元素,拷贝一个长度-1的数组
Object[] newElements = new Object[len - 1];
// 拷贝前半段元素
System.arraycopy(elements, 0, newElements, 0, index);
// 拷贝后半段元素
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
// 写回array
setArray(newElements);
}
return oldValue;
} finally {
lock.unlock();
}
}
2.4.4 size
获取元素的数量也是非常简单,直接取数组的长度即可。因为CopyOnWriteArrayList的数组是不可变数组,它始终是一个被填充满的数组对象,没有扩容的操作,因此也没有必要像ArrayList一样,额外使用一个int size
来记录数量。
public int size() {
return getArray().length;
}
2.4.5 set
set
方法用来给指定下标设置值,同时会返回旧值。它也是一个写入操作,因此也需要竞争到锁才能执行。为了不影响其它线程读取,它会拷贝一个同样长度的新数组,然后做数据拷贝,在新数组上完成新值的设置,最终再写回array。
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;
// 拷贝一个一样的数组,替换下标元素,并写入array
Object[] newElements = Arrays.copyOf(elements, len);
newElements[index] = element;
setArray(newElements);
} else {
// 即使元素没有变化,也要写入array,确保volatile的写语义
// Not quite a no-op; ensures volatile write semantics
setArray(elements);
}
return oldValue;
} finally {
lock.unlock();
}
}
细心的同学也许会发现一个很有意思的操作,即使设置的元素没有改变,它还是会调用setArray(elements)
给array重新赋值。这个步骤看起来好似没有任何意义,也不影响array本身的可见性,那为何Doug Lea大神还要多此一举呢?
其实,注释里已经说明了这一点。这一步并非没有意义,它是为了确保volatile关键字的语义。什么语义呢?其实和array的【可见性】无关,和【指令重排序】有关,它是为了保证其他非volatile关键字修饰的属性不被重排序,和happens-before
原则有关。
3. 总结
CopyOnWriteArrayList是基于【写时复制】技术实现的,适用于【读多写少】场景下的线程安全的并发容器。读操作永远不会加锁,读读、读写都不会冲突,只有写写需要等待。写操作时,为了不影响其它线程的读取,它会进行一次自我复制,待数据写入完成后再替换array数组。array数组是被volatile修饰的,它被修改后可以被其他线程立刻发现。