CopyOnWriteArrayList 源码解析和设计思路

CopyOnWriteArrayList 源码解析和设计思路

在 ArrayList 的类注释中,JDK 就提醒了我们,如果要把 ArrayList 作为共享变量的话,是线程不安全的,推荐我们自己加锁或者使用 Collections.synchronizedList 方法,其实 JDK 还提供了另外一种线程安全的 List,叫做 CopyOnWriteArrayList,这个 List 具有以下特征:

  1. 线程安全的,多线程环境下可以直接使用,无需加锁
  2. 通过锁 + 数组拷贝 + volatile 关键字保证了线程安全
  3. 每次数组操作,都会把数组拷贝一份出来,在新数组上进行操作,操作成功之后再赋值回去

整体架构

从整体架构上来说,CopyOnWriteArrayList 数据结构和 ArrayList 是一致的,底层是个数组,只不过 CopyOnWriteArrayList 在对数组进行操作的时候,基本会分四步走:

  1. 加锁
  2. 从原数组中拷贝出新数组
  3. 在新数组上进行操作,并把新数组赋值给数组容器
  4. 解锁

除了加锁之外,CopyOnWriteArrayList 的底层数组还被 volatile 关键字修饰,意思是一旦数组被修改,其他线程立马能够感知到,代码如下:

private transient volatile Object[] array;

类注释

我们来看看从 CopyOnWriteArrayList 的类注释上能得到哪些信息:

  1. 所有的操作都是线程安全的,因为操作都是在新拷贝数组上进行的
  2. 数组的拷贝虽然有一定的成本,但往往比其他的替代方案效率高
  3. 迭代过程中,不会影响到原来的数组,也不会抛出 ConcurrentModificationException 异常

新增

新增到数组尾部
public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    // 加锁
    lock.lock();
    try {
        // 拿到原数组
        Object[] elements = getArray();
        int len = elements.length;
        // 拷贝到新数组里面,新数组的长度是原来+1,因为add了新的一个元素
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        // 把新增的元素放到拷贝数组的尾部
        newElements[len] = e;
        // 替换掉原来的数组
        setArray(newElements);
        return true;
    } finally {
        // 释放锁
        lock.unlock();
    }
}

final void setArray(Object[] a) {
    array = a;
}

从源码中,我们发现整个 add 过程都是在持有锁的状态下进行的,通过加锁,来保证同一时刻只能有一个线程能够对同一个数组进行 add 操作。

除了加锁之外,还会从原数组拷贝一个新的数组,add 操作在新数组上进行,这个时候有一个问题,都已经加锁了,为什么还要拷贝数组,而不是在原来数组上进行,原因主要为:

  1. volatile 关键字修饰的是数组,如果我们在原来数组上操作,是无法触发可见性的,我们必须通过修改数组的内存地址才行,也就是说要对数组重新赋值才行。
  2. 在新的数组操作,对老数组没有任何影响,只有新数组操作完赋值后,外部才能访问到,降低了在赋值过程中,老数组数据变动的影响。
插入到指定位置
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];
            // 第一次从0拷贝到index
            System.arraycopy(elements, 0, newElements, 0, index);
            // 第二次从index+1拷贝到末尾
            System.arraycopy(elements, index, newElements, index + 1,
                             numMoved);
        }
        // 把目标值插入到位置上
        newElements[index] = element;
        setArray(newElements);
    } finally {
        lock.unlock();
    }
}

小结

从 add 系列方法可以看出,CopyOnWriteArrayList 通过加锁 + 数组拷贝+ volatile 来保证了线程安全,每一个要素都有着其独特的含义:

  1. 加锁:保证同一时刻数组只能被一个线程操作;
  2. 数组拷贝:保证数组的内存地址被修改,修改后触发 volatile 的可见性,其它线程可以立马知道数组已经被修改;
  3. volatile:值被修改后,其它线程能够立马感知最新值。

3 个要素缺一不可,比如说我们只使用 1 和 3 ,去掉 2,这样当我们修改数组中某个值时,并不会触发 volatile 的可见特性的,只有当数组内存地址被修改后,才能触发把最新值通知给其他线程的特性。

