1.简介
在ArrayList的类注释上,JDK就有相应提示,如果要把ArrayList作为共享变量的话,是线程不安全的,推荐开发者自己加锁或者使用Collections的synchronizedList方法,其实JDK还提供了另外一种线程安全的List,叫做CopyOnWriteArrayList,这个List具有以下特征。
- 是线程安全的,多线程环境下可以直接使用,无需加锁。
- 通过锁 + 数组拷贝 + volatile关键字保证了线程安全。
- 每次数组操作,都会把数组拷贝一份,在新数组上进行操作,操作成功之后再赋值回去。
2.架构
从架构上来说,CopyOnWriteArrayList数据结构和ArrayList是一致的,底层是数组,只不过CopyOnWriteArrayList在对数组进行操作的时候,需要按以下思路进行。加锁、从原数组中拷贝出新数组、在新数组上进行操作,并把新数组赋值给数组容器和解锁。除了加锁之外,CopyOnWriteArrayList的底层数组还被volatile关键字修饰,意思是一旦数组被修改,其它线程立马能够感知到。整体上来说,CopyOnWriteArrayList就是利用锁 + 数组拷贝 + volatile关键字保证了List的线程安全。
3.类注释
- 所有的操作都是线程安全的,因为操作都是在新拷贝数组上进行的。
- 数组的拷贝虽然有一定的成本,但往往比一般的替代方案效率高。
- 迭代过程中,不会影响到原来的数组,也不会抛出ConcurrentModificationException异常。
4.新增
新增有很多种情况,如新增到数组尾部、新增到数组某一个索引位置、批量新增等等,操作的思路就是架构中所说的思路,下面以新增到数组尾部的方法为例来分析其实现方式,具体源码如下所示。
源码
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
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 {
//finally里面释放锁,保证即使try发生了异常,仍然能够释放锁
lock.unlock();
}
}
}
源码解析
- 从源码中发现整个add过程都是在持有锁的状态下进行的,通过加锁,来保证同一时刻只能有一个线程能够对数组进行操作。
- 除了加锁之外,还会从老数组中创建出一个新数组,然后把老数组的值拷贝到新数组中,这时候就有一个问题,都已经加锁了,为什么需要拷贝数组,而不是在原来数组上面进行操作呢?主要原因有两个,一是volatile关键字修饰的是数组,如果简单的在原来数组上修改其中某几个元素的值,是无法触发可见性的,因此必须通过修改数组的内存地址才行,也就是说需要对数组进行重新赋值才行。二是在新的数组上进行拷贝,对老数组没有任何影响,只有新数组完全拷贝完成之后,外部才能访问到,降低了在赋值过程中,对 老数组数据变动的影响。
- CopyOnWriteArrayList通过加锁 + 数组拷贝+ volatile来保证线程安全,每一个要素都有着其独特的含义。加锁操作保证了同一时刻数组只能被一个线程操作。数组拷贝保证了数组的内存地址被修改后触发volatile的可见性,其它线程可以立马感知数组已被修改。volatile关键字保证了值被修改后,其它线程能够立马感知最新值。三个要素缺一不可,如只使用1和3,去掉2,这样当修改数组中某个值时,并不会触发volatile的可见性,只有当数组内存地址被修改后,才会把最新值通知给其他线程。
5.删除
删除也有多种情况,如删除某个索引位置的数据、批量删除等等,下面以删除某个索引位置数据的方法举例来分析其实现方式,具体源码如下所示。
源码
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
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 {
//如果删除的数据在数组的中间,则分三步走
//1.新数组长度是原数组长度减1,因为是减少一个元素
//2.从头开始拷贝数据到数组新位置
//3.从新位置拷贝数据到数组尾部
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();
}
}
}
源码解析
- 从源码中可以知道,删除一个元素主要分三步走,加锁、判断删除索引的位置,从而进行不同策略的拷贝、解锁。
- 代码整体的结构风格也比较统一,锁 + try finally + 数组拷贝,锁被final修饰的,保证了在加锁过程中,锁的内存地址肯定不会被修改,finally保证锁一定能够被释放,数组拷贝是为了删除其中某个位置的元素。
6.查找
indexOf方法的主要用处是查找元素在数组中的下标位置,如果元素存在就返回元素的下标位置,元素不存在的话返回-1,不但支持null值的搜索,还支持正向和反向的查找,下面以正向查找为例,通过源码来说明一下其底层的实现方式,具体源码如下所示。
源码
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
/**
* 查找元素在数组中的下标位置
*
* @param o 需要搜索的元素
* @param elements 搜索的目标数组
* @param index 搜索开始的位置
* @param fence 搜索结束的位置
* @return int
*/
private static int indexOf(Object o, Object[] elements, int index, int fence) {
//支持对null的搜索
if (o == null) {
for (int i = index; i < fence; i++)
//找到第一个null值,返回下标索引的位置
if (elements[i] == null)
return i;
} else {
//通过equals方法来判断元素是否相等,如果相等,返回元素的下标位置
for (int i = index; i < fence; i++)
if (o.equals(elements[i]))
return i;
}
return -1;
}
}
源码解析
indexOf方法在CopyOnWriteArrayList内部使用也比较广泛,如在判断元素是否存在时、删除元素方法中校验元素是否存在时,都会使用到indexOf方法。indexOf方法通过一次for循环来查找元素,在调用此方法时,需要注意如果找不到元素时,返回的是-1。