【编程思想】CopyOnWrite是如何解决高并发场景中的读写瓶颈?

转载请注明:

作者:TodoCoder

出处: juejin.cn/post/745150...

大家好,我是Coder哥,今天我们来聊一下CopyOnWrite这一编程思想。

看到CopyOnWrite 我相信作为一个Java开发第一时间想到的是CopyOnWriteArrayList这个容器,没错,这个容器确实是这一思想的精髓,也是面试中的常客,但是很多人面试的时候也只是回答一下 List并发时可以用 CopyOnWriteArrayList 来对标ConcurrentHashMap, 再深入就不知道回答啥了。 那我们之前文章 【锁思想】高并发下线程饥饿?看看读写锁是怎么避免饥饿的中有聊过读写锁,也在【锁思想】自旋 or CAS 它俩真的一样吗?一文搞懂聊过无锁并发的实现,我们还聊过【锁思想-终章】解锁高性能编程的密码:掌握JVM锁优化的黄金法则锁优化相关的知识,那么CopyOnWrite 思想和其他锁思想有什么关系呢?,本篇文章就分析一下CopyOnWrite 这一思想和其他锁思想的联系,从以下个方面入手:

  1. CopyOnWrite 是什么?
  2. CopyOnWrite 在CopyOnWriteArrayList中的应用。
  3. CopyOnWrite、读写锁、无锁并发的关系和各自的优劣势。
  4. CopyOnWrite在其他场景中的应用。
  5. 总结

CopyOnWrite 是什么?

从字面的意思上说就是写时复制,简单来说,每当有写操作时,我们不直接修改原数据,而是复制一份新数据进行修改,修改完成后,再用新的数据替换掉旧数据,这样能达到读写分离的效果。这种思想的核心优势是:

  • 读操作不加锁,极大提升并发读取的效率。
  • 写操作时通过复制原数据,避免了并发写带来的数据一致性问题。

这一思想用处非常广泛,其中之一就是 CopyOnWriteArrayList,我们结合CopyOnWriteArrayList详细的了解一下。

CopyOnWrite 在CopyOnWriteArrayList中的应用。

CopyOnWriteArrayList 是 Java 中并发包 java.util.concurrent 下的一个容器,它通过实现 CopyOnWrite思想,提供了一种线程安全的 List 实现。接下来我们结合CopyOnWriteArrayList源码来具体说明 CopyOnWrite 思想的应用。

CopyOnWriteArrayList 整体的结构代码如下:

java 复制代码
/** 可重入锁对象 */
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]);
}

从上面代码中我们能看到以下两点:

  1. 类中会有一个 ReentrantLock 锁, 用来保证Write的安全。
  2. volatile 能保证多线程修改的时候的可见性

然后我们看一下Add方法的具体实现: add 方法

java 复制代码
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();    
  }
}

add 方法是Write操作。

  1. 首先需要利用 ReentrantLock 的 lock 方法进行加锁,获取锁之后,得到原数组的长度和元素,也就是利用 getArray 方法得到 elements 并且保存 length。
  2. 然后利用 Arrays.copyOf 方法Copy出一个新的数组,得到一个和原数组内容相同的新数组,并且把新元素添加到新数组中。
  3. 最后完成添加动作后,转换引用所指向的对象,利用 setArray(newElements) 操作就可以把 volatile Object[] array 的指向替换成新数组同时保证了对其他线程的可见性,最后在 finally 中把锁解除。

上述步骤体现了 CopyOnWrite 思想: 写操作是在原容器的副本上进行的,而在读取数据时并不会对容器加锁。需要注意的是,如果在容器副本创建期间有新的读取操作进入,读取到的数据仍然是旧的数据。这是因为在副本创建过程中,原容器的引用并未发生变化,只有在写操作完成后,引用才会指向新的副本。

然后我们看一下读操作代码如下:

java 复制代码
public E get(int index) {    
  return get(getArray(), index);
}
final Object[] getArray() {    
  return array;
}
private E get(Object[] a, int index) {    
  return (E) a[index];
}

可以看出,get 相关的操作完全没有加锁,确保了读操作的高效性。

从上面的介绍我们可以知道,CopyOnWrite 实现了读写分离,可以同时读,也能读的时候写,也支持并发读写并且能保证并发读写的安全,那么CopyOnWrite 和读写锁、无锁并发有什么关系?以及他们各自适用的场景都是什么?

CopyOnWrite、读写锁、无锁并发的关系和各自的优劣势。

之前文章 【锁思想】高并发下线程饥饿?看看读写锁是怎么避免饥饿的中有聊过读写锁,也在【锁思想-终章】解锁高性能编程的密码:掌握JVM锁优化的黄金法则聊过CAS,从中我们知道读写锁,CAS的原理,那么我们分别简单的再介绍一下:

无锁并发是什么?它的常用实现方式有哪些?

无锁并发(Lock-free concurrency) 是指在多线程环境下,线程不需要使用传统的锁机制(如 synchronized 或 ReentrantLock)来控制对共享资源的访问。通过无锁的并发技术,可以避免使用锁带来的性能瓶颈、死锁等问题,提升系统的吞吐量和响应性。

在无锁并发中,线程对共享数据的操作并不会被显式的锁住,而是通过特定的算法保证线程安全。这通常依赖于硬件提供的原子操作(例如 CAS 操作)来实现并发数据结构的修改。

无锁并发主要有两类技术:

  • 乐观锁(Optimistic Locking)
  • CAS(Compare And Swap)操作

读写锁是什么?它的优势是什么?

