CopyOnWriteArrayList 原理

面试官问:"CopyOnWriteArrayList 是怎么实现线程安全的?"

候选人小方答:"每次写入时复制一份数据。"

面试官追问:"为什么读取不需要加锁?"

小方说:"因为...数据是最终一致的?"

面试官又问:"那它适合什么场景?"

小方答不上来。

【面试官心理】 CopyOnWriteArrayList 是一个经典的"空间换时间"的并发集合。能说出"读多写少"作为适用场景,并理解"读写分离"原理的候选人,说明对并发设计模式有积累。

一、核心原理:写时复制 🔴

public class CopyOnWriteArrayList<E> {
    /** 内部数组,用 volatile 保证可见性 */
    private transient volatile Object[] array;

    // 读取操作:无锁
    public E get(int index) {
        return get(getArray(), index);
    }

    // 写入操作:加锁 + 复制
    public boolean add(E e) {
        synchronized (lock) {
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1); // 复制数组
            newElements[len] = e;
            setArray(newElements); // 原子替换
            return true;
        }
    }
}

读写流程对比

读操作:
  array[0] ───────────────────→ 获取数据(无锁,直接访问)
  (可能读到旧数据)

写操作:
  线程1: 获取锁
        复制 array → newArray
        修改 newArray
        setArray(newArray) ← 原子替换
        释放锁
  现在:array ─────────────────→ 指向新数组

二、数据一致性分析 🔴

2.1 弱一致性

// CopyOnWriteArrayList 的读写是弱一致的
// 读线程可能在写线程修改期间读到旧数据

Thread1 (写): add("A") ────────→ array = [A]
Thread2 (读): get(0) ────────→ 可能在修改期间读到 null

Thread1: 锁住 → 复制数组 → 修改 → setArray → 释放锁
Thread2: 读取(无锁)   → 读到旧值或新值

2.2 为什么可以接受

适合的场景

  • 读远多于写(如配置信息、规则列表)
  • 短暂读到旧数据可以接受
  • 不需要实时一致性

不适合的场景

  • 写操作需要立刻被读到
  • 需要精确计数
  • 业务数据需要强一致性

三、性能特点 🟡

3.1 读性能

// 读操作:无锁,极快
for (E item : list) { // 遍历时无需加锁
    process(item);
}

3.2 写性能

// 写操作:每次都复制整个数组
// 数组大小 10000:每次 add 需要复制 10000 个引用
// 数组大小 100000:每次 add 需要复制 100000 个引用

// O(n) 写入复杂度
// 不适合频繁写入的场景

3.3 内存开销

// 每次写入都创建新数组,旧数组可能需要 GC 回收
// 1000 次写入 → 可能产生 1000 个废弃数组

// 如果写入频繁:
// 1. 大量临时数组,增加 GC 压力
// 2. 内存占用峰值高(新老数组同时存在)

四、适用场景 🟡

4.1 配置信息管理

// 场景:应用配置信息(读远多于写)
CopyOnWriteArrayList<ConfigRule> rules = new CopyOnWriteArrayList<>();

// 启动时加载配置(一次写入)
rules.add(new ConfigRule("rule1"));
rules.add(new ConfigRule("rule2"));

// 运行时读取(高频、无锁)
public ConfigRule findRule(String id) {
    for (ConfigRule rule : rules) { // 无锁遍历
        if (rule.getId().equals(id)) return rule;
    }
    return null;
}

// 动态更新配置(低频写入)
public void updateRule(String id, ConfigRule newRule) {
    rules.removeIf(r -> r.getId().equals(id)); // O(n) 查找 + 删除
    rules.add(newRule); // O(n) 复制 + 写入
}

4.2 监听器列表

// 场景:事件监听器集合
CopyOnWriteArrayList<EventListener> listeners = new CopyOnWriteArrayList<>();

// 添加监听器(低频)
public void addListener(EventListener listener) {
    listeners.add(listener);
}

// 触发事件(高频,可能在多线程)
public void fireEvent(Event e) {
    for (EventListener listener : listeners) { // 无锁遍历
        listener.onEvent(e);
    }
}

4.3 不适合的场景

// ❌ 不适合:高频写入
CopyOnWriteArrayList<Long> ids = new CopyOnWriteArrayList<>();
for (int i = 0; i < 1_000_000; i++) {
    ids.add((long) i); // ❌ 100万次复制,性能极差
}

// ✅ 适合:ConcurrentLinkedQueue(无锁链表)
ConcurrentLinkedQueue<Long> ids = new ConcurrentLinkedQueue<>();
for (int i = 0; i < 1_000_000; i++) {
    ids.add((long) i); // O(1) 写入
}

五、与 ConcurrentHashMap 对比 🟡

维度CopyOnWriteArrayListConcurrentHashMap
数据结构数组哈希桶 + 链表/红黑树
读操作无锁(极快)无锁(极快)
写操作复制整个数组(O(n))CAS/锁单个桶(O(1) 均摊)
内存开销高(每次复制)
适用场景读多写少读写都多

六、追问升级

面试官:"CopyOnWriteArrayList 的迭代器是 fail-safe 还是 fail-fast?"

CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("a");
Iterator<String> it = list.iterator();

// 在迭代过程中修改 list
list.add("b");

// 结果:迭代器仍然可以正常遍历
// 迭代器遍历的是创建时的快照(array 的副本)
for (String s : it) {
    System.out.println(s); // a(没有 b)
}

// 这是 fail-safe 的特性:
// 遍历的是创建时的快照,不会抛出 ConcurrentModificationException

答案:CopyOnWriteArrayList 的迭代器是 fail-safe(安全失败),因为它遍历的是数组的快照,而不是实时数据。

【面试官心理】 能说出 fail-safe 和 fail-fast 区别,并解释 CopyOnWriteArrayList 迭代器行为的候选人,说明对并发集合的迭代器机制有深入理解。