前言
ArrayList
是线程不安全的,这点毋庸置疑。因为ArrayList
的所有方法既没有加锁,也没有进行额外的线程安全处理。而Vector
作为线程安全版的ArrayList
,存在感总是比较低。因为无论是add
、remove
还是get
方法都加上了synchronized锁,所以效率低下。
JDK1.5引入的J.U.C包中,又实现了一个线程安全版的ArrayList
——CopyOnWriteArrayList
。
成员变量
先来看下CopyOnWriteArrayList
类的定义和底层数据结构
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
private static final long serialVersionUID = 8673264195747942595L;
/** The lock protecting all mutators */
transient final ReentrantLock lock = new ReentrantLock();
/** The array, accessed only via getArray/setArray. */
// 存储数据的array数组,注意此处是用volatile修饰的
private volatile transient Object[] array;
}
根据定义来看,比ArrayList
多了一个ReentrantLock
成员变量,存储数据的数组用volatile
修饰,其余的并没有多少区别。存储数据的结构依然是数组。
构造方法
/**
* Sets the array.
* 语法糖
*/
final void setArray(Object[] a) {
array = a;
}
/**
* Creates an empty list.
*/
public CopyOnWriteArrayList() {
setArray(new Object[0]);
}
/**
* Creates a list holding a copy of the given array.
* 创建一个保存给定数组副本的list(把参数给的数组拷贝给成员变量)
*
* @throws NullPointerException if the specified array is null
* 参数数组为null,抛出NullPointerException
*/
public CopyOnWriteArrayList(E[] toCopyIn) {
setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
}
看完构造方法依然有些疑惑,成员变量和构造方法看起来比ArrayList
还要简单,到底是如何保证线程安全的呢。或许add
方法会给我们答案。
核心方法
add(E e)
add(E e)
方法用于往list尾部添加元素,CopyOnWriteArrayList
中add(E e)
方法源码如下:
/**
* Appends the specified element to the end of this list.
* 往list尾部添加指定元素
*
* @param e element to be appended to this list
* @return <tt>true</tt> (as specified by {@link Collection#add})
*/
public boolean add(E e) {
final ReentrantLock lock = this.lock;
// 加锁
lock.lock();
try {
// 获取成员变量array[]
Object[] elements = getArray();
int len = elements.length;
// 原数组拷贝给新数组(即将添加一个元素,所以 len + 1)
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
// 新数组替换原数组
setArray(newElements);
return true;
} finally {
// 解锁
lock.unlock();
}
}
从这段代码中可以得出如下信息:
- add方法通过
ReentrantLock
保证同一时刻最多只有一个线程向list中添加元素,肯定是线程安全的 - 并不是直接往数组中添加元素,而是开辟新数组,把元素插入新数组,再用新数组替换旧数组
既然ReentrantLock
已经保证了线程安全,为什么还需要开辟新数组?
因为volatile
修饰数组时,仅能保证数组的引用具有volatile
语义。也就是说volatile
修饰的数组,即使数组中的元素被改变了,也不会触发可见性。想要解决这个问题有两种办法
- 使用
AtomicIntegerArray
或者AtomicLongArray
- 修改数组的内存地址,也就是对数组进行重新赋值
除了volatile
语义的问题,还有一个原因就是为了get
方法,下文会详细介绍这个方法。
add(int index, E element)
add(int index, E element)
方法用于往list指定位置添加元素,源码如下:
/**
* 指定位置添加元素
*
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
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;
if (numMoved == 0)
// 在尾部新增
newElements = Arrays.copyOf(elements, len + 1);
else {
// 开辟新数组
newElements = new Object[len + 1];
// 拷贝index之前的元素到新数组,拷贝前后,元素下标不变
System.arraycopy(elements, 0, newElements, 0, index);
// 拷贝index之后的元素到新数组,拷贝之后,下标+1
// 因为新数组index处需要空出来留给新增元素
System.arraycopy(elements, index, newElements, index + 1,
numMoved);
}
newElements[index] = element;
// 新数组替换原数组
setArray(newElements);
} finally {
// 解锁
lock.unlock();
}
}
这两个add
方法完成的功能不一样,但是实现步骤和原理都差不多,都可以抽象成5步:
1、加锁
2、开辟新数组
3、拷贝元素
4、新数组替换旧数组
5、解锁
CopyOnWriteArrayList
虽然底部也是数组实现,但是没有扩容这个说法。因为每次add
都会开辟新的数组。况且每次add
都会加锁,所以效率是比较低的。
remove(int index)
remove(int index)
方法用于删除并返回指定位置的元素,其源码如下:
/**
* 删除并返回指定位置的元素
*
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
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];
// 拷贝index之前的元素到新数组,拷贝前后下标不变
System.arraycopy(elements, 0, newElements, 0, index);
// 拷贝index之后的元素到新数组,拷贝之后下标-1
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
// 新数组替换原数组
setArray(newElements);
}
// 返回删除的值
return oldValue;
} finally {
// 解锁
lock.unlock();
}
}
从源码可以看出,不管是add
也好,还是remove
也好。都是通过ReentrantLock + volatile + 数组拷贝来实现线程安全的。
写到这里,也并没有看出来CopyOnWriteArrayList
比Vector
高效到哪里去,况且前者每次add/remove
操作都会开辟新数组,相当于浪费了一倍的空间。
那么,接下来就是见证奇…
咳咳,没有奇迹,来看看CopyOnWriteArrayList
的优点。
vector
效率低就低在get
也加上了synchronized
锁,但是CopyOnWriteArrayList
的get
方法就不用了加锁
get(int index)
get(int index)
方法用于获取指定位置的元素,源码如下:
/**
* {@inheritDoc}
*
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
public E get(int index) {
// 调用内部get方法
return get(getArray(), index);
}
@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
return (E) a[index];
}
可以看到get(int index)
不需要加锁,因为CopyOnWriteArrayList
在add/remove
操作时,不会修改原数组,所以读操作不会存在线程安全问题。这其实就是读写分离的思想,只有写入的时候才加锁,复制副本来进行修改。CopyOnWriteArrayList
也叫写时复制容器。
而且在迭代过程中,即使数组的结构被改变也不会抛出ConcurrentModificationException
异常。因为迭代的始终是原数组,而所有的变化都发生在原数组的副本上。所以对于迭代器来说,迭代的集合结构不会发生改变。
优缺点
CopyOnWriteArrayList
的优点主要有两个:
- 线程安全
- 大大的提高了“读”操作的并发度(相比于
Vector
)
缺点也很明显:
- 每次“写”操作都会开辟新的数组,浪费空间
- 无法保证实时性,因为“读”和“写”不在同一个数组,且“读”操作没有加互斥锁,所以不能保证强一致性,只能保证最终一致性
add/remove
操作效率低,既要加锁,还要拷贝数组
所以CopyOnWriteArrayList
比较适合读多写少的场景。
注意:千万千万不要在循环中对CopyOnWriteArrayList
进行add/remove
操作,CopyOnWriteArrayList
提供了对应的批量处理方法addAll
和removeAll
。
以下是在循环中进行add
操作和addAll
操作对比:
/**
* 循环 + add vs addAll
*/
public class CopyOnWriteArrayListDemo {
private static final int COUNT = 100000;
private static final List<Integer> list1 = new CopyOnWriteArrayList<>();
private static final List<Integer> list2 = new CopyOnWriteArrayList<>();
public static void main(String[] args) {
List<Integer> dataList = new ArrayList<>(COUNT);
for (int i = 0; i < COUNT; i++) {
dataList.add(i);
}
testCopyOnWriteArrayList(dataList);
}
private static void testCopyOnWriteArrayList(List<Integer> dataList) {
long time1 = System.currentTimeMillis();
for (Integer data : dataList) {
list1.add(data);
}
long time2 = System.currentTimeMillis();
System.out.println("循环+add 耗时:" + (time2 - time1) / 1000.0 + " 秒");
list2.addAll(dataList);
long time3 = System.currentTimeMillis();
System.out.println("addAll 耗时:" + (time3 - time2) / 1000.0 + " 秒");
}
}
执行结果
循环+add 耗时:2.604 秒
addAll 耗时:0.001 秒
这样很直观的看到了两者的效率差异。
总结
CopyOnWriteArrayList
利用ReentrantLock + volatile + 数组拷贝实现了线程安全的ArrayList
。在特定的场景下使用CopyOnWriteArrayList
既能保证线程安全,又能有较好的表现。
参考
- https://www.javamex.com/tutorials/volatile_arrays.shtml
- http://ifeve.com/volatile-array-visiblity/