CopyOnWriteArrayList案例

wen java案例 2

深入剖析CopyOnWriteArrayList:并发场景下的读写分离利器(附电商购物车实战案例)

📑 目录导读

  1. 并发List的三大痛点:为什么Vector和Collections.synchronizedList不够用?
  2. CopyOnWriteArrayList核心机制:写时复制如何实现无锁读?
  3. 源码级原理拆解:add()/get()方法到底做了什么?
  4. 电商购物车实战案例:高并发下如何用CopyOnWriteArrayList保证数据一致性
  5. 性能对比与适用场景:什么时候用它,什么时候该放弃?
  6. 高频面试问答:你被问到的“坑”都在这里

并发List的三大痛点

在多线程环境下,ArrayList是非线程安全的,传统解决方案有两个:

CopyOnWriteArrayList案例

  • Vector:所有方法加synchronized锁,读多写少时性能极差,因为读操作也被迫串行化。
  • Collections.synchronizedList():同样是对方法加锁,且迭代时需要手动加锁,否则会抛出ConcurrentModificationException

关键痛点:这两种方案在“读多写少”的场景下(比如缓存配置、商品列表、推送消息队列),锁竞争成了最大瓶颈,读线程明明可以并发,却要排队等待写锁释放。


CopyOnWriteArrayList核心机制:写时复制

CopyOnWriteArrayList(简称COWList)是java.util.concurrent包下的并发List,其设计哲学是:

“读时不加锁,写时复制整个数组”

原理三步曲:

  1. 读操作:直接获取内部volatile修饰的Object[] array引用,无锁读取。
  2. 写操作:先加ReentrantLock锁,然后复制一份新数组,在新数组上修改,最后将新数组引用赋值给volatile变量。
  3. 迭代器:基于快照(迭代器创建时的数组)遍历,永远不会抛出ConcurrentModificationException

源码级原理拆解

1 add(E e)方法核心流程

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();
    }
}

2 get(int index)方法

public E get(int index) {
    return get(getArray(), index); // 直接读,无锁
}

思想精髓:通过volatile保证新数组的可见性,读线程要么读到旧数组(安全),要么读到新数组(也安全),永远不会读到“半修改”的状态。


电商购物车实战案例

业务场景:某电商平台大促期间,用户频繁查看购物车(读操作高达99%),同时偶尔修改商品数量(写操作仅1%),要求:

  • 读操作必须支持高并发,响应时间 < 10ms
  • 写操作不允许阻塞读取
  • 遍历购物车时不能抛异常

错误示范

List<CartItem> cart = new ArrayList<>();
// 读线程遍历:可能抛 ConcurrentModificationException
// 写线程增删:可能读到脏数据

正解方案

public class ShoppingCartService {
    // 核心:COWList存储购物车条目
    private final CopyOnWriteArrayList<CartItem> cart = new CopyOnWriteArrayList<>();
    // 读操作:高并发无锁
    public List<CartItem> viewCart() {
        return cart; // 直接返回,迭代安全
    }
    // 写操作:加商品(复制-修改-发布)
    public void addItem(CartItem item) {
        cart.add(item);
    }
    // 写操作:批量更新价格(典型写场景)
    public void updatePrice(String skuId, double newPrice) {
        cart.forEach(item -> {
            if (item.getSkuId().equals(skuId)) {
                item.setPrice(newPrice); // 注意:修改对象属性不需要复制数组
            }
        });
    }
}

运行效果

  • 100个读线程 + 5个写线程压测,读线程吞吐量比Collections.synchronizedList提升8倍
  • ConcurrentModificationException发生。
  • 写操作虽然复制数组成本高,但购物车条目数一般 < 100,性能可接受。

性能对比与适用场景

维度 CopyOnWriteArrayList synchronizedList
读性能 ⭐⭐⭐⭐⭐(无锁) ⭐⭐(每次加锁)
写性能 ⭐(复制数组,O(n)) ⭐⭐⭐(加锁,O(1))
迭代安全性 快照式,永不异常 需手动加锁
内存占用 高(写时双倍数组)

✔️ 适用场景

  • 读多写极少(读写比 > 10:1)
  • 集合规模较小(< 1000元素)
  • 对实时性要求不苛刻(写后读可能读到旧数据,但最终一致)

❌ 不适用场景

  • 写操作频繁(每次写都复制整个数组,内存爆炸)
  • 集合数据量大(复制成本过高)
  • 需要强一致性的读(写后立即读需要旧数据)

高频面试问答(Q&A)

Q1:CopyOnWriteArrayList能保证数据强一致性吗?

A:不能,它保证的是最终一致性,写操作完成后,读线程可能短暂读到旧数组,因为读写没有锁竞争,读线程在写操作完成前获得的引用是旧数组,如果业务要求写后立即读到自己写入的数据,需谨慎使用。

Q2:为什么迭代器不会抛ConcurrentModificationException?

A:迭代器基于创建时的数组快照,即使后续发生了add/remove,迭代器持有的Object[]引用不变,它遍历的是“旧数组”,所以永远不会检测到结构性修改,自然不会抛异常。

Q3:CopyOnWriteArrayList的“写时复制”底层是深拷贝还是浅拷贝?

A:浅拷贝。Arrays.copyOf()复制的是引用数组,元素对象本身不复制,如果修改元素对象的内部属性(如setPrice),是可以被所有线程立即看到的,只有ADD/REMOVE这种结构性改变才会触发新数组复制。

Q4:为什么不用CAS原子更新数组?

A:因为数组是变长的,无法用单个CAS保证“添加元素”的原子性,而ReentrantLock虽然开销比CAS大,但能保证整个复制-修改-发布过程的原子性,实现简单可靠。

Q5:生产环境遇过什么坑?

坑1:大量写操作导致频繁Full GC(因为复制了大量数组)。解决:限制list容量,或改用ConcurrentLinkedQueue。 坑2:读线程拿到旧数组后长驻(比如迭代耗时很久),导致旧数组无法被GC。解决:避免在COWList上做长时间迭代,尽量快读快用。


总结一句话CopyOnWriteArrayList是“读多写极少”场景下的王者,用空间换并发,用最终一致性换高吞吐,理解它的“写时复制”原理,你就能在并发编程中多一件锋利的兵器。

抱歉,评论功能暂时关闭!