← 返回题目列表

线程安全的 List 有哪些?同步容器和并发容器有什么区别?

中等 第 19 / 30 题 更新于 2026/07/26
线程安全VectorCopyOnWriteArrayList并发容器

简化版

线程安全的 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)VectorHashtableCollections.synchronizedXxx。做法简单粗暴——把每个方法都用同一把对象锁串行化。同一时刻只有一个线程能操作,安全但并发度极低(读操作也要抢锁),且没解决复合操作和遍历的问题。
  • 并发容器(concurrent collections)CopyOnWriteArrayListConcurrentHashMapConcurrentLinkedQueue 等,JUC 包提供。用更聪明的设计(写时复制、CAS 无锁、分段/桶级锁)替代「一把大锁」,让读写尽量不互相阻塞,并发度高得多。

一句话:同步容器用「加锁串行」保安全,牺牲并发;并发容器用「精细设计」保安全,兼顾并发。面试问「线程安全 List」,答出这层演进就到位了。

二、Vector:为什么被淘汰

Vector 是 JDK 1.0 就有的类,每个方法(get/add/size…)都带 synchronized。它的问题:

  1. 读也要锁:即使只是 get(i),也要获取对象锁,多线程读场景完全无法并行,白白串行;
  2. 锁粒度太粗:整个 Vector 一把锁,任何操作都互斥;
  3. 复合操作仍不安全if(v.size()>0) v.get(0) 两步之间锁已释放,别的线程可能已清空;
  4. 扩容 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);  ←又添加一次!重复了
}                               }

containsadd 各自加锁,但两者之间锁是释放的,别的线程能插进来。要让复合操作原子,必须在外层再加同一把锁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 读免锁写复制,按读写比例选」,这题就答透了。