线程安全的 List 有哪些?同步容器和并发容器有什么区别?
简化版
线程安全的 List 主要三种:Vector(古老,方法全 synchronized)、Collections.synchronizedList(list)(给普通 List 套一层同步壳)、CopyOnWriteArrayList(并发容器,写时复制)。前两者是「同步容器」——所有方法用同一把锁串行化,并发度低,且「复合操作」和「遍历」仍不安全;CopyOnWriteArrayList 是「并发容器」——读不加锁、写时复制新数组,读多写少场景性能好得多。区别本质:同步容器靠粗粒度锁保平安,并发容器靠更精细的设计(写时复制、CAS、分段)提升并发度。
详细版
三种线程安全 List:
| 方案 | 实现方式 | 读性能 | 写性能 | 适用 |
|---|---|---|---|---|
Vector | 每个方法加 synchronized | 差(读也要锁) | 差 | 遗留代码,新项目别用 |
Collections.synchronizedList | 包装类,方法体加 synchronized 块 | 差 | 差 | 临时给非并发 List 加锁 |
CopyOnWriteArrayList | 写时复制新数组,读无锁 | 极好(无锁) | 差(每写全量复制) | 读多写极少(如监听器列表) |
同步容器的两个坑(Vector 和 synchronizedList 都有):
List<Integer> list = Collections.synchronizedList(new ArrayList<>());
// 坑1:复合操作不原子——check-then-act 之间锁已释放
if (!list.contains(x)) { // 加锁、判断、释放锁
list.add(x); // 再加锁、添加——两步之间别的线程可能已插入 x
}
// 坑2:遍历必须手动加锁,否则 ConcurrentModificationException
synchronized (list) { // 遍历要锁住整个 list
for (Integer i : list) { ... }
}
CopyOnWriteArrayList 原理:写操作(add/set/remove)先加锁,复制一份新数组,在新数组上改,再把引用指向新数组;读操作直接读当前数组、完全不加锁。所以读永远看到一个「一致的快照」,读写不互相阻塞。
List<String> listeners = new CopyOnWriteArrayList<>();
// 读多(频繁遍历通知)写少(偶尔增删监听器)→ 完美契合
⚠️
CopyOnWriteArrayList每次写都全量复制数组,写多或数据量大时内存和性能都很差;它只适合「读远多于写」。别拿它当通用线程安全 List。
完整版教学
一、先分清两个概念:同步容器 vs 并发容器
Java 线程安全集合分两代:
- 同步容器(synchronized collections):
Vector、Hashtable、Collections.synchronizedXxx。做法简单粗暴——把每个方法都用同一把对象锁串行化。同一时刻只有一个线程能操作,安全但并发度极低(读操作也要抢锁),且没解决复合操作和遍历的问题。 - 并发容器(concurrent collections):
CopyOnWriteArrayList、ConcurrentHashMap、ConcurrentLinkedQueue等,JUC 包提供。用更聪明的设计(写时复制、CAS 无锁、分段/桶级锁)替代「一把大锁」,让读写尽量不互相阻塞,并发度高得多。
一句话:同步容器用「加锁串行」保安全,牺牲并发;并发容器用「精细设计」保安全,兼顾并发。面试问「线程安全 List」,答出这层演进就到位了。
二、Vector:为什么被淘汰
Vector 是 JDK 1.0 就有的类,每个方法(get/add/size…)都带 synchronized。它的问题:
- 读也要锁:即使只是
get(i),也要获取对象锁,多线程读场景完全无法并行,白白串行; - 锁粒度太粗:整个 Vector 一把锁,任何操作都互斥;
- 复合操作仍不安全:
if(v.size()>0) v.get(0)两步之间锁已释放,别的线程可能已清空; - 扩容 2 倍(ArrayList 是 1.5 倍),更浪费内存。
所以新代码里 Vector 已被淘汰。要单线程用 ArrayList,要线程安全用并发容器或显式锁。
三、Collections.synchronizedList:装饰器加锁
Collections.synchronizedList(list) 返回一个包装对象,内部持有原 list,每个方法用 synchronized(mutex){ ... } 包住再委托给原 list:
synchronizedList
├── 内部 list(真正存数据的 ArrayList)
└── 每个方法: synchronized(mutex){ list.method(); }
它比 Vector 灵活(可以包装任意 List),但本质一样是「一把锁串行化」,读写都要锁,并发度同样低。而且它有个必须记住的坑:遍历不会自动加锁——迭代器方法没被包,for-each 遍历时若别的线程修改,照样抛 ConcurrentModificationException。所以遍历它时必须手动 synchronized(list){ 迭代 }。
四、复合操作为什么仍不安全
这是同步容器最容易被忽视的陷阱。同步容器只保证单个方法原子,不保证多个方法组成的复合操作原子:
线程 A 线程 B
if (!list.contains(x)) { ←加锁判断、释放
if (!list.contains(x)) { ←也判断为不含
list.add(x); ←加锁添加
list.add(x); ←又添加一次!重复了
} }
contains 和 add 各自加锁,但两者之间锁是释放的,别的线程能插进来。要让复合操作原子,必须在外层再加同一把锁:synchronized(list){ if(!contains) add; }。这也说明:同步容器不能免除你对「操作组合」的并发思考。
五、CopyOnWriteArrayList:写时复制的智慧
CopyOnWriteArrayList(COW)是并发容器的代表,思路巧妙——读写分离,牺牲写来成全读:
读(get/遍历):直接读当前数组引用,完全不加锁 → 极快
写(add/set/remove):
1. 加锁(只锁写与写之间)
2. 复制一份新数组 Arrays.copyOf
3. 在新数组上修改
4. volatile 引用指向新数组
5. 解锁
好处:读永远无锁,且读到的是一个不变的快照,遍历过程中即使别人在写也不会抛 ConcurrentModificationException(读的是旧数组)。代价也明显:
- 每次写都全量复制整个数组,O(n),写多或数组大时极慢、内存翻倍;
- 读可能读到旧数据(弱一致性)——写完成前,读看到的是旧快照。
所以它的定位非常窄:读远多于写、且能容忍短暂旧数据。经典场景是「事件监听器列表」「配置白名单」——启动时写入、运行时海量遍历、极少改动。
六、选型决策表
| 场景 | 推荐 | 理由 |
|---|---|---|
| 单线程 | ArrayList | 无锁最快 |
| 读多写极少(监听器/白名单) | CopyOnWriteArrayList | 读无锁,写时复制 |
| 读写都频繁的并发队列 | ConcurrentLinkedQueue / BlockingQueue | 无锁或阻塞队列,专为高并发设计 |
| 临时给现有 List 加锁 | Collections.synchronizedList + 手动锁遍历 | 简单,但并发度低 |
| 遗留代码 | Vector | 不推荐,仅维护旧代码时遇到 |
关键判断:读写都频繁时,别用 COW(写太贵)也别用同步容器(并发太低),应换用队列类并发容器或用 ConcurrentHashMap 重新设计数据结构。没有「万能线程安全 List」,要按读写比例选。
记忆钩子:「同步容器一把锁串行,读也堵;并发容器 COW 读无锁、写复制」;COW 只配读多写少,复合操作和遍历同步容器都要你自己再加锁。
七、常见误区与追问
- 误区:用了 synchronizedList 遍历就安全了。 不,遍历不自动加锁,必须手动
synchronized(list)包住迭代,否则仍抛 ConcurrentModificationException。 - 误区:同步容器能保证复合操作原子。 只保证单方法原子;check-then-act 这类组合仍需外层加锁。
- 误区:CopyOnWriteArrayList 适合通用线程安全场景。 只适合读多写极少;写多时全量复制导致性能和内存双崩。
- 误区:Vector 和 ArrayList 只是线程安全的区别。 还有扩容倍数不同(Vector 2 倍、ArrayList 1.5 倍),且 Vector 读也加锁性能差。
- 追问:CopyOnWrite 为什么读能不加锁? 写操作在新数组上改完再原子切换 volatile 引用,读始终读到一个完整不可变的旧/新数组快照,不会读到中间态,所以无需锁。
- 追问:CopyOnWriteArrayList 的迭代器能删除元素吗? 不能,迭代器基于创建时的快照,
remove/set/add会抛 UnsupportedOperationException;要改只能调用 list 本身的方法。 - 追问:读写都频繁该用什么? List 语义少见这种并发需求;通常改用
ConcurrentLinkedQueue(无锁队列)或用ConcurrentHashMap承载,避免 COW 的写放大和同步容器的低并发。
八、加强记忆
线程安全 List 分两代看:第一代同步容器(Vector、Collections.synchronizedList)就是「一把对象锁把每个方法串行化」,安全但读也要抢锁、并发度极低,而且两个致命细节要记牢——复合操作(check-then-act)不原子、遍历不自动加锁,都得你在外层再套同一把锁。第二代并发容器用更聪明的设计提升并发:CopyOnWriteArrayList 走「写时复制」,读完全无锁、读的是不变快照(遍历不抛 CME),代价是每次写全量复制数组,所以只配读多写极少(监听器、白名单)。选型没有银弹:单线程 ArrayList,读多写少 COW,读写都多改用队列类并发容器或 ConcurrentHashMap 重构。一句话「同步容器锁串行、COW 读免锁写复制,按读写比例选」,这题就答透了。