【Java多线程】并发容器之COWArrayList

https://blog.nowcoder.net/n/efc3fb52f0464d44800b9aba4478874e 中提到同步容器类的并发修改异常和为了解决并发修改异常造成的低并发性。
下面内容转载自Java3y公众号

COWArrayList介绍

一般来说,我们会认为:CopyOnWriteArrayList是同步List的替代品,CopyOnWriteArraySet是同步Set的替代品。

无论是Hashtable --> ConcurrentHashMap,还是说Vector --> CopyOnWriteArrayList。JUC下支持并发的容器与老一代的线程安全类相比,总结起来就是加锁粒度的问题Hashtable、Vector加锁的粒度大(直接在方法声明处使用synchronized),ConcurrentHashMap、CopyOnWriteArrayList加锁粒度小(用各种的方式来实现线程安全,比如我们知道的ConcurrentHashMap用了cas、锁、volatile等方式来实现线程安全..)。

JUC下的并发容器在遍历的时候不会抛出ConcurrentModificationException异常。
所以一般来说,我们都会使用JUC包下给我们提供的线程安全容器,而不是使用老一代的线程安全容器。

下面我们来看看CopyOnWriteArrayList是怎么实现的,为什么使用迭代器遍历的时候就不用额外加锁,也不会抛出ConcurrentModificationException异常。

实现原理:写时复制

我们还是先来回顾一下COW:

如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这么做的优点是如果调用者没有修改该资源,就不会有副本(private copy)被建立,因此多个调用者只是读取操作时可以共享同一份资源,这就避免了并发修改异常了。

概括一下CopyOnWriteArrayList源码注释介绍了什么:
CopyOnWriteArrayList是线程安全容器(相对于ArrayList),底层通过复制数组的方式来实现。
CopyOnWriteArrayList在遍历的使用不会抛出ConcurrentModificationException异常,并且遍历的时候就不用额外加锁
元素可以为null。

基本结构

     /** 可重入锁对象,在add或者set的时候要用到的 */
    final transient ReentrantLock lock = new ReentrantLock();

    /** CopyOnWriteArrayList底层由数组实现,volatile修饰 */
    private transient volatile Object[] array;

    /**
     * 得到数组
     */
    final Object[] getArray() {
        return array;
    }

    /**
     * 设置数组
     */
    final void setArray(Object[] a) {
        array = a;
    }

    /**
     * 初始化CopyOnWriteArrayList相当于初始化数组
     */
    public CopyOnWriteArrayList() {
        setArray(new Object[0]);
    }

看起来挺简单的,CopyOnWriteArrayList底层就是数组,加锁就交由ReentrantLock来完成。

常见方法实现

根据上面的分析我们知道如果遍历Vector/SynchronizedList是需要自己手动加锁的。
CopyOnWriteArrayList使用迭代器遍历时不需要显示加锁,看看add()、clear()、remove()与get()方法的实现可能就有点眉目了。

首先我们可以看看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;

            // 将volatile Object[] array 的指向替换成新数组
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }

通过代码我们可以知道:在添加的时候就上锁,并复制一个新数组,增加操作在新数组上完成,将array指向到新数组中,最后解锁。

再来看看size()方法:

public int size() {
        // 直接得到array数组的长度
        return getArray().length;
    }

再来看看get()方法:

    public E get(int index) {
        return get(getArray(), index);
    }

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

那再来看看set()方法:

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;

            // 将array引用指向新数组
            setArray(newElements);
        } else {
            // Not quite a no-op; enssures volatile write semantics
            setArray(elements);
        }
        return oldValue;
    } finally {
        lock.unlock();
    }
}

对于remove()、clear()跟set()和add()是类似的,这里我就不再贴出代码了。

总结:
1. 在修改时,复制出一个新数组,修改的操作在新数组中完成,最后将新数组交由array变量指向。
2. 写加锁,读不加锁。

