ArrayList是常用的Java数据结构,不过在多线程环境下对ArrayList进行并发修改会造成很多意想不到的错误:
- 并发导致数据丢失
- 并发导致插入null
- 并发导致数组越界
所以ArrayList不是线程安全的类,在并发环境下需要使用线程安全的ArrayList进行修改操作,线程安全的ArrayList有这些:Vector和CopyOnWriteArrayList
官方不推荐使用Vector,毕竟它是一个遗留类,而且并发性能很差。这里就来聊一聊CopyOnWriteArrayList这个并发容器,看看它是如何解决并发问题的。
CopyOnWriteArrayList简介
在很多的应用场景中,读操作的可能会远远大于写操作。对于这些场景我们希望是读操作尽可能地快,而写操作慢一些也没有太大的关系。由于读操作根本不会修改原有的数据,因此对于每一次的读取都进行加锁是一种资源的浪费。根据读写锁的思想,读锁与读锁之间不冲突。但是读操作会受到写操作的阻碍,当写操作发生时,读就必须等待。否则可能读到不一致的数据。同时,如果读操作正在进行,程序也不能进行写入。
CopyOnWriteArrayList,从名字上看,在写入操作时,进行一次自我复制。也就是对原有的数据进行一次复制,将修改的内容写入副本中。写完之后,再将修改完的副本替换原来的数据。
CopyOnWriteArrayList的特点:
- 实现了List接口
- 内部持有一个ReentrantLock lock = new ReentrantLock();
- 底层是用volatile transient声明的数组 array
- 读写分离,写时复制出一个新的数组,完成插入、修改或者移除操作后将新数组赋值给array
接下来看一下CopyOnWriteArrayList的源码,理解一下它是如何做到这点的吧
CopyOnWriteArrayList源码解析
概览
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
//序列号
private static final long serialVersionUID = 8673264195747942595L;
/**
* 可重入锁,对数组增删改时,使用它加锁
*/
final transient ReentrantLock lock = new ReentrantLock();
/**
* 存放元素的数组,其实就是本体
*/
private transient volatile Object[] array;
}
怎么说呢, CopyOnWriteArrayList的成员变量没有比ArrayList多,比较精简,这么设置跟 CopyOnWriteArrayList的特性有关,接下来就可以看出来了
构造方法
/**
* 默认构造方法,构建数组长度为0,
* 没错就是0,接下来会知道为什么这么做
*/
public CopyOnWriteArrayList() {
setArray(new Object[0]);
}
/**
* 通过集合类来构造
*/
public CopyOnWriteArrayList(Collection<? extends E> c) {
Object[] elements;
if (c.getClass() == CopyOnWriteArrayList.class)
elements = ((CopyOnWriteArrayList<?>)c).getArray();
else {
elements = c.toArray();
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elements.getClass() != Object[].class)
elements = Arrays.copyOf(elements, elements.length, Object[].class);
}
setArray(elements);
}
/**
* 通过数组来构造
*/
public CopyOnWriteArrayList(E[] toCopyIn) {
setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
}
在上述的构造方法中都使用了setArray()这个方法,不过这个方法只是将array引用指向对应的数组对象罢了,这个方法不是关键。
//设置数组引用指向的数组对象
final void setArray(Object[] a) {
array = a;
}
get方法
//这个是真正用于查询的方法
@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
return (E) a[index];
}
/**
* 向外开放的方法
*/
public E get(int index) {
return get(getArray(), index);
}
final Object[] getArray() {
return array;
}
从上面的代码来看,在对CopyOnWriteArrayList读取元素时,根本没有加锁,这极大的避免了加锁带来的开销。
接下来,CopyOnWriteArrayList核心就要来了,看大师如何将CopyOnWriteArrayList打造成线程安全的ArrayList。
add方法
//直接将元素添加到末尾
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;
//将array引用指向新的数组,原来的数组会被垃圾收集器回收
setArray(newElements);
return true;
} finally {
//释放锁
lock.unlock();
}
}
//在指定位置插入新元素
public void add(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock();
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;
//若为0,则是添加到数组末尾
if (numMoved == 0)
newElements = Arrays.copyOf(elements, len + 1);
else {
//不为0,则将原数组分开复制
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);
} finally {
lock.unlock();
}
}
看了add()方法,一切疑问都解释了,我来梳理一下:
- CopyOnWriteArrayList刚创建时,默认的大小为0,当向其插入一个元素时,将原数组复制到一个比原数组大1的新数组中,然后直接将插入的元素放置到新数组末尾,之后修改array引用到新数组就可以,原来的数组就会被垃圾收集器回收。
- 初始化为什么要设置数组大小为0呢,这是因为每次进行添加操作时,都会复制原数组到新的数组中,相当于CopyOnWriteArrayList在进行add操作时,实际占用的空间是原来的两倍,这样的空间开销,导致了CopyOnWriteArrayList不能像ArrayList那样初始化大小为10,不然太浪费空间了,而且CopyOnWriteArrayList主要用于读多写少的地方。因为CopyOnWriteArrayList在进行增删改操作时,都是在新数组上进行,所以此时对CopyOnWriteArrayList进行读取完全不会导致阻塞或是出错。CopyOnWriteArrayList通过将读写分离实现线程安全。
set方法
//修改元素
public E set(int index, E element) {
final ReentrantLock lock = this.lock;
//加锁
lock.lock();
try {
Object[] elements = getArray();
E oldValue = get(elements, index);
//判断原来的元素的值是否等于新值,相等则直接修改array的引用(这么做为了防止remove误删元素,见下面),
//不相等则复制一份到新数组中再进行修改
if (oldValue != element) {
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len);
newElements[index] = element;
setArray(newElements);
} else {
// Not quite a no-op; ensures volatile write semantics
setArray(elements);
}
return oldValue;
} finally {
//释放锁
lock.unlock();
}
}
set方法也体现了CopyOnWriteArrayList的特点,源码上有注释,这里就不过多解释了。
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);
//这里跟add方法很像,判断删除元素的下标与数组末尾下标的距离
//如果为0,则是删除末尾元素,直接将前面的元素复制到新数组中
int numMoved = len - index - 1;
if (numMoved == 0)
setArray(Arrays.copyOf(elements, len - 1));
else {
//若不为0,则创建一个比原来数组小1的新数组,再将要删除元素下标之外的所有元素全部复制到新数组
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();
}
}
//通过元素删除元素
public boolean remove(Object o) {
Object[] snapshot = getArray();
//获取元素下标
int index = indexOf(o, snapshot, 0, snapshot.length);
return (index < 0) ? false : remove(o, snapshot, index);
}
/**
* 删除方法本体
*/
private boolean remove(Object o, Object[] snapshot, int index) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] current = getArray();
int len = current.length;
//若在执行romove操作时,数组已经改变了,则需要对要删除的元素重新定位,防止误删(因为这个删除方法在最初进入时没有加锁,在这个时候可能会发生改变)
if (snapshot != current) findIndex: {
//取最小的遍历范围
int prefix = Math.min(index, len);
for (int i = 0; i < prefix; i++) {
//找到元素对应下标,跳出,重新判断
if (current[i] != snapshot[i] && eq(o, current[i])) {
index = i;
break findIndex;
}
}
//若没有在指定范围中找到对应元素,则进行下一步判断
//元素被删除或不存在
if (index >= len)
return false;
if (current[index] == o)
break findIndex;
index = indexOf(o, current, index, len);
//元素不存在或是被删除
if (index < 0)
return false;
}
//删除
Object[] newElements = new Object[len - 1];
System.arraycopy(current, 0, newElements, 0, index);
System.arraycopy(current, index + 1, newElements, index, len - index - 1);
setArray(newElements);
return true;
} finally {
//释放锁
lock.unlock();
}
}
private static boolean eq(Object o1, Object o2) {
return (o1 == null) ? o2 == null : o1.equals(o2);
}
private static int indexOf(Object o, Object[] elements, int index, int fence) {
if (o == null) {
for (int i = index; i < fence; i++)
if (elements[i] == null)
return i;
} else {
for (int i = index; i < fence; i++)
if (o.equals(elements[i]))
return i;
}
return -1;
}
remove也使用也这样的方法来实现读写分离,不过第二个remove方法因为可能会出现在加锁之前发生修改,所以需要重新判断。
总结
从以上的增删改查中我们可以发现,增删改都需要获得锁,并且锁只有一把,而读操作不需要获得锁,支持并发。为什么增删改中都需要创建一个新的数组,操作完成之后再赋给原来的引用?这是为了保证get的时候都能获取到元素,如果在增删改过程直接修改原来的数组,可能会造成执行读操作获取不到数据。
CopyOnWriteArrayList为什么并发安全且性能比Vector好
Vector是增删改查方法都加了synchronized,保证同步,但是每个方法执行的时候都要去获得锁,性能就会大大下降,而CopyOnWriteArrayList 只是在增删改上加锁,但是读不加锁,在读方面的性能就好于Vector,CopyOnWriteArrayList支持读多写少的并发情况。
CopyOnWriteArrayList缺点
- 最大的缺点就是在进行增删改操作时会让CopyOnWriteArrayList占用内存翻倍,如果是对有大量元素的CopyOnWriteArrayList进行增删改,空间消耗是不容忽视的。
- CopyOnWriteArrayList还有一个缺点是数据不具有实时性,即对CopyOnWriteArrayList进行增删改操作,与此同时有其他线程来访问CopyOnWriteArrayList中的元素,因为增删改操作未完成,所以读取元素的线程看不到新数据。如果强调数据的实时性,那么CopyOnWriteArrayList并不是一个好的选择。