Java并发编程——Copy-On-Write
本文最后更新于:3 年前
引言
在高并发读场景下,如果读操作远多于写操作,传统的互斥锁可能成为瓶颈。Copy-On-Write
(COW
)机制旨在充分利用“读多写少”的特性:对数据结构的写操作时才进行复制,从而极大地降低读操作的竞争与锁开销。本文将从 COW
的原理、在 Java 中的具体实现(CopyOnWriteArrayList
、CopyOnWriteArraySet
)以及使用注意事项等方面展开,帮助你在特定场景下写出更高效、更易维护的多线程程序。
简介
Copy-On-Write
(简称 COW
)机制是一种在多线程环境下用于提高读操作效率的并发策略。其核心思想是:当有写操作(如新增、修改、删除)发生时,并不直接在原有数据结构上进行修改,而是先拷贝一份新的副本,然后在副本上进行写操作。写操作结束后,再将引用指向新的数据结构,从而避免了对读操作的阻塞。
Java 标准库中提供了写时复制的集合类,CopyOnWriteArrayList
和 CopyOnWriteArraySet
等类就是基于写时复制机制实现的线程安全集合,它们在读操作远多于写操作的场景下有不错的性能表现。
核心思想
COW
机制的核心思想是:写操作时进行“复制”,读操作时直接访问原有对象。这样做可以显著减少在读高并发时竞争同一把锁的情况。也就是说,COW
使得读操作无需阻塞写操作,写操作也无需阻塞读操作。
在 Java 的 CopyOnWriteArrayList
、CopyOnWriteArraySet
中,都遵循下面的流程:
- 当调用写操作方法(如
add()
、remove()
、set()
等)时,它会先复制当前容器的全部内容到一个新数组中。 - 在新数组上执行对应的写操作。
- 写操作完成后,将容器内部引用切换到新数组上。
graph TD
A[原始数据] --> B[读取请求]
A --> C[修改请求]
C --> D[创建副本]
D --> E[修改副本]
E --> F[替换引用]
原理
数据结构
1 |
|
- 内部维护volatile数组保证可见性。
读操作原理
1 |
|
- 读操作直接使用当下有效的内部数组(即当前引用所指向的数组)进行遍历或读取。
- 读取过程中容器内部引用不会改变,对读操作而言是可见且稳定的。
写操作原理
1 |
|
- 写操作需要先获取一定的锁(通常是对象级锁)。
- 一旦获取到锁,容器内部会拷贝原数组并在副本上进行对应的操作(如添加元素、删除元素等)。
- 完成之后,将引用从旧数组切换到新的数组,写操作结束。
- 由于拷贝发生在写操作期间,所有对旧数组的读操作都不受影响,新操作完成后,后续的读操作则看到新的数组。
特点
优点
- 高效的并发读取:读操作几乎无锁,因为读操作直接访问的是共享的数据结构,能保证在大多数情况下无锁或最小化锁开销。
- 线程安全:多个读操作可以同时进行,且不会互相阻塞,也不会被写操作阻塞。写操作在拷贝完成并更新引用后,读操作会立即看到更新后的新副本。
- 迭代器的弱一致性:迭代器基于快照,避免了
ConcurrentModificationException
,适合于某些需要遍历集合的场景。
缺点
- 高内存消耗:每次写操作都需要复制整个数据结构,对于大规模数据结构和频繁写操作,会导致内存消耗增大。
- 写操作性能较低:写操作需要复制数据副本,导致写性能较低,不适用于写操作频繁的场景。
- 延迟写入:由于写操作是在副本上进行,可能会带来一定的延迟,影响实时性要求较高的应用。
- GC 压力:每次写操作均会重新复制所有元素对
GC
造成的压力,这在长时间运行的高并发系统中可能显著增大Full GC
的触发频率,需要在实际应用中谨慎评估。
适用场景
- 读多写少的并发场景:系统参数配置、配置信息缓存、热点数据缓存等读取操作远多于写入操作场景,
COW
可以提供高效的并发读取性能。 - 不需要实时一致性:对读操作来说,容器内部的数据在写操作瞬间可能已经“旧”了。但对大多数场景来说,这个瞬时不一致通常可以接受,并能显著提升读性能。
- 需要迭代且不希望迭代过程中被修改:
COW
提供了基于快照的迭代器,避免了迭代过程中集合被修改导致的问题。 - 内存相对充裕:由于每次写操作都会创建新副本,占用更多的内存,所以对内存的消耗有一定要求。
具体实现
Java在并发包java.util.concurrent
中提供了两种基于写时复制的集合实现:
CopyOnWriteArrayList
CopyOnWriteArrayList
是 List
接口的线程安全实现,基于数组实现,并采用写时复制策略。它适用于读多写少的场景,如缓存列表、事件监听器列表等。
内部实现
- 底层使用一个
volatile
修饰的数组array
来存储元素。 - 写操作(如
add
、set
、remove
等)会先加锁,然后创建一个新的数组副本,进行修改后将引用指向这个新数组。 - 读取操作(如
get
、iterator
等)直接读取当前的array
数组,无需加锁。
特点
- 线程安全:所有的修改操作都是通过复制底层数组来实现的,确保了线程安全。
- 高效的并发读取:读取操作无需加锁,可以高效地并发执行。
- 迭代器的弱一致性:迭代器基于创建时的数组快照,因此不会抛出
ConcurrentModificationException
,但可能不会反映最新的修改。
CopyOnWriteArraySet
CopyOnWriteArraySet
是 Set
接口的线程安全实现,基于 CopyOnWriteArrayList
实现。它确保集合中没有重复的元素,适用于需要保证元素唯一且读多写少的场景。
内部实现
- 基于
CopyOnWriteArrayList
实现,所有添加元素的操作都会检查元素是否已存在,避免重复。 - 写操作与
CopyOnWriteArrayList
相同,创建新的数组副本进行修改。 - 读取操作无需加锁,可以高效地并发执行。
特点
- 线程安全:继承自
CopyOnWriteArrayList
,通过COW
机制实现。 - 保证元素唯一:通过内部机制确保集合中不包含重复元素。
- 高效的并发读取:与
CopyOnWriteArrayList
相同,读取操作无需加锁。
问题引入
假设有一个共享的列表,多个线程需要频繁读取和偶尔写入元素。没有适当的同步机制,可能会导致数据不一致或竞态条件。
1 |
|
这个程序中,多个线程同时对 list
进行读写操作,缺乏同步机制可能导致数据不一致、异常抛出(如 ConcurrentModificationException
)等问题。
问题解决
synchronized解决
可以使用 synchronized
关键字对添加和读取操作进行同步,确保同一时刻只有一个线程可以修改或读取列表,从而避免竞态条件。
1 |
|
COW解决
写时复制通过在写操作时复制数据结构,修改副本,然后将副本替换原始数据,确保读操作无需加锁即可安全进行。
1 |
|
COW
不用在业务代码中显式地加锁,只需要将存储元素的列表实现替换为 CopyOnWriteArrayList
即可,最大程度减少了代码入侵、使代码简洁易懂。
注意事项
- 优先确认业务场景:只有在“读多写少”且“不需要强实时一致性”的情况下,才适合使用
COW
。否则可能引起不必要的内存浪费和写开销。 - 批量写入优化:批量添加时应该使用
addAll
,提高性能。 - 控制写操作频率:若写操作比较频繁,
CopyOnWriteArrayList
或CopyOnWriteArraySet
的性能会大幅下降。可以考虑其他并发集合,如ConcurrentHashMap
、ConcurrentSkipListMap
、ConcurrentLinkedQueue
等,或使用锁等手段进行同步。 - 避免数据体量过大:因为每次写都会复制整个内部数组,大体量数据的情况下会消耗较多的 CPU 和内存。
- 使用迭代器遍历时要小心修改:虽然不会抛
ConcurrentModificationException
,但要注意遍历的结果和真实数据可能不同步。如果读取过程中有新数据加入,当前迭代器看不到。
总结
Copy-On-Write
策略巧妙地分离了“读”与“写”对数据结构的影响:所有读线程都共享一份当前的稳定数据,写线程则在副本上完成更新后再切换引用,从而使读操作无须在锁中等待。在读多写少、不追求实时一致且内存相对充裕的场景下,Java 的 CopyOnWriteArrayList
与 CopyOnWriteArraySet
能够明显减少读写竞争,大幅提升系统在高并发读访问下的吞吐量与响应速度。但若应用对写操作性能要求较高,或数据规模非常庞大,那么频繁的复制会带来额外的 CPU 与内存开销,此时可考虑更适合的并发数据结构(如 ConcurrentHashMap
、ConcurrentSkipListMap
、或基于锁分段的方案)来平衡读写需求。通过结合自身业务特点来选择不同并发容器,才能做到性能与成本的双赢。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!