剖析为什么遍历时不用调用者显式加锁

常用的方法实现我们已经基本了解了,但还是不知道为啥能够在容器遍历的时候对其进行修改而不抛出异常。所以,来看一下他的迭代器吧:

// 1. 返回的迭代器是COWIterator
    public Iterator<E> iterator() {
        return new COWIterator<E>(getArray(), 0);
    }


    // 2. 迭代器的成员属性
    private final Object[] snapshot;
    private int cursor;

    // 3. 迭代器的构造方法
    private COWIterator(Object[] elements, int initialCursor) {
        cursor = initialCursor;
        snapshot = elements;
    }

    // 4. 迭代器的方法...
    public E next() {
        if (! hasNext())
            throw new NoSuchElementException();
        return (E) snapshot[cursor++];
    }

    //.... 可以发现的是,迭代器所有的操作都基于snapshot数组,而snapshot是传递进来的array数组

到这里,我们应该就可以想明白了!CopyOnWriteArrayList在使用迭代器遍历的时候,操作的都是原数组
图片说明

CopyOnWriteArrayList缺点

看了上面的实现源码,我们应该也大概能分析出CopyOnWriteArrayList的缺点了。

  1. 内存占用:如果CopyOnWriteArrayList经常要增删改里面的数据,经常要执行add()、set()、remove()的话,那是比较耗费内存的。
    因为我们知道每次add()、set()、remove()这些增删改操作都要复制一个数组出来。

  2. 数据一致性:CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。
    从上面的例子也可以看出来,比如线程A在迭代CopyOnWriteArrayList容器的数据。线程B在线程A迭代的间隙中将CopyOnWriteArrayList部分的数据修改了(已经调用setArray()了)。但是线程A迭代出来的是原有的数据。

PS:COWSet的实现

CopyOnWriteArraySet的原理就是CopyOnWriteArrayList。

    private final CopyOnWriteArrayList<E> al;

    public CopyOnWriteArraySet() {
        al = new CopyOnWriteArrayList<E>();
    }
全部评论

相关推荐

过往烟沉:我说什么来着,java就业面就是广!
点赞 评论 收藏
分享
评论
点赞
收藏
分享
正在热议
# 25届秋招总结 #
443331次浏览 4520人参与
# 春招别灰心,我们一人来一句鼓励 #
42266次浏览 539人参与
# 北方华创开奖 #
107474次浏览 600人参与
# 地方国企笔面经互助 #
7973次浏览 18人参与
# 同bg的你秋招战况如何? #
77166次浏览 569人参与
# 实习必须要去大厂吗? #
55811次浏览 961人参与
# 阿里云管培生offer #
120455次浏览 2220人参与
# 虾皮求职进展汇总 #
116310次浏览 887人参与
# 如果你有一天可以担任公司的CEO,你会做哪三件事? #
11683次浏览 289人参与
# 实习,投递多份简历没人回复怎么办 #
2454962次浏览 34861人参与
# 提前批简历挂麻了怎么办 #
149927次浏览 1978人参与
# 在找工作求抱抱 #
906124次浏览 9423人参与
# 如果公司给你放一天假,你会怎么度过? #
4762次浏览 55人参与
# 你投递的公司有几家约面了? #
33209次浏览 188人参与
# 投递实习岗位前的准备 #
1196037次浏览 18550人参与
# 机械人春招想让哪家公司来捞你? #
157648次浏览 2267人参与
# 双非本科求职如何逆袭 #
662384次浏览 7397人参与
# 发工资后,你做的第一件事是什么 #
12806次浏览 62人参与
# 工作中,努力重要还是选择重要? #
35906次浏览 384人参与
# 简历中的项目经历要怎么写? #
86943次浏览 1516人参与
# 参加完秋招的机械人,还参加春招吗? #
20153次浏览 240人参与
# 我的上岸简历长这样 #
452074次浏览 8089人参与
牛客网
牛客企业服务