为什么有了并发安全的集合还需要读写锁?

大家好,我是三友,这篇文章想来跟大家来探讨一下,在Java中已经提供了并发安全的集合,为什么有的场景还需要使用读写锁,直接用并发安全的集合难道不行么?

在java中,并发安全的集合有很多,这里我就选用常见的CopyOnWriteArrayList为例,来说明一下读写锁的价值到底提现在哪。

CopyOnWriteArrayList核心源码分析

接下来我们分析一下CopyOnWriteArrayList核心的增删改查的方法

成员变量

//独占锁
final transient ReentrantLock lock = new ReentrantLock();
//底层用来存放元素的数组
private transient volatile Object[] array;

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();
        }
    }

add操作先通过lock加锁,保证同一时刻最多只有一个线程可以操作。加锁成功获取到成员变量的数据,然后拷贝成员变量数组的元素到新的数组,再基于新的数据来添加元素,最后将新拷贝的数组通过setArray来替换旧的成员变量的数组。

remove方法:移除集合中的某个元素

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;
            if (numMoved == 0)
                setArray(Arrays.copyOf(elements, len - 1));
            else {
                Object[] newElements = new Object[len - 1];
                System.arraycopy(elements, 0, newElements, 0, index);
                System.arraycopy(elements, index + 1, newElements, index,
                                 numMoved);
                setArray(newElements);
            }
            return oldValue;
        } finally {
            lock.unlock();
        }
    }

remove操作也要先获取到锁。它先是取出对应数组下标的旧元素,然后新建了一个原数组长度减1的新数组,将除了被移除的元素之外,剩余的元素拷贝到新的数组,最后再通过setArray替换旧的成员变量的数组。

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;
                setArray(newElements);
            } else {
                // Not quite a no-op; ensures volatile write semantics
                setArray(elements);
            }
            return oldValue;
        } finally {
            lock.unlock();
        }
    }

set方法跟add,remove操作一样得先获取到锁才能继续执行。将原数组的原有元素拷贝到新的数组上,在新的数组完成数据的替换,最后也是通过setArray替换旧的成员变量的数组。


size方法:获取集合中元素的个数

public int size() {
        return getArray().length;
}

size方法操作很简单,就是简单地返回一下当前数组的长度。

迭代器的构造

public Iterator<E> iterator() {
        return new COWIterator<E>(getArray(), 0);
}

构造COWIterator的时候传入当前数组的对象,然后基于当前数组来遍历,也不需要加锁。

讲完CopyOnWriteArrayList源码,我们可以看出CopyOnWriteArrayList的核心原理就是在对数组进行增删改的时候全部都是先加独占锁,然后对原有的数组进行拷贝,然后基于新复制的数组进行操作,最后将这个新的数组替换成员变量的数组;而对于读的操作来说,都是不加锁的,是基于当前成员变量的数组的这一时刻的快照来读的。其实CopyOnWriteArrayList是基于一种写时复制的思想,写的时候基于新拷贝的数组来操作,之后再赋值给成员变量,读的时候是原有的数组,这样读写其实就是不是同一个数组,这样就避免了读写冲突的情况,这其实也体现了一种读写分离的思想,读写操作的是不同的数组。

CopyOnWriteArrayList适用场景

接下来我们来思考一下,CopyOnWriteArrayList适合使用在什么样的场景中。通过上面源码的分析,我们可以看出,所有的写操作,包括增删改都需要加同一把独占锁,所以同时只允许一个线程对数组进行拷贝赋值的操作,多线程并发情况下所有的操作都是串行执行的,势必会导致并发能力降低,同时每次操作都涉及到了数组的拷贝,性能也不太好;而所有的读操作都不需要加锁,所以同一时间可以允许大量的线程同时读,并发性能高。所以综上我们可以得出一个结论,那就是CopyOnWriteArrayList适合读多写少的场景。

CopyOnWriteArrayList的局限性

