深入剖析CopyOnWriteArrayList:并发场景下的读写分离利器(附电商购物车实战案例)
📑 目录导读
- 并发List的三大痛点:为什么Vector和Collections.synchronizedList不够用?
- CopyOnWriteArrayList核心机制:写时复制如何实现无锁读?
- 源码级原理拆解:add()/get()方法到底做了什么?
- 电商购物车实战案例:高并发下如何用CopyOnWriteArrayList保证数据一致性
- 性能对比与适用场景:什么时候用它,什么时候该放弃?
- 高频面试问答:你被问到的“坑”都在这里
并发List的三大痛点
在多线程环境下,ArrayList是非线程安全的,传统解决方案有两个:

Vector:所有方法加synchronized锁,读多写少时性能极差,因为读操作也被迫串行化。Collections.synchronizedList():同样是对方法加锁,且迭代时需要手动加锁,否则会抛出ConcurrentModificationException。
关键痛点:这两种方案在“读多写少”的场景下(比如缓存配置、商品列表、推送消息队列),锁竞争成了最大瓶颈,读线程明明可以并发,却要排队等待写锁释放。
CopyOnWriteArrayList核心机制:写时复制
CopyOnWriteArrayList(简称COWList)是java.util.concurrent包下的并发List,其设计哲学是:
“读时不加锁,写时复制整个数组”
原理三步曲:
- 读操作:直接获取内部
volatile修饰的Object[] array引用,无锁读取。 - 写操作:先加
ReentrantLock锁,然后复制一份新数组,在新数组上修改,最后将新数组引用赋值给volatile变量。 - 迭代器:基于快照(迭代器创建时的数组)遍历,永远不会抛出
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是“读多写极少”场景下的王者,用空间换并发,用最终一致性换高吞吐,理解它的“写时复制”原理,你就能在并发编程中多一件锋利的兵器。