Java并发编程——Copy-On-Write

本文最后更新于:3 年前

引言

在高并发读场景下,如果读操作远多于写操作,传统的互斥锁可能成为瓶颈。Copy-On-WriteCOW)机制旨在充分利用“读多写少”的特性:对数据结构的写操作时才进行复制,从而极大地降低读操作的竞争与锁开销。本文将从 COW 的原理、在 Java 中的具体实现(CopyOnWriteArrayListCopyOnWriteArraySet)以及使用注意事项等方面展开,帮助你在特定场景下写出更高效、更易维护的多线程程序。

简介

Copy-On-Write(简称 COW)机制是一种在多线程环境下用于提高读操作效率的并发策略。其核心思想是:当有写操作(如新增、修改、删除)发生时,并不直接在原有数据结构上进行修改,而是先拷贝一份新的副本,然后在副本上进行写操作。写操作结束后,再将引用指向新的数据结构,从而避免了对读操作的阻塞。

Java 标准库中提供了写时复制的集合类,CopyOnWriteArrayListCopyOnWriteArraySet 等类就是基于写时复制机制实现的线程安全集合,它们在读操作远多于写操作的场景下有不错的性能表现。

核心思想

COW 机制的核心思想是:写操作时进行“复制”,读操作时直接访问原有对象。这样做可以显著减少在读高并发时竞争同一把锁的情况。也就是说,COW 使得读操作无需阻塞写操作,写操作也无需阻塞读操作。

在 Java 的 CopyOnWriteArrayListCopyOnWriteArraySet 中,都遵循下面的流程:

  1. 当调用写操作方法(如 add()remove()set() 等)时,它会先复制当前容器的全部内容到一个新数组中。
  2. 在新数组上执行对应的写操作。
  3. 写操作完成后,将容器内部引用切换到新数组上。
graph TD
    A[原始数据] --> B[读取请求]
    A --> C[修改请求]
    C --> D[创建副本]
    D --> E[修改副本]
    E --> F[替换引用]

原理

数据结构

1
private transient volatile Object[] array;
  • 内部维护volatile数组保证可见性。

读操作原理

1
2
3
public E get(int index) {
return get(getArray(), index);
}
  • 读操作直接使用当下有效的内部数组(即当前引用所指向的数组)进行遍历或读取。
  • 读取过程中容器内部引用不会改变,对读操作而言是可见且稳定的。

写操作原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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();
}
}
  • 写操作需要先获取一定的锁(通常是对象级锁)。
  • 一旦获取到锁,容器内部会拷贝原数组并在副本上进行对应的操作(如添加元素、删除元素等)。
  • 完成之后,将引用从旧数组切换到新的数组,写操作结束。
  • 由于拷贝发生在写操作期间,所有对旧数组的读操作都不受影响,新操作完成后,后续的读操作则看到新的数组。

特点

优点

  1. 高效的并发读取:读操作几乎无锁,因为读操作直接访问的是共享的数据结构,能保证在大多数情况下无锁或最小化锁开销。
  2. 线程安全:多个读操作可以同时进行,且不会互相阻塞,也不会被写操作阻塞。写操作在拷贝完成并更新引用后,读操作会立即看到更新后的新副本。
  3. 迭代器的弱一致性:迭代器基于快照,避免了ConcurrentModificationException,适合于某些需要遍历集合的场景。

缺点

  1. 高内存消耗:每次写操作都需要复制整个数据结构,对于大规模数据结构和频繁写操作,会导致内存消耗增大。
  2. 写操作性能较低:写操作需要复制数据副本,导致写性能较低,不适用于写操作频繁的场景。
  3. 延迟写入:由于写操作是在副本上进行,可能会带来一定的延迟,影响实时性要求较高的应用。
  4. GC 压力:每次写操作均会重新复制所有元素对 GC 造成的压力,这在长时间运行的高并发系统中可能显著增大 Full GC 的触发频率,需要在实际应用中谨慎评估。

适用场景

  • 读多写少的并发场景:系统参数配置、配置信息缓存、热点数据缓存等读取操作远多于写入操作场景,COW 可以提供高效的并发读取性能。
  • 不需要实时一致性:对读操作来说,容器内部的数据在写操作瞬间可能已经“旧”了。但对大多数场景来说,这个瞬时不一致通常可以接受,并能显著提升读性能。
  • 需要迭代且不希望迭代过程中被修改COW 提供了基于快照的迭代器,避免了迭代过程中集合被修改导致的问题。
  • 内存相对充裕:由于每次写操作都会创建新副本,占用更多的内存,所以对内存的消耗有一定要求。

具体实现

Java在并发包java.util.concurrent中提供了两种基于写时复制的集合实现:

CopyOnWriteArrayList

CopyOnWriteArrayListList 接口的线程安全实现,基于数组实现,并采用写时复制策略。它适用于读多写少的场景,如缓存列表、事件监听器列表等。

内部实现

  • 底层使用一个 volatile 修饰的数组 array 来存储元素。
  • 写操作(如addsetremove等)会先加锁,然后创建一个新的数组副本,进行修改后将引用指向这个新数组。
  • 读取操作(如getiterator等)直接读取当前的 array 数组,无需加锁。