删除

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;
        // 如果删除的正好在数组的末尾,直接复制到len-1的位置即可
        if (numMoved == 0)
            setArray(Arrays.copyOf(elements, len - 1));
        else {
            // 新数组的长度减一,因为是减少一个元素
            Object[] newElements = new Object[len - 1];
            // 从 0 拷贝到数组新位置
            System.arraycopy(elements, 0, newElements, 0, index);
            // 从新位置拷贝到数组尾部
            System.arraycopy(elements, index + 1, newElements, index,
                             numMoved);
            setArray(newElements);
        }
        return oldValue;
    } finally {
        lock.unlock();
    }
}

步骤分为三步:

  1. 加锁;
  2. 判断删除索引的位置,从而进行不同策略的拷贝;
  3. 解锁。

代码整体的结构风格也比较统一:锁 + try finally +数组拷贝,锁被 final 修饰的,保证了在加锁过程中,锁的内存地址肯定不会被修改,finally 保证锁一定能够被释放,数组拷贝是为了删除其中某个位置的元素。

批量删除

public boolean removeAll(Collection<?> c) {
    if (c == null) throw new NullPointerException();
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        if (len != 0) {
            // temp array holds those elements we know we want to keep
            int newlen = 0;
            Object[] temp = new Object[len];
            for (int i = 0; i < len; ++i) {
                Object element = elements[i];
                // 把要留下的元素放到新数组temp中
                if (!c.contains(element))
                    temp[newlen++] = element;
            }
            // 如果newlen=len,说明原数组没有元素要删除,(优化)
            if (newlen != len) {
                setArray(Arrays.copyOf(temp, newlen));
                return true;
            }
        }
        return false;
    } finally {
        lock.unlock();
    }
}

从源码中,我们可以看到,我们并不会直接对数组中的元素进行挨个删除,而是先对数组中的值进行循环判断,把我们不需要删除的数据放到临时数组中,最后临时数组中的数据就是我们要留下的数据。

不知道大家有木有似曾相识的感觉,ArrayList 的批量删除的思想也是和这个类似的,所以我们在需要删除多个元素的时候,最好都使用这种批量删除的思想,而不是采用在 for 循环中使用单个删除的方法,单个删除的话,在每次删除的时候都会进行一次数组拷贝(删除最后一个元素时不会拷贝),很消耗性能,也耗时,会导致加锁时间太长,并发大的情况下,会造成大量请求在等待锁,这也会占用一定的内存。

其他方法

indexOf

indexOf 方法的主要用处是查找元素在数组中的下标位置,如果元素存在就返回元素的下标位置,元素不存在的话返回 -1,不但支持 null 值的搜索,还支持正向和反向的查找,我们以正向查找为例,通过源码来说明一下其底层的实现方式:

public int indexOf(Object o) {
    Object[] elements = getArray();
    return indexOf(o, elements, 0, elements.length);
}

/**
 * static version of indexOf, to allow repeated calls without
 * needing to re-acquire array each time.
 * @param 要搜索的元素
 * @param 目标数组
 * @param 搜索的开始位置
 * @param 搜索的结束位置
 * @return 如果元素存在就返回元素的下标位置,元素不存在的话返回 -1
 */
private static int indexOf(Object o, Object[] elements,
                           int index, int fence) {
    // 支持对 null 的搜索
    if (o == null) {
        for (int i = index; i < fence; i++)
            if (elements[i] == null)
                return i;
    } else {
        for (int i = index; i < fence; i++)
            // 通过 equals 方法来判断元素是否相等
            if (o.equals(elements[i]))
                return i;
    }
    return -1;
}

Collections.synchronizedList & CopyOnWriteArrayList 对比

CopyOnWriteArrayListCollections.synchronizedList 是实现线程安全的 List 的两种方式。两种实现方式分别针对不同情况有不同的性能表现,其中 CopyOnWriteArrayList 的写操作性能较差,而多线程的读操作性能较好。而 Collections.synchronizedList 的写操作性能比CopyOnWriteArrayList 在多线程操作的情况下要好很多,而读操作因为是采用了 synchronized 关键字的方式,其读操作性能并不如CopyOnWriteArrayList。因此在不同的应用场景下,应该选择不同的多线程安全实现类。

CopyOnWriteArrayList 的读操作是没有加锁的

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

Collections.synchronizedList 的读操作是加了 synchronized 关键字

public E get(int index) {
    synchronized (mutex) {return list.get(index);}
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值