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

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

大家好,我是三友~~

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

公众号:三友的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来实现的。有兴趣的小伙伴可以翻一下源码,看看是如何实现的,这里就不再剖析源码了。

总结

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

PS:如果觉得这篇文章对你有帮助,欢迎大家关注公众号三友的java日记、分享、点赞、在看,感谢支持。

往期热门文章推荐

如何去阅读源码,我总结了18条心法

如何写出漂亮代码,我总结了45个小技巧

三万字盘点Spring/Boot的那些常用扩展点

三万字盘点Spring 9大核心基础功能

万字+20张图剖析Spring启动时12个核心步骤

1.5万字+30张图盘点索引常见的11个知识点

两万字盘点那些被玩烂了的设计模式

搜索关注公众号 三友的java日记 ,及时干货不错过,公众号致力于通过画图加上通俗易懂的语言讲解技术,让技术更加容易学习,回复 面试 即可获得一套面试真题。

相关文章
|
4月前
多线程并发锁的方案—原子操作
多线程并发锁的方案—原子操作
|
4月前
多线程并发锁的方案—互斥锁
多线程并发锁的方案—互斥锁
|
12月前
|
安全 Java 调度
认识并发中常见的锁
1. 锁的作用 2. 乐观锁和悲观锁 1)乐观锁 2)悲观锁 3)乐观锁和悲观锁在 Java 中的典型实现 4)数据版本机制 3. CAS 机制 1)什么是 CAS 2)CAS 的 ABA 问题 4. 读写锁 1)Java 标准库中提供的读写锁 5. 偏向锁、轻量级锁和重量级锁 1)偏向锁 2)轻量级锁 3)重量级锁 6. 自旋锁 7. 公平锁和非公平锁
91 0
|
12月前
|
存储 安全 Java
synchronize偏向锁底层实现原理
无多线程竞争时,减少不必要的轻量级锁执行路径。大多数情况下,锁不仅不存在多线程竞争,而且总是由同一条线程去多次获得锁,为了让线程获得锁的性能代价更低而引入了偏向锁。
50 0
并发锁(一):为什么要加锁
并发锁(一):为什么要加锁
132 0
并发锁(一):为什么要加锁
|
安全
【多线程:cas】无锁实现并发
【多线程:cas】无锁实现并发
194 0
|
安全 开发者
JUC并发(并发的包)下的CopyOnWriteArrayList(线程安全的集合)
JUC并发(并发的包)下的CopyOnWriteArrayList(线程安全的集合)
97 0
JUC并发(并发的包)下的CopyOnWriteArrayList(线程安全的集合)
|
存储 缓存 Java
并发场景加锁优化小技巧
在 JDK 中有很多锁,包括 synchronized、ReentrantLock、ReentrantReadWriteLock、锁的使用场景也分很多种,下面看一下对加锁优化的小技巧。
119 0
|
缓存 安全 Java
为什么 ConcurrentHashMap 的读操作不需要加锁?为什么 ConcurrentHashMap 的读操作不需要加锁?
为什么 ConcurrentHashMap 的读操作不需要我们知道,ConcurrentHashmap(1.8)这个并发集合框架是线程安全的,当你看到源码的get操作时,会发现get操作全程是没有加任何锁的,这也是这篇博文讨论的问题——为什么它不需要加锁呢?加锁?为什么 ConcurrentHashMap 的读操作不需要加锁?
为什么 ConcurrentHashMap 的读操作不需要加锁?为什么 ConcurrentHashMap 的读操作不需要加锁?
|
算法 Java 测试技术
基于锁的并发算法 vs 无锁的并发算法
上周在由Heinz Kabutz通过JCrete 组织的开放空间会议(unconference)上,我参加一个新的java规范 JSR166 StampedLock 的审查会议。 StampedLock 是为了解决多个readers 并发访问共享状态时,系统出现的内存地址竞争问题。在设计上通过使用乐观的读操作, StampedLock 比 ReentrantReadWriteLock 更加高效;
105 0
基于锁的并发算法 vs 无锁的并发算法