读写锁(Read-Write Lock) 是一种特殊类型的锁,它允许多个线程同时读取共享资源,但在写操作时,只允许一个线程访问资源。换句话说,读写锁是通过区分读操作和写操作,来优化并发性能的一种机制。它的主要目的是提升在多读少写的场景下的性能。

在 Java 中,读写锁由 java.util.concurrent.locks.ReadWriteLock 接口定义,常用的实现类是 ReentrantReadWriteLock。

  • 读锁(Read Lock): 多个线程可以同时获取读锁,当一个线程持有读锁时,其他线程可以继续获取读锁,这样能够并发地进行读取操作。
  • 写锁(Write Lock): 写锁是排它性的,即当一个线程获取写锁时,其他所有线程(无论是读线程还是写线程)都无法获取锁,直到写操作完成。

读写锁的适用

  • 读多写少的场景:如缓存、共享配置数据等,读取的操作远多于写入的操作。
  • 数据一致性要求较高的场景:当需要保证数据的一致性和线程安全时,使用读写锁能够平衡并发性和安全性。
  • 并发性能要求较高的场景:当需要处理大量并发的读取操作,但写操作频率较低时,读写锁提供了更好的性能。

CopyOnWrite 与读写锁的区别是什么?

从上面的学习我们知道CopyOnWrite 的核心优势是读操作无需加锁,写操作时通过复制数据执行写,那么他们两个的区别如下:

  • 读写锁的规则
    读写锁的思想是:读读共享、其他都互斥(写写互斥、读写互斥、写读互斥),原因是由于读操作不会修改原有的数据,因此并发读并不会有安全问题;而写操作是危险的,所以当写操作发生时,不允许有读操作加入,也不允许第二个写线程加入。
  • 对读写锁规则的升级 CopyOnWrite 的思想比读写锁的思想又更进一步。为了将读取的性能发挥到极致,CopyOnWrite 读取可以完全不用加锁的,更厉害的是,写入也不会阻塞读取操作,也就是说你可以在写入的同时进行读取,只有写入和写入之间需要进行同步,也就是不允许多个写入同时发生,但是在写入发生时允许读取同时发生。这样一来,读操作的性能就会大幅度提升。

上面的第二条理论上也是无锁并发的一种方式,读写共存不用加锁.

那么除了在CopyOnWriteArrayList中有应用,在我们编程中其他的应用场景有哪些呢?

CopyOnWrite在其他场景中的应用。

例子一:Nacos 源码中的应用

在 Nacos 源码中,CopyOnWrite 思想也有广泛的应用。例如,Nacos 在管理配置中心时,会频繁进行读取操作,但写入操作相对较少。为了提高读取性能并避免加锁,Nacos 使用了类似 CopyOnWrite 的机制来管理一些只读数据的容器。

以 Nacos 中的 ConfigService 为例,它需要频繁地读取配置信息,而配置的修改(如修改某个配置项)相对较少。为了避免每次读取都加锁,Nacos 采用了类似 CopyOnWrite 的策略,即每次配置修改时,会复制一份新的配置,而读取时则直接读取当前配置的副本,这样可以确保读操作高效且线程安全。

例子二:缓存系统

另一个典型的应用场景是缓存系统。在缓存系统中,读取操作通常是非常频繁的,而写操作(如缓存更新)则相对较少。为了确保在高并发环境下读取操作的高效性,很多缓存系统采用了类似 CopyOnWrite 的思想。当缓存需要更新时,系统会将旧缓存的副本复制一份,并在副本上进行修改,然后将副本替换掉原缓存。这样,读取操作就不会被写操作阻塞,极大地提高了缓存的读取性能。

总结

CopyOnWrite 编程思想是通过写时复制来实现线程安全的,它适用于读多写少的场景,能够极大提升并发读操作的效率。CopyOnWriteArrayList 作为其在 Java 中的实现,通过每次写操作都复制一份新的副本,保证了读操作的无锁执行,同时避免了并发写时的锁竞争。虽然它在写操作频繁时可能会带来内存占用和性能开销的问题,但在大多数读多写少的场景中,仍然是一个非常有效的解决方案。

到最后了,感谢各位能看到这里

《锁思想》 系列文章:
【锁思想】锁的7大分类及特点 - 了解并发编程中的锁机制

【锁思想】性能提升之道-悲观锁和乐观锁原理及场景分析

【锁思想】为什么各种语言中锁实现的默认策略都是非公平的?

【锁思想】高并发下 读写锁是通过什么策略来避免写线程饥饿的?

【锁思想】自旋 or CAS 它俩真的一样吗?一文搞懂

【锁思想-终章】解锁高性能编程的秘密:掌握JVM锁优化的黄金法则

参考文章: todocoder.com/posts/017.h...

相关推荐
神仙别闹7 分钟前
基于java的改良版超级玛丽小游戏
java
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭30 分钟前
SpringBoot如何实现缓存预热?
java·spring boot·spring·缓存·程序员
暮湫1 小时前
泛型(2)
java
超爱吃士力架1 小时前
邀请逻辑
java·linux·后端
南宫生1 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
转码的小石1 小时前
12/21java基础
java
李小白661 小时前
Spring MVC(上)
java·spring·mvc
GoodStudyAndDayDayUp1 小时前
IDEA能够从mapper跳转到xml的插件
xml·java·intellij-idea
装不满的克莱因瓶2 小时前
【Redis经典面试题六】Redis的持久化机制是怎样的?
java·数据库·redis·持久化·aof·rdb
n北斗2 小时前
常用类晨考day15
java