文章目录

  • 1. 引入
  • 2. 源码剖析
  • 3. 总结



1. 引入

我们都知道java.util包下的ArrayList是线程不安全的,如果想要在多线程且存在竞争的场景下使用ArrayList,就需要通过一定的逻辑来保证线程安全。常用于解决ArrayList线程安全问题的方案有:

  • Vector:类似于HashMap和Hashtable的关系,通过在可能发生线程安全问题的方法上直接使用synchronized关键字进行保证
public synchronized E get(int index) {
    if (index >= elementCount)
        throw new ArrayIndexOutOfBoundsException(index);

    return elementData(index);
}

 public synchronized boolean add(E e) {
    modCount++;
    ensureCapacityHelper(elementCount + 1);
    elementData[elementCount++] = e;
    return true;
}
  • synchronizedList:类似于synchronizedMap,方法在执行前需要竞争通过synchronized竞争互斥锁,锁粒度同样较大
public E get(int index) {
   synchronized (mutex) {return list.get(index);}
}

public E set(int index, E element) {
   synchronized (mutex) {return list.set(index, element);}
}

public void add(int index, E element) {
   synchronized (mutex) {list.add(index, element);}
}

public E remove(int index) {
   synchronized (mutex) {return list.remove(index);}
}

更好的选择是使用JUC下的CopyOnWriteArrayList,见名知意:复制写的ArrayList。盲猜它通过类似COW机制的思想来保证线程安全,即:

  • 多线程读时不会有线程安全问题,不需要加锁,可并发读
  • 多线程写时,实际上操作的是ArrayList的副本,因此写请求并不会阻塞读请求

到底是不是这样呢?


2. 源码剖析

对于CopyOnWriteArrayList来说,写操作需要加锁,方法并发写入导致数据丢失。它并不会直接操作原数组,而是先复制一个数组再进行操作,写操作执行结束后在将复制后的数组赋值给原数组。CopyOnWriteArrayList在写操作的同时允许读操作,大大提高了读操作的性能。

下面通过剖析一下集合常用方法的源码实现,来看一下它是如何做到线程安全的。它首先在属性字段定义了ReentrantLock锁对象和被volatile修饰的对象数组:

public class CopyOnWriteArrayList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    private static final long serialVersionUID = 8673264195747942595L;

    /** The lock protecting all mutators */
    final transient ReentrantLock lock = new ReentrantLock();

    /** The array, accessed only via getArray/setArray. */
    private transient volatile Object[] array;

对于get()来说,由于不存在线程安全问题,所以可以直接返回索引对应的元素:

private E get(Object[] a, int index) {
	return (E) a[index];
}

get()的实现如下:

public boolean add(E e) {
    final ReentrantLock lock = this.lock;  // 获取锁
    lock.lock();   // 加锁
    try {
        Object[] elements = getArray();  // 获取属性字段定义的array
        int len = elements.length;  // 获取array的长度
        Object[] newElements = Arrays.copyOf(elements, len + 1);   // 获取数组的副本
        newElements[len] = e;   // 在副本上执行添加操作
        setArray(newElements);    //  将副本复制给原数组
        return true;
    } finally {
        lock.unlock();    // 释放锁
    }
}

其中getArray()setArray()的方法实现如下:

final Object[] getArray() {
    return array;
}

/**
 * Sets the array.
 */
final void setArray(Object[] a) {
    array = a;
}

从源码的实现可以看出,add()的逻辑如下:

  • 首先获取ReentrantLock对象,进行加锁保证线程安全
  • 获取array的副本,并将新元素添加到数组尾部
  • 将副本复制给原数组
  • 释放锁

对于在指定的索引处添加新元素的add()方法逻辑类似,源码实现如下:

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];
            System.arraycopy(elements, 0, newElements, 0, index);
            System.arraycopy(elements, index, newElements, index + 1,
                             numMoved);
        }
        newElements[index] = element;  // 插入新元素
        setArray(newElements);
    } finally {
        lock.unlock();
    }
}

其他涉及到线程安全问题的方法都需要使用ReentrantLock和数组复制来保证线程安全,详细实现可见源码。


3. 总结

总体来说,CopyOnWriteArrayList需要使用ReentrantLock来加锁和释放锁保证线程安全。因此,适合于读多写少的应用场景,不适合内存敏感和对数据实时性要求高的场景。不足之处在于:

  • 内存占用:复制的新数组需要占用内存空间
  • 数据不一致:读操作不能读取实时的数据,即读操作不能读取到写操作还没有同步到源数组中的数据