前言
写时复制(COW/CopyOnWrite)机制是一种以空间换时间的读写分离策略。当进行写操作时,COW机制会触发一次深拷贝,将原始数据复制一份,然后在新的数据副本上进行修改。最后,通过原子性指针切换将引用指向新的数据副本。这种机制的优点是读操作不需要加锁,可以并发执行,从而提高了读操作的性能。常见的Java中的CopyOnWriteArrayList就是基于COW机制实现的,它在多线程环境下可以高效地进行读操作。
本文将以COWArrayList为例,详细解读COW思想,内容还涉及COWMap 的实现,版本管理如何使用COW,不可变实现和COW如何结合等。
从 COWArrayList 看COW实现
1. 并发安全
java
// 并发写
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
range(0, 10).parallel().forEach(list::add);
System.out.println("list = " + list);
// output: random order list = [6, 5, 8, 9, 7, 1, 0, 3, 2, 4]
java
// 并发写 + 并发读
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
CompletableFuture<Void> fut1 = runAsync(() -> range(0, 10).parallel().forEach(list::add));
CompletableFuture<Void> fut2 = runAsync(() -> range(0, 10).parallel().forEach(x -> System.out.println("list = " + list));
fut1.runAfterBoth(fut2, () -> System.out.println("list = " + list)).join();
/* output:
list = [6, 5, 8, 9, 7, 1, 0, 3]
list = [6, 5, 8, 9, 7]
list = [6, 5, 8, 9, 7, 1, 0, 3]
list = [6, 5, 8, 9, 7, 1, 0, 3, 2, 4]
list = [6, 5, 8, 9, 7, 1, 0, 3, 2, 4]
list = [6, 5, 8, 9, 7, 1, 0, 3, 2]
list = [6, 5, 8, 9, 7, 1, 0, 3, 2, 4]
list = [6, 5, 8, 9, 7, 1, 0, 3, 2, 4]
list = [6, 5, 8, 9, 7, 1, 0, 3, 2, 4]
list = [6, 5, 8, 9, 7, 1, 0, 3, 2, 4]
last read list = [6, 5, 8, 9, 7, 1, 0, 3, 2, 4]
*/
多次运行的结果不稳定,但是最终结果一定包括0到9。 COWArrayList是线程安全的,从例子可以看出,多次并发写操作不会影响最终结果的正确性;每次读操作的结果为快照。
2. 读操作
CopyOnWrite 容器允许并发读,完全不需要加锁。这是它最大的优点,读操作性能很高。读操作返回的结果基于当前快照。
java
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>(Arrays.asList("1"));
// 并发读取
String element1 = list.get(0);
String element2 = supplyAsync(() -> list.get(0)).join();
list.set(0, "2");
Verify.verify(element1 == element2);
3. 写操作更新快照
每次写操作(add、set、remove 等)都会创建底层数组的一个新快照(副本),在新快照上执行修改,然后用新快照替换原来的数组。以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;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
虽然读操作无锁,但写操作需要互斥访问,通常通过锁来实现。
从以上代码还涉及一些知识点:
- 底层数组长度等于真实长度(capacity === size),不需要额外分配长度以应对后续的添加操作,这样实现减少了内存占用。
- 使用copyOf或者arraycopy 可以实现复制效率最大化。
- 显式锁的标准使用方式(JDK21中为隐式锁实现,官方更偏向隐式锁实现)
final ReentrantLock lock = this.lock;
一次读操作,从对象字段到局部变量,避免多次读操作,在追求极致性能或者线程安全场景下常用。
4. 只维护一个版本
java
private transient volatile Object[] array;
volatile保证多线程场景下读取底层数据存在唯一最新版本。
原则:读操作是当前读 + 无锁的。
当同时存在写操作和读操作时,读操作读取底层 volatile 数组,写操作还在进行中,读取不一定是最新的,但是能保证读操作在写操作之后时,必然读取写操作更新数组的结果。
以下是一个使用例子,容器的迭代器基于读取时的数据快照,完全不受后续写操作影响。
java
CopyOnWriteArrayList<LogEntry> logBuffer = new CopyOnWriteArrayList<>();
// 日志消费线程
new Thread(() -> {
Iterator<LogEntry> it = logBuffer.iterator();
while (it.hasNext()) {
persistLog(it.next()); // 迭代期间写入不影响当前迭代
}
}).start();
// 日志生产线程
new Thread(() -> {
logBuffer.add(new LogEntry("INFO", "System startup"));
}).start();
5. 视图
视图操作需要额外注意操作是否需要加锁、视图的特点、支持的操作等内容,总之,实现起来比COW本身复杂的多。
Iterator视图是只读的,需要注意快照创建的时间,其是立即求值的。所以对于迭代器的使用需要注意,其代表历史快照。
iterator的实现是静态视图。
java
public Iterator<E> iterator() {
return new COWIterator<E>(getArray(), 0);
}
static final class COWIterator<E> implements ListIterator<E> {
// 略去其他
/** Snapshot of the array */
private final Object[] snapshot;
/** Index of element to be returned by subsequent call to next. */
private int cursor;
COWIterator(Object[] es, int initialCursor) {
cursor = initialCursor;
snapshot = es;
}
public boolean hasNext() {
return cursor < snapshot.length;
}
public boolean hasPrevious() {
return cursor > 0;
}
@SuppressWarnings("unchecked")
public E next() {
if (! hasNext())
throw new NoSuchElementException();
return (E) snapshot[cursor++];
}
// 只读迭代器
public void add(E e) {
throw new UnsupportedOperationException();
}
}
subList 提供方便的 range 操作,但是源List和subList 不支持并发写,而且读操作需要加锁,需要特别注意。限于篇幅,后续我再写文章聊聊subList怎么使用吧。
JDK21提供了SequencedCollection#reversed实现,COWArrayList实现为动态视图,对于视图的写操作会作用到底层实现:
java
public List<E> reversed() {
return new Reversed<>(this, lock);
}
private static class Reversed<E> implements List<E>, RandomAccess {
public boolean addAll(int index, Collection<? extends E> c) {
@SuppressWarnings("unchecked")
E[] es = (E[]) c.toArray();
if (es.length > 0) {
ArraysSupport.reverse(es);
synchronized (lock) {
base.addAll(base.size() - index, Arrays.asList(es));
}
return true;
} else {
return false;
}
}
}
这里涉及锁的传递,仅以addAll为例,额外说一下相关知识点:
原则:涉及竞争条件的对象或字段,所有操作(读写操作)都要上锁。
addAll 的实现实际上为对底层数据prepend,通过lock,保证操作原子性,即保证size 方法和 addAll 方法都上锁,避免并发问题。
COWMap实现
COWMap实际上也是一个常见需求,kafka-client:2.5.2 中提供了org.apache.kafka.common.utils.CopyOnWriteMap ,可供参考:
java
public class CopyOnWriteMap<K, V> implements ConcurrentMap<K, V> {
// 唯一最新版本,保证可见性
private volatile Map<K, V> map;
// 写操作,COW
@Override
public synchronized V put(K k, V v) {
Map<K, V> copy = new HashMap<K, V>(this.map);
V prev = copy.put(k, v);
this.map = Collections.unmodifiableMap(copy);
return prev;
}
// 读操作,无锁
@Override
public V get(Object k) {
return map.get(k);
}
// 视图不可变(unmodifiable)
@Override
public Set<K> keySet() {
return map.keySet();
}
}
从以上代码可以看出,实现COW的基本方法:
- 维护最新版本 volatile Map
- 写时复制,加锁保护
- 无锁读,返回结果防止修改
这里需要注意的是,由于维护的最新版本底层实现为 unmodifiableMap,其返回的视图也是不可变更的,不需要再进行包装。
不可变对象与COW
不可变对象是指一旦创建,其状态就不能被修改的对象。通过消除修改行为,不可变对象可以规避同步问题。Java中的String类就是一个典型的不可变对象,它的绝大多数方法都不会修改对象的内部状态,而是返回一个新的对象。此外,Google的Guava库提供了一系列的不可变集合,如ImmutableList、ImmutableMap等,这些集合在多线程环境下可以安全地共享,无需额外的同步操作。
不可变对象具有天然的线程安全优势。由于其状态不可修改,多个线程可以同时访问同一个不可变对象,而不会出现竞争条件和可见性问题。例如,Java 中的String类、Guava 的 Immutable 集合,record 类,它们在多线程环境下可以安全地共享,无需额外的同步操作。
使用 ImmutableMap + COW原理可实现配置管理:
java
public class ConfigManager {
// 使用 volatile 保证多线程可见性
private volatile ImmutableMap<String, String> config = ImmutableMap.of();
private final ReentrantLock updateLock = new ReentrantLock();
/**
* 全量更新配置(原子操作)
* @param newConfig 新的配置映射
*/
public void updateAll(Map<String, String> newConfig) {
updateLock.lock();
try {
// 创建新的不可变配置快照
config = ImmutableMap.copyOf(newConfig);
} finally {
updateLock.unlock();
}
}
/**
* 增量更新单个配置项
*/
public void update(String key, String value) {
updateLock.lock();
try {
ImmutableMap<String, String> c = config;
config = ImmutableMap.<String, String>builder()
.putAll(Maps.filterKeys(c, k -> !k.equals(key))) // 过滤掉要更新的键
.put(key, value)
.build();
} finally {
updateLock.unlock();
}
}
/**
* 获取当前配置值(无锁读取)
* @param key 配置键
* @return 配置值(不存在时返回 null)
*/
public String get(String key) {
return config.get(key);
}
/**
* 获取配置快照(线程安全的不可变视图)
* @return 当前配置的不可变副本
*/
public ImmutableMap<String, String> getSnapshot() {
return config; // 直接返回不可变对象引用
}
}
性能
COW常常和读写锁进行比较。在性能方面,COW并发模型和读写锁模型各有优劣。COW并发模型在读多写少的场景下具有明显的优势,因为读操作不需要加锁,可以并发执行,从而提高了读操作的性能。然而,写操作需要进行深拷贝,会消耗大量的内存和CPU资源,因此在写操作频繁的场景下,COW并发模型的性能会明显下降。常见的优化思路为:减少复制次数,尽量合并写操作。
读写锁模型则在读写操作较为均衡的场景下表现较好。读写锁允许多个线程同时进行读操作,提高了读操作的并发度。同时,在进行写操作时,会独占锁,保证了数据的一致性。因此,读写锁模型在读写比例适中的场景下可以提供较好的性能。
使用可持久化数据结构可以进一步提升写操作的性能,下篇文章再详细分析。