如何线程安全的操作一个数组对象,类似于前面分析的ArrayBlockingQueue以及Vector,实际上都是使用加锁来实现,只不过第一个底层是使用ReentrantLock,第二个底层采用synchronized关键字来实现。对于任意一个线程访问数组,都会阻塞其他线程。但是实际上对于一个数组来说,当我们并发的去读的时候是不会出现并发问题的,因此如果可以在并发读的情况下不加锁,而在修改的时候加锁,理论上是可以增加吞吐量的。
而实际上,并发读不加锁,修改时阻塞其他修改线程,这里实际上使用一个新的数组来修改,可以认为这是一个新的副本上的操作,因此不会影响其他读线程的读操作,当修改线程完成修改之后会整体替换原来的数组。这就是整个CopyOnWriteArrayList的基本原理。
再来说一下为什么这样快一些,在普通的写线程加锁阻塞这一过程中,所有的读线程都是会阻塞的,而在CopyOnWriteArrayList中,当有线程修改数组,直接在副本上操作,完全不影响原来的读线程,这样就会极大的增加并发量。这里相当于以空间换时间。
CopyOnWriteArrayList重要参数和构造函数
底层是一个非公平锁+一个数组(使用了volatile关键字,修改了立刻可见)。构造函数可以不传参数,此时生成一个空的数组,或者是可以传入一个容器类。通过getArray和setArray来分别用来获得底层数组和替换底层数组。
/** 非公平锁 */
final transient ReentrantLock lock = new ReentrantLock();
/** 全局可见数组. */
private transient volatile Object[] array;
/**
* Gets the array. Non-private so as to also be accessible
* from CopyOnWriteArraySet class.
*/
final Object[] getArray() {
return array;
}
/**
* Sets the array.
*/
final void setArray(Object[] a) {
array = a;
}
/**
* Creates an empty list.
*/
public CopyOnWriteArrayList() {
setArray(new Object[0]);
}
/**
* Creates a list containing the elements of the specified
* collection, in the order they are returned by the collection's
* iterator.
*
* @param c the collection of initially held elements
* @throws NullPointerException if the specified collection is null
*/
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);
}
读操作
读操作很简单,就是一个数组里根据下标值获取元素,这里不会有任何阻塞操作。
// Positional Access Operations
@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
return (E) a[index];
}
/**
* {@inheritDoc}
*
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
public E get(int index) {
return get(getArray(), index);
}
写操作
以set方法和add方法为例,我们可以看出这两个操作都在开始的时候加锁,也就是说如果这个时候有其他线程想要修改数组的时候都会被阻塞。这里的具体过程是获取原来的旧数组,然后根据旧数组copy出一个新的数组,在上面进行修改和添加的操作。这样的话是不影响读操作的,当线程修改完成数组之后,将原来的数组引用指向新数组就可以了。
/**
* Replaces the element at the specified position in this list with the
* specified element.
*
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
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;
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();
}
}
/**
* 添加操作更为简单,首先加锁,生成一个新数组,然后在后面添加一个元素。
*
* @param e element to be appended to this list
* @return {@code true} (as specified by {@link Collection#add})
*/
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();
}
}
总结
感觉CopyOnWriteArrayList中的方法都是比较简单的,重要的可能是其中有关读写分离的思想,对于读和写来说,本质上操作的是不一样的容器。
CopyOnWriteArrayList不会在写的时候阻塞读线程,这样就可以大大的增加并发量。
如果写操作比较多的时候,会产生大量的冗余数组,因此实际上CopyOnWriteArrayList更适合读多写少的情况。
CopyOnWriteArrayList的读操作,不能保证数据是实时一致的,比如在写的时候读线程读的仍旧是旧的数组。因此一致性很强的时候不要使用这个类。
参考资料
http://ifeve.com/java-copy-on-write/
https://www.jianshu.com/p/5f570d2f81a2
https://blog.csdn.net/hua631150873/article/details/51306021
https://blog.csdn.net/linsongbin1/article/details/54581787