理解另类的并发安全实现CopyOnWriteArrayList

时间:2022-06-11
本文章向大家介绍理解另类的并发安全实现CopyOnWriteArrayList,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

在Java的并发包java.util.concurrent里面有一个比较有意思现象,针对Map和LinkList都有对应的高效的+线程安全的并发实现类:

ConcurrentHashMap
ConcurrentLinkedQueue

唯独没有针对ArrayList的高效的并发实现,这个我们后面在细说,先来看下目前在Java里面线程安全的List有三种:

Vector 
Collections.synchronizedList(List list)
CopyOnWriteArrayList

Vector这个类是一个非常古老的类了,在JDK1.0的时候便已经存在,其实现安全的手段非常简单所有的方法都加上synchronized关键字,这样保证这个实例的方法同一时刻只能有一个线程访问,所以在高并发场景下性能非常低。

Collections.synchronizedList(List list)这个方法,其实在内部有一个SynchronizedList包装类对应,其实现安全的手段稍微有一点优化,就是把Vector加在方法上的synchronized关键字,移到了方法里面变成了同步块而不是同步方法从而把锁的范围缩小了,而且在构造函数的时候可以传入不同的同步监视器,实现基于不同的监视器并发,但我觉得没有多大意义,只能保证一个监视器内是线程安全的,不同监视器还是不安全的,除非另一个是只读操作,但如果是这样,完全可以用并发包的读写锁来替代。

CopyOnWriteArrayList这个类比较特殊,对于写作来说是基于重入锁互斥的,对于读操作来说是无锁的。还有一个特殊的地方,这个类的iterator是fail-safe的,也就是说是线程安全List里面的唯一一个不会出现ConcurrentModificationException异常的类。能做到这一点其实是有代价的,跟它的实现机制有很大关系,我们从名字上就能看出来,CopyOnWriteArrayList这个类内部维护的核心内容:

//重入锁保写操作互斥
    final transient ReentrantLock lock = new ReentrantLock();
    //volatile保证读可见性
    private transient volatile Object[] array;

从上面的代码能够看出,为了实现无锁读,对象数组上面是加了volatile修饰的,当然如果你去掉这个关键字,那么对于读操作来说也是必须加锁的。volatile相比读加锁实现则更轻量级。

接着我们看这个类是如何添加数据的:

public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();//加锁
        try {
            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();
        }
    }

简单的说,这个类每次新加的数据都会新copy生成一个数组来容纳,并不是直接修改原来的数据结构,这种方式提供了安全的快照读和遍历的方法,带来的不足就是对于频繁写的应用并不适合,Doug Lea大神在开发这个类的时候也介绍了这个类的主要应用场景是避免对集合的iterator方法加锁遍历。

这里要提一下对于对于Java里面的集合类无论是线程安全和不安全的,只要涉及到在遍历的时候修改数据,就会抛出异常,原因是集合类的modCount字段与Iteritor记录的expectedModCount字段值不相等,也就是不同步导致的,这是集合类的fail-fast机制,在单线程情况下我们可以通过Iteritor的remove方法来避免抛出ConcurrentModificationException异常,但在多线程情况下仍然是有问题的,如果想要解决,有两种方式:

(1)在遍历IterItor时候,采用加锁策略,避免多个线程同时修改。

(2)采用弱一致性的副本,原理是不改变原来数据,比如CopyOnWriteArrayList这种的。这种解决方法非常类似数据库技术的MVCC多版本的模式,对于Iteritor生成的时候,读取的是当前数组的快照,所以在遍历的时候永远不会存在ConcurrentModificationException异常,注意CopyOnWriteArrayList是不支持Iteritor.remove操作的,因为对快照的删除是没有任何意义的,所以想要删除必须调用CopyOnWriteArrayList.remove方法,前面说过该类的写操作相关的方法都是线程互斥的,所以不存在安全问题。

整体来说CopyOnWriteArrayList是另类的线程安全的实现,但并一定是高效的,适合用在读取和遍历多的场景下,并不适合写并发高的场景,因为数组的拷贝也是非常耗时的,尤其是数据量大的情况下。

到这里我们能够看到关于List的线程安全实现基本都是采用加锁实现,只不过CopyOnWriteArrayList是比较特殊的另类的安全并发实现,包括同样的CopyOnWriteArraySet(底层用的CopyOnWriteArrayList),这里强调了线程安全,但并没有提到高效,因为HashMap和LinkQueue都有对应的线程安全+高效的并发容器,只有List没有,主要原因如下:

在Java并发编程网有一篇关于这个的解释,我在这里总结一下:

在java.util.concurrent包中没有加入并发的ArrayList实现的主要原因是:很难去开发一个通用并且没有并发瓶颈的线程安全的List。

1:ConcurrentHashMap这样的类的真正价值在于,在保证了线程安全的同时,采用了分段锁+弱一致性的Map迭代器提供了高效的并发效率。如果仅仅是线程安全而不高效,对于高并发来说意义不大。

2:ConcurrentLinkedQueue基于链表的高并发实现采用了CAS +自旋的方式,提供了无阻塞的高并发实现,主要原因是他们的接口相比List的接口有更多的限制,这些限制使得实现并发成为可能。

而ArrayList这样的数据结,不知道如何去规避并发的瓶颈,拿contains() 这样一个操作来说,当你进行搜索的时候如何避免锁住整个list?

CopyOnWriteArrayList的实现仅仅是规避了读并发的瓶颈,对于修改操作扔然是需要锁住整个List的,所以从某种程度上来说,实现一个通用高效的并发List是比较困难的,这也是java并发包里为什么没有该实现的原因。

本文主要介绍了Java并发包里面另类的安全实现方式CopyOnWriteArrayList的实现原理,其主要特点是利用了快照的概念从而使读和迭代器遍历操作无须同步加锁,由于其不可变的特性,所以在并发应用中更加容易处理,但这种方式也是有代价的毕竟数组的拷贝在数据大的时候也是一笔不少的开销,这一点需要注意。