48、写时复制
上一节我们对 JUC 并发容器做了一个框架性介绍,提到了很多并发容器
本节我们讲解其中的 CopyOnWriteArrayList 和 CopyOnWriteArraySet,它们都是采用写时复制技术来实现,因此称为写时复制并发容器
从名称我们可以发现,这两个容器都跟数组有关,那么为什么 JUC 没有提供 CopyOnWriteLinkedList、CopyOnWriteHashMap 等其他类型的写时复制并发容器呢?
带着这个问题,我们来学习本节的内容
1、基本原理
1.1、写时复制
写时复制是一个比较通用的技术,可以应用于很多技术场景中,当应用于并发容器中时,写时复制指的是
- 当对容器进行写操作(这里的写可以理解为 "增、删、改")时,为了避免读写操作同时进行而导致的线程安全问题
我们将原始容器中的数据复制一份放入新创建的容器,然后对新创建的容器进行写操作 - 而读操作继续在原始容器上进行,这样读写操作之间便不会存在数据访问冲突,也就不存在线程安全问题
当写操作执行完成之后,新创建的容器替代原始容器,原始容器便废弃
我们结合 CopyOnWriteArrayList 的源码来理解以上写时复制的处理过程,CopyOnWriteArrayList 的部分源码如下所示
CopyOnWriteArrayList 是 ArrayList 的线程安全版本,它跟 ArrayList 一样,也实现了 List 接口
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable { final transient ReentrantLock lock = new ReentrantLock(); private transient volatile Object[] array; public CopyOnWriteArrayList() { this.array = new Object[0]; } // ... 省略其他方法 ... }
CopyOnWriteArrayList 中包含的读写函数非常多
我们拿其中的 4 个函数作为代表来分析,它们分别是:get() 函数、add() 函数、remove() 函数、set() 函数,分别对应读、增、删、改这 4 个操作
1.2、get() 函数
get() 函数的代码实现如下所示,get() 函数实现读操作,代码逻辑非常简单,直接按照下标访问 array 数组
从代码中我们可以发现,读操作没有加锁,因此即便在多线程环境下,效率也非常高
public E get(int index) { return (E) this.array[index]; }
1.3、add() 函数
add() 函数的代码实现如下所示,add() 函数包含写时复制逻辑,因此相对于 get() 函数,要复杂一些
- 当往容器中添加数据时,并非直接将数据添加到原始数组中
而是创建一个长度比原始数组大一的数组 newElements,将原始数组中的数据拷贝到 newElements
然后将数据添加到 newElements 的末尾,最后修改 array 引用指向 newElements - 除此之外,为了保证写操作的线程安全性,避免两个线程同时执行写时复制,写操作通过加锁来串行执行
也就是说:读读、读写都可以并行执行,唯独写写不可以并行执行
public boolean add(E e) { lock.lock(); try { int len = array.length; // Arrays.copyOf() 底层依赖 native 方法 System.arrayCopy() 来实现, 比较快速 Object[] newElements = Arrays.copyOf(array, len + 1); newElements[len] = e; array = newElements; return true; } finally { lock.unlock(); } }
1.4、remove() 函数
remove() 函数的代码实现如下所示,remove() 函数的处理逻辑跟 add() 函数类似
先通过加锁保证写时复制操作的线程安全性,然后申请一个大小比原始数组大小小一的新数组 newElements
除了待删除数据之外,我们将原始数组中的其他数据统统拷贝到 newElements,拷贝完成之后,我们将 array 引用指向 newElements
public E remove(int index) { lock.lock(); try { int len = array.length; E oldValue = get(array, index); int numMoved = len - index - 1; // (len - 1) - (index + 1) + 1 if (numMoved == 0) { // 删除 array 数组的最后一个元素 array = Arrays.copyOf(array, len - 1); } else { // 删除 array 数组内部的元素 Object[] newElements = new Object[len - 1]; System.arraycopy(array, 0, newElements, 0, index); // array[0, index - 1] System.arraycopy(array, index + 1, newElements, index, numMoved); // array[index + 1, len - 1] array = newElements; } return oldValue; } finally { lock.unlock(); } }
1.5、set() 函数
set() 函数的代码实现如下所示,set() 函数的写时复制处理逻辑,跟 add() 函数、remove() 函数的类似,这里就不再赘述了,你可以参看源码了解其实现原理
public E set(int index, E element) { lock.lock(); try { E oldValue = get(array, index); if (oldValue != element) { int len = array.length; // Arrays.copyOf() 底层依赖 native 方法 System.arrayCopy() 来实现, 比较快速 Object[] newElements = Arrays.copyOf(array, len); newElements[index] = element; array = newElements; } return oldValue; } finally { lock.unlock(); } }
1.6、CopyOnWriteArraySet
JUC 提供的写时复制容器有两个,除了刚刚讲到的 CopyOnWriteArrayList 之外,还有 CopyOnWriteArraySet
从名称上我们可以看出,CopyOnWriteArraySet 具备普通 Set 所具备的功能,CopyOnWriteArraySet 的部分源码如下所示
public class CopyOnWriteArraySet<E> extends AbstractSet<E> { private final CopyOnWriteArrayList<E> al; public CopyOnWriteArraySet() { al = new CopyOnWriteArrayList<E>(); } public boolean add(E e) { return al.addIfAbsent(e); } public boolean remove(Object o) { return al.remove(o); } public boolean contains(Object o) { return al.contains(o); } }
从上述代码我们可以发现,CopyOnWriteArraySet 实际上是基于 CopyOnWriteArrayList 实现的,CopyOnWriteArraySet 中的函数委托给 CopyOnWriteArrayList 来实现
我们重点看下 contains() 函数,在 HashSet 或者 TreeSet 中,contains() 函数基于哈希表或者红黑树来实现,查询效率非常高
而在 CopyOnWriteArraySet 中,contains() 函数基于数组来实现,需要遍历查询,执行效率比较低
对写时复制的基本原理有了一定了解之后,我们从读多写少、弱一致性、连续存储这三个特点,再对写时复制进行更深入的探讨
2、读多写少
从以上给出的 CopyOnWriteArrayList 源码,我们可以发现
- 写操作需要加锁,只能串行执行
- 写操作执行写时复制逻辑,涉及大量数据的拷贝
- 因此写操作的执行效率很低,写时复制并发容器只适用于读多写少的应用场景
如果我们将写时复制容器应用于写操作比较多的场景,那么性能表现将会非常差,如下示例代码所示
执行 100000 次写入操作,使用 CopyOnWriteArrayList 容器的耗时为 2098 毫秒,使用 ArrayList 容器的耗时 2 毫秒,前者的耗时是后者的 1000 多倍
public class Demo { public static void main(String[] args) { List<Integer> cowList = new CopyOnWriteArrayList<>(); long startTime = System.currentTimeMillis(); for (int i = 0; i < 100000; i++) { cowList.add(i); } System.out.println(System.currentTimeMillis() - startTime); List<Integer> list = new ArrayList<>(); startTime = System.currentTimeMillis(); for (int i = 0; i < 100000; i++) { list.add(i); } System.out.println(System.currentTimeMillis() - startTime); } }
既然 CopyOnWriteArrayList 只适合读多写少的应用场景,那么对于写也比较多的应用场景,又该怎么办呢?
在《数据结构与算法之美》中我们讲到,在数组内部进行数据的添加、删除操作,会涉及到大量数据的搬移
也就是说:数组的写操作是一个全局操作,要保证其线程安全性,加锁可能是唯一的解决方案,因此我们可以使用 Java 提供的并发容器 SynchronizedList
3、弱一致性
从 CopyOnWriteArrayList 的源码,我们还可以发现,写操作的结果并非对读操作立即可见,写操作在新数组上执行,读操作在原始数组上执行
因此在 array 引用指向新数组之前,读操作只能读取到旧的数据,这就导致了短暂的数据不一致,我们把写时复制的这个特点叫做弱一致性
在某些业务场景下,弱一致性有可能引起代码 bug,我们举例解释一下,如下代码所示
当一个线程调用 add() 函数添加数据的同时,另一个线程调用 sum() 函数遍历容器求和
那么遍历容器求和就有可能存在重复统计的问题,请你思考一下,重复统计是如何产生的呢?
public class Demo { private List<Integer> scores = new CopyOnWriteArrayList<>(); public void add(int idx, int score) { scores.add(idx, score); // 将数据插入到 idx 下标位置 } public int sum() { int ret = 0; for (int i = 0; i < scores.size(); i++) { ret += scores.get(i); } return ret; } }
如下图所示,线程 A 遍历原始数组进行累加,当累加完下标为 3 的元素 23 之后,线程 B 执行 add(3, 17) 函数,将 array 引用切换为指向新的数组
此时变量 i 的值仍然为 4,线程 A 从下标为 4 的位置开始遍历新的数组,于是元素 23 又被遍历累加了一遍,那么怎么解决重复统计问题呢?
实际上 CopyOnWriteArrayList 提供了用来专门遍历容器的迭代器,如下源码所示
在创建迭代器对象时,CopyOnWriteArrayList 会将原始数组赋值给 snapshot 引用,之后的遍历都是在 snapshot 上进行的
因此即便 array 应用切换为指向新的数组,也并不影响到 snapshot 引用继续指向原始数组
// 位于 CopyOnWriteArrayList.java 中 public Iterator<E> iterator() { return new COWIterator<E>(getArray(), 0); } static final class COWIterator<E> implements ListIterator<E> { private final Object[] snapshot; // 指向原始数组 private int cursor; private COWIterator(Object[] elements, int initialCursor) { cursor = initialCursor; snapshot = elements; } public boolean hasNext() { return cursor < snapshot.length; } @SuppressWarnings("unchecked") public E next() { if (!hasNext()) throw new NoSuchElementException(); return (E) snapshot[cursor++]; } // ... 省略其他方法 ... }
我们使用迭代器对 sum() 函数进行重构,重构之后的代码如下所示
public int sum() { int ret = 0; Iterator<Integer> itr = scores.iterator(); while (itr.hasNext()) { ret += itr.next(); } return ret; }
4、连续存储
在本节的开头我们提到,JUC 提供了 CopyOnWriteArrayList、CopyOnWriteArraySet
却没有提供 CopyOnWriteLinkedList、CopyOnWriteHashMap 等其他类型的写时复制容器,这是出于什么样的考虑呢?
4.1、数组容器
在写时复制的处理逻辑中,每次执行写操作时,哪怕只添加、修改、删除一个数据,都需要大动干戈,把原始数据重新拷贝一份
如果原始数据比较大,那么对于链表、哈希表来说,因为数据在内存中不是连续存储的,因此拷贝的耗时将非常大,写操作的性能将无法满足一个工业级通用类对性能的要求
而 CopyOnWriteArrayList 和 CopyOnWriteArraySet 底层都是基于数组来实现的,数组在内存中是连续存储的
JUC 使用 JVM 提供的 native 方法,如下所示,通过 C++ 代码中的指针实现了内存块的快速拷贝,因此写操作的性能在可接受范围之内
而在平时的业务开发中,对于一些读多写少的业务场景,在确保性能满足业务要求的前提下,我们仍然可以使用写时复制技术来提高读操作性能
// 位于 System.java 中 public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);
4.2、非数组容器
JUC 没有提供非数组类型的写时复制容器,是出于对于一个工业级通用类的性能的考量
对于非数组类型的容器,我们需要自己去开发相应的写时复制逻辑,在《设计模式之美》中,当讲解原型设计模式时,我们引入了一个非常贴近实战的示例
展示了如何在 HashMap 容器之上应用写时复制技术,因为那个示例比较复杂,所以我们就不重新表述了,这里我们举一个简单一点的例子
假设系统配置存储在文件中,在系统启动时,配置文件被解析加载到内存中的 HashMap 容器中,之后 HashMap 容器中的配置会频繁地被用到
系统支持配置热更新,在不重启系统的情况下,我们希望能较实时地更新内存中的配置,让其跟文件中的配置保持一致
为了实现热更新这个功能,我们在系统中创建一个单独的线程,定时从配置文件中加载解析配置,更新到内存中的 HashMap 容器中
对于这样一个读多写少的应用场景,我们就可以使用写时复制技术,如下代码所示
在更新内存中的配置时,使用写时复制技术,避免写操作和读操作互相影响
相对于 ConcurrentHashMap 来说,读操作完全不需要加锁,甚至连 CAS 操作都不需要,因此读操作的性能更高
public class Configuration { private static final Map<String, String> map = new HashMap<>(); // 热更新, 这里不需要加锁(只有一个线程调用此函数), 也不需要拷贝(全量更新配置) public void reload() { Map<String, String> newMap = new HashMap<>(); // ... 从配置文件加载配置, 并解析放入 newMap map = newMap; } }
5、课后思考题
写时复制是一个通用的技术,请思考一下写时复制技术还可以应用到哪些其他技术场景中?
Redis 中的 BGSAVE 命令可以将内存中的数据快照存储到硬盘,Redis 在执行 BGSAVE 命令的同时,仍然可以响应其他数据操作请求
这是因为 BGSAVE 命令是在子进程中执行的,之所以使用进程而非线程执行 BGSAVE 命令,是因为 Redis 希望使用父子进程之间的写时复制技术
当我们使用 fork() 创建子进程时,父子进程共享内存数据,只有当有数据被更改时,子进程才会将包含更新数据的那块内存块进行复制
基于这种写时复制技术,Redis 执行 BGSAVE 命令并不会耗费太多额外的内存
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17493380.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步