特点

  • 线程安全:所有的修改操作都是通过复制底层数组来实现的,确保了线程安全。
  • 高效的并发读取:读取操作无需加锁,可以高效地并发执行。
  • 迭代器的弱一致性:迭代器基于创建时的数组快照,因此不会抛出ConcurrentModificationException,但可能不会反映最新的修改。

CopyOnWriteArraySet

CopyOnWriteArraySetSet 接口的线程安全实现,基于 CopyOnWriteArrayList 实现。它确保集合中没有重复的元素,适用于需要保证元素唯一且读多写少的场景。

内部实现

  • 基于 CopyOnWriteArrayList 实现,所有添加元素的操作都会检查元素是否已存在,避免重复。
  • 写操作与 CopyOnWriteArrayList 相同,创建新的数组副本进行修改。
  • 读取操作无需加锁,可以高效地并发执行。

特点

  • 线程安全:继承自 CopyOnWriteArrayList,通过 COW 机制实现。
  • 保证元素唯一:通过内部机制确保集合中不包含重复元素。
  • 高效的并发读取:与CopyOnWriteArrayList相同,读取操作无需加锁。

问题引入

假设有一个共享的列表,多个线程需要频繁读取和偶尔写入元素。没有适当的同步机制,可能会导致数据不一致或竞态条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class ListTestClient {

private static final List<String> list = new ArrayList<>();

/**
* 添加元素到列表
*/
private synchronized void addElement(String element) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
list.add(element);
}

/**
* 获取列表
*/
private synchronized List<String> getList() {
return list;
}

public static void main(String[] args) {
ListTestClient client = new ListTestClient();
List<Thread> threads = new ArrayList<>();

// 启动多个线程添加元素
for (int i = 0; i < 100; i++) {
Thread t = new Thread(() -> client.addElement("Element"));
threads.add(t);
t.start();
}

// 启动多个线程读取列表
for (int i = 0; i < 100; i++) {
Thread t = new Thread(() -> {
List<String> currentList = client.getList();
System.out.println("List size: " + currentList.size());
});
threads.add(t);
t.start();
}

// 等待所有线程完成
threads.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});

System.out.println("Final list size: " + client.getList().size());
}

}

这个程序中,多个线程同时对 list 进行读写操作,缺乏同步机制可能导致数据不一致、异常抛出(如 ConcurrentModificationException)等问题。

问题解决

synchronized解决

可以使用 synchronized 关键字对添加和读取操作进行同步,确保同一时刻只有一个线程可以修改或读取列表,从而避免竞态条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ListTestClient {

private static List<String> list = new ArrayList<>();

/**
* 添加元素到列表(同步方法)
*/
private synchronized void addElement(String element) {
list.add(element);
}

/**
* 获取列表(同步方法)
*/
private synchronized List<String> getList() {
return new ArrayList<>(list);
}

// main 方法同上
}

COW解决

写时复制通过在写操作时复制数据结构,修改副本,然后将副本替换原始数据,确保读操作无需加锁即可安全进行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private static final List<String> list = new CopyOnWriteArrayList<>();

/**
* 添加元素到列表
*/
private void addElement(String element) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
list.add(element);
}

/**
* 获取列表
*/
private List<String> getList() {
return list;
}

COW 不用在业务代码中显式地加锁,只需要将存储元素的列表实现替换为 CopyOnWriteArrayList 即可,最大程度减少了代码入侵、使代码简洁易懂。

注意事项

  • 优先确认业务场景:只有在“读多写少”且“不需要强实时一致性”的情况下,才适合使用 COW。否则可能引起不必要的内存浪费和写开销。
  • 批量写入优化:批量添加时应该使用 addAll,提高性能。
  • 控制写操作频率:若写操作比较频繁,CopyOnWriteArrayListCopyOnWriteArraySet 的性能会大幅下降。可以考虑其他并发集合,如 ConcurrentHashMapConcurrentSkipListMapConcurrentLinkedQueue 等,或使用锁等手段进行同步。
  • 避免数据体量过大:因为每次写都会复制整个内部数组,大体量数据的情况下会消耗较多的 CPU 和内存。
  • 使用迭代器遍历时要小心修改:虽然不会抛 ConcurrentModificationException,但要注意遍历的结果和真实数据可能不同步。如果读取过程中有新数据加入,当前迭代器看不到。

总结

Copy-On-Write 策略巧妙地分离了“读”与“写”对数据结构的影响:所有读线程都共享一份当前的稳定数据,写线程则在副本上完成更新后再切换引用,从而使读操作无须在锁中等待。在读多写少、不追求实时一致且内存相对充裕的场景下,Java 的 CopyOnWriteArrayListCopyOnWriteArraySet 能够明显减少读写竞争,大幅提升系统在高并发读访问下的吞吐量与响应速度。但若应用对写操作性能要求较高,或数据规模非常庞大,那么频繁的复制会带来额外的 CPU 与内存开销,此时可考虑更适合的并发数据结构(如 ConcurrentHashMapConcurrentSkipListMap、或基于锁分段的方案)来平衡读写需求。通过结合自身业务特点来选择不同并发容器,才能做到性能与成本的双赢。