说完CopyOnWriteArrayList,我们来想一想它有没有什么缺点。看起来CopyOnWriteArrayList除了写的并发性能差点,好像没有什么缺点了。的确,单从性能来看,确实是这种情况,但是,从数据一致性的角度来看,CopyOnWriteArrayList的数据一致性能力较弱,属于数据弱一致性。所谓的弱一致性,你可以这么理解,在某一个时刻,读到的数据并不是当前这一时刻最新的数据。

就拿CopyOnWriteArrayList举例来说,当有个线程A正在调用add方法来添加元素,此时已经完成了数组的拷贝,并且也将元素添加到数组中,但是还没有将新的数组赋值给成员变量,此时,另一个线程B来调用CopyOnWriteArrayList的size方法,来读取集合中元素的个数,那么此时读到的元素个数其实是不包括线程A要添加的元素,因为线程A并没有将新的数组赋值给成员变量,这就导致了线程B读到的数据不是最新的数据,也就是跟实际的数据不一致。

所以,从上面我们可以看出,CopyOnWriteArrayList对于数据一致性的保证,还是比较弱的。其实不光是CopyOnWriteArrayList,其实Java中的很多集合,队列的实现对于数据一致性的保证都比较弱。

如何来保证数据的强一致性

那么有什么好的办法可以保证数据的强一致性么?当然,保证并发安全,加锁就可以完成,但是加什么锁可以保证数据读写安全和数据一致性,其实最简单粗暴的方法就是对所有的读写都加上同一把独占锁,这样保证所有的读写操作都是串行执行,那么读的时候,其他线程一定不能写,那么读的一定是最新的数据。

如果真的这么去加独占锁,的确能够保证读写安全,但是性能却会很差,这也是为什么CopyOnWriteArrayList的读不加锁的原因,其实CopyOnWriteArrayList在设计的时候,就是降低数据一致性来换取读的性能。

那有没有什么折中的方法,既能保证读的性能不差,又能保证数据强一致性呢。这时就可以用读写锁来实现。所谓的读写锁,就是写的时候,其他线程不能写也不能读,读的时候,其他线程能读,但是不能写。也就是写写、读写互斥,但是读读不互斥。基于这种方式,就能保证读的时候,一定没有人在写,这样读到的数据就一定是最新的,同时也能保证其他线程也能读,不会出现上面举例的那种情况了,也就能保证数据的强一致性。读写锁相比独占锁而言,大大提高了读的并发能力,但是写的时候不能读,相比于CopyOnWriteArrayList而言,读的并发能力有所降低,这可能就是鱼(并发性能)和熊掌(数据一致性)不可兼得吧。

Java中也提供了读写锁的实现,ReentrantReadWriteLock,底层是基于AQS来实现的。有兴趣的小伙伴可以翻一下源码,看看是如何实现的,这里就不再剖析源码了。

总结

好了,通过这篇文章,想必大家知道为什么有并发安全的集合之后,还需要读写锁的原因,因为很多并发安全的集合对于数据一致性的保证是比较弱的,一旦遇到对于数据一致性要求比较高的场景,一些并发安全的集合就不适用了;同时为了避免独占锁带来的性能问题,可以选择读写锁来保证读的并发能力。小伙伴们在实际应用中需要根据应用场景来灵活地选择使用并发安全的集合、读写锁或者是独占锁,其实永远没有最好的选择,只有更好的选择。

以上就是本篇文章的全部内容,如果你有什么不懂或者想要交流的地方,欢迎关注我的个人的微信公众号 三友的java日记 ,我们下篇文章再见。

如果觉得这篇文章对你有所帮助,还请帮忙点赞、在看、转发一下,码字不易,非常感谢!

 

最近花了一个月的时间,整理了这套并发编程系列的知识点。涵盖了 volitile、synchronized、CAS、AQS、锁优化策略、同步组件、数据结构、线程池、Thread、ThreadLocal,几乎覆盖了所有的学习和面试场景,如图。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

文档获取方式:扫描二维码或者搜一搜关注微信公众号 三友的java日记 ,回复 并发  就能获取了。

 

posted @ 2022-05-27 23:52  三友的java日记  阅读(128)  评论(0编辑  收藏  举报