写时复制COW核心原理解读

前言

写时复制(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();
    }
}

虽然读操作无锁,但写操作需要互斥访问,通常通过锁来实现。

从以上代码还涉及一些知识点:

  1. 底层数组长度等于真实长度(capacity === size),不需要额外分配长度以应对后续的添加操作,这样实现减少了内存占用。
  2. 使用copyOf或者arraycopy 可以实现复制效率最大化。
  3. 显式锁的标准使用方式(JDK21中为隐式锁实现,官方更偏向隐式锁实现)
  4. 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的基本方法:

  1. 维护最新版本 volatile Map
  2. 写时复制,加锁保护
  3. 无锁读,返回结果防止修改

这里需要注意的是,由于维护的最新版本底层实现为 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并发模型的性能会明显下降。常见的优化思路为:减少复制次数,尽量合并写操作。

读写锁模型则在读写操作较为均衡的场景下表现较好。读写锁允许多个线程同时进行读操作,提高了读操作的并发度。同时,在进行写操作时,会独占锁,保证了数据的一致性。因此,读写锁模型在读写比例适中的场景下可以提供较好的性能。


使用可持久化数据结构可以进一步提升写操作的性能,下篇文章再详细分析。

相关推荐
幻奏岚音2 分钟前
Java数据结构——第一章Java基础回顾
java·开发语言·jvm·笔记·学习
岁忧4 分钟前
(LeetCode 每日一题) 2016. 增量元素之间的最大差值 (数组)
java·c++·算法·leetcode·职场和发展·go
爬虫程序猿12 分钟前
如何利用 Java 爬虫按关键字搜索 Amazon 商品:实战指南
java·开发语言·爬虫
风之旅人1 小时前
开发必备"节假日接口"
java·后端·开源
2201_753169471 小时前
implement用法
java·开发语言
不会编程的阿成2 小时前
spring aop的概念与实战以及面试项目题
java·spring·面试
李强57627822 小时前
语法制导的语义计算(包含python源码)
java·数据库·python
鼠鼠我捏,要死了捏2 小时前
Java开发企业微信会话存档功能笔记小结(企业内部开发角度)
java·企业微信·会话存档
wx_ywyy67982 小时前
“微信短剧小程序开发指南:从架构设计到上线“
java·python·短剧·短剧系统·海外短剧·推客小程序·短剧系统开发
缘友一世3 小时前
设计模式之五大设计原则(SOLID原则)浅谈
java·算法·设计模式