概述
Collections.synchronizedList 是 Java 集合框架中线程安全包装机制的典型实现。它以装饰器模式为基础,通过全局互斥锁(mutex)将任意 List 透明地转为线程安全容器,而不改变其底层数据结构。这种设计在提供便捷的同步能力同时,也天然带来了迭代器非同步、复合操作原子性缺失以及高并发下的锁竞争瓶颈。深入剖析它的锁模型、迭代器行为、与 Vector 的继承式同步差异以及与 CopyOnWriteArrayList 的读写分离对比,是系统性理解 Java 并发集合设计取舍与并发控制范式的基础。
- 装饰器模式的经典应用:
SynchronizedList不改变底层List结构,通过包装增加同步能力。 - 互斥锁(mutex)的设计:所有方法通过
mutex对象实现同步,支持自定义锁对象。 - 迭代器的同步陷阱:
iterator()返回的迭代器本身未同步,遍历时需手动使用synchronized块。 - 与 Vector 的前世今生:
Vector是 JDK 1.0 的全方法锁遗产,SynchronizedList是其轻量替代。 - 与 CopyOnWriteArrayList 的场景分野:锁阻塞 vs 写时复制,读多写少的抉择。
graph TB
subgraph Part1["Part 1: 基础认知篇"]
M1["模块1: 定义 核心特性与适用场景"]
M2["模块2: 设计模式——装饰器模式与同步包装器"]
end
subgraph Part2["Part 2: 源码实现与锁机制篇"]
M3["模块3: 内部结构与构造方法"]
M4["模块4: 核心操作 add/get/remove 同步实现"]
M5["模块5: listIterator/iterator 同步陷阱"]
end
subgraph Part3["Part 3: 集合视图与批量操作篇"]
M6["模块6: subList 视图的同步行为"]
M7["模块7: toArray/forEach 批量操作"]
end
subgraph Part4["Part 4: 并发对比篇"]
M8["模块8: SynchronizedList vs Vector"]
M9["模块9: SynchronizedList vs CopyOnWriteArrayList"]
end
subgraph Part5["Part 5: 实战陷阱篇"]
M10["模块10: 常见陷阱与最佳实践"]
M11["模块11: 注意事项与性能总结"]
end
subgraph Part6["Part 6: 面试高频专题"]
M12["模块12: 10 道必问面试题"]
end
Part1 --> Part2 --> Part3 --> Part4 --> Part5 --> Part6
M1 --> M2
M3 --> M4 --> M5
M8 --> M9
M10 --> M11
架构说明:
基础认知篇建立核心概念与设计模式理解;源码与锁机制篇深入 JDK 8 内部类与同步细节;集合视图与批量操作篇补全 subList 和批量操作的同步行为;并发对比篇用 Vector 和 CopyOnWriteArrayList 做横向比较;实战陷阱篇给出错误示例与最佳实践;最终所有面试考点凝练在独立的高频面试模块中,便于读者直接检索。
Part 1:基础认知篇
模块1:定义、核心特性与适用场景
定义
Collections.synchronizedList 是 java.util.Collections 提供的一个静态工厂方法,它接收一个普通的 List 对象,返回一个线程安全的 List 包装器。这个包装器在内部将所有对原 List 的访问委托给原对象,并在每一个方法调用前后加上 synchronized 同步块,从而实现线程安全。这种实现方式是装饰器模式的教科书级范例——它不改变原有集合的数据结构,而是动态地为它叠加同步能力。
核心特性
- 装饰器模式实现:返回的
SynchronizedList实现List接口,内部持有一个被包装的List实例c。所有操作委托给c,不侵入原集合类的结构。 - 基于 synchronized 同步块:每个关键方法都使用
synchronized(mutex)格式的同步块,而不是同步方法。这样做允许更灵活的锁策略,并且可以避免无意间将内部锁暴露给外部。 - 默认互斥锁 mutex:如果调用
Collections.synchronizedList(list)而不指定锁,mutex会被默认设置为this(即包装器对象自身)。也可以使用Collections.synchronizedList(list, mutex)指定自定义锁对象,便于多个包装集合共享一把锁以实现一致性。 - 迭代器自身不同步:
iterator()、listIterator()返回的迭代器未进行同步。设计者认为在迭代器内部加锁可能产生死锁,且无法保证在遍历期间数据不发生并发修改。因此必须由使用者在代码块外部手动加锁(synchronized(list){})。 - 视图模式:包装器本身只是一个视图,底层的原
List仍然可以被直接修改。如果外部代码绕开包装器直接修改原List,线程安全就被破坏。这属于典型的引用逃逸风险。
适用场景与决策树
graph TD
A["需要一个线程安全的 List"] --> B{"是否已有特定 List 实现?"}
B -->|"是 必须兼容"| C["使用 Collections.synchronizedList"]
B -->|"否"| D{"读写比例如何?"}
D -->|"读写均衡或写多读少"| C
D -->|"读远多于写"| E["使用 CopyOnWriteArrayList"]
C --> F{"是否需自定义锁对象?"}
F -->|"是"| G["使用 Collections.synchronizedList(list, mutex)"]
F -->|"否"| H["使用 Collections.synchronizedList(list)"]
E --> I["接受弱一致性且内存写时复制成本"]
C --> J["高并发写多时考虑 ConcurrentLinkedQueue 或锁分段方案"]
J --> K["但 List 接口无分段方案 可以选择并发队列替代"]
决策树说明
- 步骤1:判断是否有遗留代码必须使用
ArrayList或LinkedList等具体实现。如果是,直接包装;因为不能替换为其它并发集合类,装饰器模式保留了原集合类型。 - 步骤2:若无此限制,进入读写比例判断。若读操作占据绝对多数 (
>>90%),宜用CopyOnWriteArrayList,其读无锁且性能极高。若读写均衡或写操作较多,SynchronizedList的悲观锁可保证强一致性,且不需要复制整个数组。 - 步骤3:若需要多集合协同同步(例如两个 List 需要原子性地一起修改),可使用同一个自定义
mutex对象来包装多个集合,此时SynchronizedList的灵活性远胜Vector。 - 反例场景:极高并发下的写密集任务会导致锁争用成为热点,
SynchronizedList的全局互斥锁将严重限流吞吐。此时应重新思考数据结构选择,或使用并发队列设计替代。
模块2:设计模式——装饰器模式与同步包装器
装饰器模式在集合框架的地位
装饰器模式(Decorator)动态地将责任附加到对象上。集合框架大量应用了该模式:
Collections.unmodifiableList返回不可修改视图;Collections.checkedList返回类型安全视图;Collections.synchronizedList返回同步视图。
这些包装器都实现了 List 接口,内部引用原 List,在方法调用前后插入额外逻辑(如抛出异常、类型检查、或加锁)。这完美遵循开闭原则:对扩展开放,对修改关闭。我们无需改动 ArrayList 的源码,就能赋予它同步能力。
对比 Vector 的“继承同步”
Vector 是 Java 1.0 就存在的线程安全 List。它的同步方式是在关键方法上直接使用 synchronized 修饰符。例如 Vector.add(E) 是一个 synchronized 方法。这属于继承式的线程安全,即类自身“与生俱来”的同步。缺点包括:
- 锁对象固定为
this,无法更换,灵活性差。 - 所有实例方法都同步,即使单线程使用的场景也要承受锁开销。
- 强制同步导致扩展困难。若要对
Vector增加复合操作的原子性,仍需外部同步,但却无法和内部锁协调。
SynchronizedList 则通过组合(持有原 List)+装饰(外加同步)的方式实现,既保持了原 List 的所有行为,又可以独立控制锁对外暴露。这体现“组合优于继承”的原则。
classDiagram
class List{
<<interface>>
}
class AbstractList{
<<abstract>>
}
class Vector{
+add(E e) synchronized
+get(int index) synchronized
}
class ArrayList{
+add(E e)
+get(int index)
}
class SynchronizedCollection{
+final Collection c
+final Object mutex
+add(E e)
+size()
}
class SynchronizedList{
+final List list
+get(int index)
+listIterator()
}
class SynchronizedRandomAccessList{
}
List <|.. Vector : implements
List <|.. ArrayList : implements
AbstractList <|-- Vector
AbstractList <|-- ArrayList
List <|.. SynchronizedList : implements
SynchronizedCollection <|-- SynchronizedList
SynchronizedList <|-- SynchronizedRandomAccessList
SynchronizedList o-- List : wraps
SynchronizedList : +SynchronizedList(List list)
SynchronizedList : +SynchronizedList(List list, Object mutex)
类图解析
SynchronizedCollection是Collections的私有静态内部类,实现了Collection接口,是所有同步包装器的根基。它持有final Collection<E> c和final Object mutex。SynchronizedList继承SynchronizedCollection并实现List,增加针对List接口的方法(add(int index, E)、get、set、remove(int)、listIterator等)。它内部保留了对list的引用,实际上list就是传入的c。SynchronizedRandomAccessList进一步继承SynchronizedList并实现RandomAccess,用于在包装ArrayList等支持随机访问的集合时,保持RandomAccess标志,以便上层算法选择高效遍历策略。- 装饰器模式体现为:
SynchronizedList既是一个List,又包裹着一个真实List,在不改变原结构的基础上增加同步行为。
Part 2:源码实现与锁机制篇
模块3:SynchronizedList 的内部结构与构造方法(源码剖析)
我们基于 OpenJDK 8 的源码详细解析。Collections 类内部的同步包装器分成两个关键内部类:SynchronizedCollection 和 SynchronizedList。
SynchronizedCollection 核心结构 (简化):
static class SynchronizedCollection<E> implements Collection<E>, Serializable {
final Collection<E> c; // 被包装的集合
final Object mutex; // 互斥锁对象
SynchronizedCollection(Collection<E> c) {
this.c = Objects.requireNonNull(c);
mutex = this; // 默认锁对象为包装器自身
}
SynchronizedCollection(Collection<E> c, Object mutex) {
this.c = Objects.requireNonNull(c);
this.mutex = Objects.requireNonNull(mutex);
}
public int size() {
synchronized (mutex) { return c.size(); }
}
public boolean add(E e) {
synchronized (mutex) { return c.add(e); }
}
// ... 其他委托同步方法
}
SynchronizedList 核心实现:
static class SynchronizedList<E>
extends SynchronizedCollection<E>
implements List<E> {
final List<E> list; // c 的强转引用
SynchronizedList(List<E> list) {
super(list);
this.list = list;
}
SynchronizedList(List<E> list, Object mutex) {
super(list, mutex);
this.list = list;
}
public E get(int index) {
synchronized (mutex) { return list.get(index); }
}
public E set(int index, E element) {
synchronized (mutex) { return list.set(index, element); }
}
public void add(int index, E element) {
synchronized (mutex) { list.add(index, element); }
}
public E remove(int index) {
synchronized (mutex) { return list.remove(index); }
}
// 迭代器相关见模块5
}
设计意图:
- 字段
mutex提供了极大的灵活性。使用者可以跨多个同步包装器共享同一锁对象,实现复合操作的原子性,例如:这在多集合联动时至关重要。Object lock = new Object(); List<String> list1 = Collections.synchronizedList(new ArrayList<>(), lock); List<String> list2 = Collections.synchronizedList(new ArrayList<>(), lock); synchronized (lock) { list1.add("a"); list2.add("b"); } - 默认
mutex = this使得每个包装器实例自带一把私有锁,避免了锁泄露问题。
继承关系类图已在模块2展示。需注意 SynchronizedRandomAccessList,当传入的 List 实现 RandomAccess 时,Collections.synchronizedList 实际返回的是它的实例,这保证了外部代码在遍历时可以充分利用随机访问特性。
模块4:核心操作——add、get、remove 的同步实现(源码剖析)
add(E e) 方法
public boolean add(E e) {
synchronized (mutex) { return c.add(e); }
}
这里 c.add(e) 是委托给原始 ArrayList 或 LinkedList 的方法。同步块保证了原子性:在 add 执行期间,其它试图获取 mutex 锁的线程(如 get、remove 等)将被阻塞。这消除了数据竞争,使线程安全得以保证。
为什么使用 synchronized 块,而不是 synchronized 方法?
如果 add 方法声明为 synchronized,锁对象固定为 this(即包装器自身)。使用 synchronized (mutex) 块允许锁对象被自定义,从而支持多集合共享锁。此外,这也贯彻“封装锁”的原则:锁对象作为内部字段 mutex 存在,避免了外部直接对包装器实例加锁可能造成的混乱(但通常仍然鼓励对外暴露 mutex 来实现手动同步)。
get(int index)
public E get(int index) {
synchronized (mutex) { return list.get(index); }
}
读取操作同样需要加锁。因为 ArrayList 本身不是线程安全的,若不加锁,另一个线程可能正在执行结构性修改(如 add 触发的扩容),导致读取过程中数组越界或得到脏数据。SynchronizedList 通过将读写操作都包裹在同一把锁下,保证了强一致性。
remove(int index)
public E remove(int index) {
synchronized (mutex) { return list.remove(index); }
}
删除操作的同步逻辑一致,持有 mutex 进入底层 list.remove。
核心操作流程图:add 方法的同步执行
graph TD
A["线程调用 add(E e)"] --> B{"尝试获取 mutex 锁"}
B -->|"被其他线程占用"| C["线程阻塞等待"]
C --> B
B -->|"获取锁成功"| D["进入 synchronized 块"]
D --> E["调用底层 list.add(e)"]
E --> F["底层数组扩容等操作"]
F --> G["list.add 返回布尔值"]
G --> H["退出 synchronized 块"]
H --> I["释放 mutex 锁"]
I --> J["返回结果给调用者"]
流程图深度说明
- 锁竞争入口:每个线程在进入
add的synchronized (mutex)块前,必须成功获得mutex监视器锁。若锁已被占用,线程状态变为BLOCKED,等待锁释放。 - 原子边界:从成功获取锁到退出
synchronized块,其间所有操作(包括底层数组的修改)构成一个原子单元。对于ArrayList.add可能涉及的工作(检查容量、复制数组等),全部在锁保护之下。 - 重入性:Java 内置锁是可重入的。如果同一线程在已经持有
mutex的情况下再次调用SynchronizedList的其他同步方法(如复合操作中list.add后又调用list.get),不会发生死锁,因为synchronized允许该线程重入。 - 锁释放时机:无论
list.add正常返回还是抛出异常(例如OutOfMemoryError或索引越界),synchronized块结束都会释放锁,保证锁不会被永久占有。 - 数据一致性:在锁保护下,
add之前的状态是稳定的,add之后的更新对所有后续获取锁的线程立即可见(因为锁的释放会执行store屏障,将线程本地内存刷新到主存)。这满足 Java 内存模型的happens-before规则。 - 与 Vector 对比:
Vector.add方法本身是synchronized,锁是this,不可变;而SynchronizedList允许mutex为任意对象。后者更灵活但本质锁模型相同(悲观锁)。
模块5:listIterator 与 iterator 的同步陷阱(源码剖析)
这是 SynchronizedList 最易出错的区域。
源码剖析
JDK 8 中 iterator() 源码:
public Iterator<E> iterator() {
return c.iterator(); // Must be manually synched by user!
}
listIterator() 类似:
public ListIterator<E> listIterator() {
return list.listIterator(); // Must be manually synched by user!
}
注意注释:“必须由用户手动同步!” 返回的迭代器是底层集合的原生迭代器,没有经过任何同步包装。
设计原因
迭代过程中的同步极具挑战:
- 如果在迭代器的
hasNext()和next()内部加锁,会导致频繁的锁获取与释放(每次迭代都要加锁一次),性能极差,且仍无法保证迭代期间其它线程不修改集合。 - 更致命的是,如果在迭代过程中持锁不放(比如整个
while(hasNext())用synchronized包裹),也容易导致死锁或并发性能极度低下。 - 因此,设计者将同步的责任完全抛给使用者,要求在遍历时使用 客户端加锁:
synchronized (list) { Iterator i = list.iterator(); while(i.hasNext()) ... }。
时序图:同步方法调用 vs 迭代器灾难
sequenceDiagram
participant T1 as 线程1
participant T2 as 线程2
participant SyncList as SynchronizedList
participant BaseList as ArrayList底层
Note over T1,T2: 场景:T1 迭代,T2 写操作 (未手动同步)
T1->>SyncList: iterator() [无锁]
SyncList->>BaseList: iterator() 返回迭代器
T2->>SyncList: add(element) [尝试获取mutex锁]
T2-->>T2: 获得mutex
loop 迭代循环
T1->>BaseList: 迭代器.next()
Note over T1: 无锁保护
end
T2->>BaseList: list.add() 修改结构
T2-->>T2: 释放锁
T1->>BaseList: 后续next() -> ConcurrentModificationException
时序说明
- 步骤1:
T1调用iterator()。该方法没有同步,直接返回底层ArrayList迭代器。此时线程T1不持有任何锁。 - 步骤2:几乎同时,
T2调用add,获取mutex锁成功,执行结构修改(例如ArrayList的modCount增加)。 - 步骤3:在
T2释放锁之后,T1的迭代器继续调用next()。因为ArrayList.Itr内部会检查modCount,发现与预期不符,抛出ConcurrentModificationException。 - 核心教训:即使
SynchronizedList的单个操作是原子的,但跨越多个方法的复合操作不具原子性,迭代就是最典型的复合操作。用户必须通过synchronized (syncList) { ... }包裹整个迭代循环才能避免异常和数据不一致。
官方推荐遍历方式:
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
...
synchronized (syncList) {
Iterator<String> it = syncList.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}
}
注意:锁定的是包装器对象 syncList(当 mutex 为默认值时锁的是 this)。如果使用了自定义 mutex,则需锁定该 mutex 对象。
Part 3:集合视图与批量操作篇
模块6:subList 视图的同步行为
SynchronizedList.subList(from, to) 内部实现:
public List<E> subList(int fromIndex, int toIndex) {
synchronized (mutex) {
return new SynchronizedList<>(list.subList(fromIndex, toIndex), mutex);
}
}
关键点:
- 子视图依然是一个
SynchronizedList,并共享同一个mutex。这意味着对子列表的所有操作仍然受原锁保护。 - 原视图和子视图之间的修改相互可见,并且并发安全建立在同一锁上。
子列表同步流程
graph LR
A["原 SynchronizedList"] -->|"subList() in synchronized"| B["new SynchronizedList 子视图"]
B --> C["子视图 mutex 指向原 mutex"]
C --> D["对子视图操作需获取原锁"]
D --> E["保证了原集合和子集合的操作互斥"]
E --> F["但极端情况慎防: 原集合结构性变更导致子视图失效"]
说明
- 调用
subList时,整个方法在synchronized(mutex)内执行,保证了获取子列表这一操作的原子性。 - 新包装器的构造传入了同一个
mutex引用,因此原List和subList的同步操作会竞争同一把锁,不会出现原列表增加元素而子列表视图不一致的情况。 - 但要注意
subList视图的语义限制:底层ArrayList.subList返回的视图依赖于原列表结构,一旦原列表发生了结构性修改(非通过子列表),子视图将抛出ConcurrentModificationException。即使加锁,如果逻辑上先通过原列表add,再使用子列表遍历,同样会抛出该异常,这属于设计契约问题,非并发安全性。
模块7:批量操作——toArray、forEach 的同步实现
toArray 方法的同步
public Object[] toArray() {
synchronized (mutex) { return c.toArray(); }
}
toArray 在锁保护下获取底层数组的快照,返回的结果数组可以安全地脱离同步环境使用。
forEach 与 Java 8 Stream
SynchronizedList 并没有专门覆写 forEach(继承自 SynchronizedCollection):
public void forEach(Consumer<? super E> action) {
synchronized (mutex) { c.forEach(action); }
}
这意味着 forEach 操作在锁内执行,遍历期间其它线程无法修改集合,是安全的。
与流操作的结合
流操作的非干扰性要求源在操作期间不被修改。若要对同步包装器进行流操作,应该在 synchronized 块内生成流,然后快速执行终端操作,或者利用 toArray 生成副本后流式处理。示例:
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
// 添加元素省略...
synchronized (syncList) {
syncList.stream().filter(s -> s.startsWith("A")).collect(Collectors.toList());
}
这样既利用了锁,又避免了并发修改。
Demo 代码示例(可直接运行)
import java.util.*;
public class SynchronizedListBatchDemo {
public static void main(String[] args) {
List<String> list = Collections.synchronizedList(new ArrayList<>());
list.add("Alpha");
list.add("Beta");
list.add("Gamma");
// 批量操作 toArray
String[] arr;
synchronized (list) {
arr = list.toArray(new String[0]);
}
System.out.println(Arrays.toString(arr));
// 安全地 forEach
list.forEach(System.out::println);
// 安全复合操作:在锁保护下进行条件修改
synchronized (list) {
for (String s : list) {
if (s.equals("Beta")) {
list.remove(s);
}
}
}
System.out.println("After removal: " + list);
}
}
Part 4:并发对比篇
模块8:SynchronizedList vs Vector——继承同步 vs 包装同步
两者都是基于悲观锁的同步模型,但设计与行为细节有诸多不同。
源码层面对比
Vector.add(E):
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
SynchronizedList.add(E) 如前所述,依赖于 synchronized(mutex) 块委托给 ArrayList.add。
区别:
- 锁对象:
Vector锁固定为this;SynchronizedList锁可以是任意对象(默认this)。这赋予了后者跨集合共享锁的能力。 - 方法粒度:
Vector每个公有方法都synchronized,而SynchronizedList可以选择性地同步(虽然目前也同步了所有方法)。 - 性能差异:两者在同一基准下性能接近,
SynchronizedList由于多一层委托调用,微基准测试中可能略慢,但在实际应用中差异可忽略。 - 迭代器:
Vector的迭代器同样未能解决并发修改,遍历时仍需客户端加锁;但Vector有一个古老的Enumeration可用,性质类似。 - 额外能力:
Vector提供了capacity、elementAt等老旧方法,且扩展策略可定制;SynchronizedList则与标准List模型完全一致。
锁行为时序对比
sequenceDiagram
participant T1 as 线程1
participant T2 as 线程2
participant SyncObj as SynchronizedList(mutex=this)
participant Vec as Vector
Note over T1,SyncObj: SynchronizedList 场景
T1->>SyncObj: get(0) 尝试获得this锁
T1-->>T2: T1 持有 this 锁
T2->>SyncObj: add(e) 尝试获得this锁
T2-->>T2: 阻塞,直到T1释放
T1-->>SyncObj: 释放锁
T2-->>T2: 获得锁并add
Note over T1,Vec: Vector 场景
T1->>Vec: get(0) 尝试获得Vector实例锁
T1-->>T2: T1 持有锁
T2->>Vec: add(e) 阻塞
说明
- 两种情况下,读操作和写操作均互斥,并发度相同,均为串行化访问。
SynchronizedList因mutex可自定义,可以通过让多个集合共享一个mutex来实现复杂原子操作,这在Vector中做不到。- 但
Vector作为 JDK 1.0 遗留,不建议新代码使用。用Collections.synchronizedList更符合现代 Java 的“组合/接口”风格。
模块9:SynchronizedList vs CopyOnWriteArrayList——悲观锁 vs 读写分离
这两种线程安全的 List 实现哲学完全不同。
实现原理
SynchronizedList:基于锁的互斥,读写均需独占锁,保证强一致性。CopyOnWriteArrayList:读操作完全无锁,直接读取数组引用;写操作(add,set,remove)通过加ReentrantLock创建底层数组的新副本,修改后替换引用。这是典型的读写分离和写时复制。
性能场景对比
- 读多写极少:
CopyOnWriteArrayList读无锁且无阻塞,性能碾压SynchronizedList。写时需复制全数组,代价高,但写频率低时可接受。 - 写多或中等并发:
SynchronizedList可能更优,因为写时复制产生大量内存分配与复制,且写锁竞争时 CPU 和内存压力更大。此时用户量写操作下,SynchronizedList的锁虽阻塞但避免了巨量数组拷贝。
一致性模型
SynchronizedList:强一致性。每一次数据更新立即可见于后续的读操作(通过锁的内存语义)。CopyOnWriteArrayList:弱一致性。迭代器在创建时就获得一个数组快照,后续的写入操作在另一个新数组上进行,迭代器全然不知,不会抛出ConcurrentModificationException,但可能读到陈旧数据。
内存开销
SynchronizedList:无额外内存开销,仅持有原集合引用。CopyOnWriteArrayList:每次修改都需要复制整个内部数组,若集合很大(例如10万元素)且写操作频繁,会带来严重的内存和 GC 压力。
选择决策流程图
flowchart TD
A["需要线程安全的List"] --> B{"主要操作是读还是写?"}
B -->|"几乎全是读"| C["使用 CopyOnWriteArrayList"]
B -->|"读写频繁/写较多"| D{"数据量大小?"}
D -->|"小到中等"| E["可考虑 SynchronizedList"]
D -->|"极大(>10万)且写频繁"| F["避免 CopyOnWriteArrayList 考虑 SynchronizedList 或重新设计"]
B -->|"需要强一致性"| G["使用 SynchronizedList"]
C --> H["接受可能读到旧数据"]
G --> I["接受读操作阻塞"]
说明
- 当业务能容忍迭代器读到稍旧数据(例如事件监听器列表、配置列表)时,
CopyOnWriteArrayList完美无锁读几乎可无限伸缩。 - 若业务要求严格数据最新(如金融交易流水),必须用
SynchronizedList或其它强一致并发容器。 - 切忌在写频率高的场景下使用
CopyOnWriteArrayList,否则会因数组复制耗尽 CPU 和内存导致性能骤降。
Part 5:实战陷阱篇
模块10:常见陷阱与最佳实践
陷阱1:遍历时未手动同步
错误示例:
List<String> list = Collections.synchronizedList(new ArrayList<>());
list.add("a"); list.add("b");
for (String s : list) { // 可能抛出 ConcurrentModificationException
System.out.println(s);
}
正确示例:
synchronized (list) {
for (String s : list) {
System.out.println(s);
}
}
原因:增强 for 循环实际使用迭代器,而迭代器未同步。
陷阱2:误以为复合操作是原子性的
错误示例:
if (list.size() > 0) {
list.remove(list.size() - 1); // 线程不安全序列
}
正确示例:
synchronized (list) {
if (list.size() > 0) {
list.remove(list.size() - 1);
}
}
size() 和 remove() 两者之间可能插入其它线程修改,破坏原子性。
陷阱3:将包装后的 List 传给方法导致底层原 List 逃逸
错误示例:
List<String> original = new ArrayList<>();
List<String> syncList = Collections.synchronizedList(original);
someMethod(original); // 原始 list 仍然可被修改,同步包装失效
正确做法:要么彻底隐藏原始引用,要么将包装器传给其它线程。切勿在用包装器保护集合的同时还暴露原引用。
陷阱4:高并发读多场景的性能瓶颈
示例:一个需要频繁遍历的配置列表,使用 SynchronizedList,所有读取操作排队,CPU 利用率低。
解决方案:替换为 CopyOnWriteArrayList,读取无锁立即返回,大幅提高吞吐。
模块11:注意事项与性能总结
- 时间复杂度:单个操作的时间复杂度与底层 List 相同(如
ArrayList.getO(1)),加上锁竞争导致的等待。当线程数小于CPU核心数时,竞争不激烈,影响很小;激烈竞争时,锁将导致大量线程上下文切换。 - 推荐使用场景:兼容现有
List实现;读写数目相当且写不在极端高频;需要强一致性;需自定义锁进行多集合协调。 - 回避场景:极高并发度写;读占绝对主导且接受弱一致性;集合规模巨大且写频繁。
- 最佳实践:尽量缩小同步块的范围,仅在必须原子操作的复合语句外加锁;迭代时务必使用
synchronized块;考虑并发集合类(如ConcurrentHashMap的KeySet视图是 List? 无,但可选择并发队列框架)作为更上层的方案。
Part 6:面试高频专题
模块12:面试高频专题
以下为 Collections.synchronizedList 常见面试考点,独立成章,每题包含标准回答、追问模拟、加分回答。
1. Collections.synchronizedList 的实现原理是什么?
标准回答:基于装饰器模式,返回一个包装了原 List 的 SynchronizedList 实例。所有操作方法内部使用 synchronized (mutex) 同步块将操作委托给原集合。mutex 默认为包装器自身,也可在构造时指定。
追问:mutex 为什么能用自定义对象?好处是什么?
回答:这样可以多个同步包装器共享一把锁,实现跨集合的原子操作,增强灵活性。例如同时对两个列表操作时用同一把锁就能保证整体原子性。
加分回答:这种设计也避免了继承 Vector 带来的锁对象硬编码为 this 的窘境,遵循“组合优于继承”原则。另外,在 Java 并发编程中,显式锁对象也是推荐的封装方式,可防止客户端错误地对包装器本身加锁导致的不可预期行为。
2. 为什么有 Vector 还需要 SynchronizedList?
标准回答:Vector 是 JDK1.0 的遗留类,其所有方法用 synchronized 修饰,锁固定为 this,无法自定义;且它同时也是一个动态数组,捆绑了线程安全与具体数据结构。SynchronizedList 通过装饰器模式为任何 List 实现提供线程安全,更灵活、解耦。
追问:SynchronizedList 性能优于 Vector 吗?
回答:基本持平,可能因额外委托略慢。但灵活性更佳。新代码应使用 Collections.synchronizedList 或并发集合,避免 Vector。
加分回答:另外 Vector 还遗留了 Enumeration 等过时接口,与集合框架的设计风格不一致。使用 SynchronizedList 能让代码风格统一,符合现代 Java 实践。
3. SynchronizedList 和 CopyOnWriteArrayList 的区别?各适用什么场景?
标准回答:SynchronizedList 基于悲观锁,读写互斥,强一致性;CopyOnWriteArrayList 读写分离,读无锁,写加锁复制全数组,弱一致。前者适合读写均衡或写多场景,后者适合读多写极少且可容忍读旧数据。
追问:CopyOnWriteArrayList 为什么用 ReentrantLock 与写时复制,而不用 synchronized 同步块?
回答:因为 CopyOnWriteArrayList 需要更灵活的锁控制(如支持多写者在创建副本时的竞争),并且利用锁保护数组替换操作即可,不需要保护读操作。此外 ReentrantLock 提供比内置锁更丰富的特性(如 tryLock)。
加分回答:还可从内存角度阐述:大规模数据频繁写时,写时复制巨大的内存复制会造成 GC 压力,甚至 OOM。此时 SynchronizedList 虽然阻塞但安全。
4. SynchronizedList 的迭代器为什么不是线程安全的?遍历时如何做?
标准回答:iterator() 方法直接返回底层集合的迭代器,未加同步,因为迭代器内部同步无法保证整个遍历期间的原子性,且可能引发死锁。正确做法是客户端加锁:synchronized(list) { Iterator i = list.iterator(); while(i.hasNext()) ... }。
追问:如果使用 forEach 方法呢?
回答:SynchronizedList 的 forEach 方法是在 synchronized(mutex) 内执行的,因此安全。
加分回答:重点指出,迭代器设计的取舍体现了并发容器的普遍难题——复合操作的原子性由用户控制。这也是为何并发包提供 ConcurrentHashMap 等弱一致性迭代器的原因。
5. SynchronizedList 和 Vector 的锁有什么区别?能否自定义锁对象?
标准回答:Vector 的锁固定为 Vector 实例本身(this),所有同步方法使用同一个内置锁,无法更改。SynchronizedList 支持通过构造器传入自定义 mutex 对象,默认 mutex 为包装器自身。自定义锁可实现多集合共享锁。
追问:如果想让多个 SynchronizedList 共享锁,该如何实现?
回答:创建一个任意 Object 作为锁,然后通过 Collections.synchronizedList(list, lock) 传入。所有使用该锁的集合的同步操作都会在此锁上排队。
加分回答:这实际上是装饰器模式与锁策略分离的体现,体现了开放封闭原则和同步策略的外部化。
6. 为什么 ConcurrentHashMap 不用这种包装方式来实现线程安全?
标准回答:包装方式通过全表互斥锁实现,并发度极低,无法发挥多核优势。ConcurrentHashMap 使用锁分段(Java7)或 CAS + synchronized 细粒度锁(Java8)实现高并发。包装方式完全背离了并发容器的设计目标。
追问:能简单描述 ConcurrentHashMap 为何比 SynchronizedMap 性能好吗?
回答:ConcurrentHashMap 允许多个线程同时操作不同桶,大大降低锁竞争。而包装 Map 只有一个全局锁。
加分回答:并发容器不仅仅依靠锁,还大量使用 volatile、CAS 无锁技术来减少上下文切换,包装模型无法做到。
7. 使用 SynchronizedList 时,哪些操作仍然是线程不安全的?
标准回答:任何由多个方法调用组合的操作,如 if(!list.contains(x)) list.add(x), 迭代遍历等。单一方法如 add, get 是原子的,但缺乏更大范围的原子性。
追问:如何在 SynchronizedList 上实现 put-if-absent 原子操作?
回答:必须使用客户端加锁 synchronized(list) { if(!list.contains(x)) list.add(x); }。
加分回答:这也解释了并发集合为何要提供 putIfAbsent 等原子复合方法,正是为了消除这类客户端加锁。
8. 如何将 SynchronizedList 转换为 CopyOnWriteArrayList?是否合理?
标准回答:new CopyOnWriteArrayList<>(syncList),通过同步块安全地初始化。是否合理取决于后续使用模式。如果从此转为读多写少,合理;否则无意义。
追问:转换过程中需要注意什么?
回答:需要在 synchronized(syncList) 块中创建快照,以保证数据一致性。
加分回答:可分享一次性构建 CopyOnWriteArrayList 的最佳实践:如果数据不再变化,复制后即可无锁读取,非常适合缓存场景。
9. 多个线程同时操作同一个 SynchronizedList,吞吐量瓶颈在哪里?
标准回答:瓶颈在于 mutex 锁的争用。所有操作都串行化,随着线程数增加,线程上下文切换和锁争夺导致性能下降。热点方法如频繁的 add/get 会导致高锁竞争。
追问:如何优化?
回答:考虑使用 ConcurrentLinkedQueue 或分段加锁的自定义结构,或者用 ReadWriteLock 包裹 List 实现读写分离(但要注意 List 的写入可能结构性修改,需要谨慎)。
加分回答:从系统层面可以衡量使用多列表哈希分片(类似 ConcurrentHashMap 的分段思想),每个线程操作不同的列表段,减少竞争。
10. Java 8 以后还有必要使用 SynchronizedList 吗?
标准回答:有必要,当需要低成本地将原有 List 实现转为线程安全时,它仍是最简洁的方案。尤其是需要和遗留代码集成的场景。对于全新开发,读写均衡且数据量不大的情况也可接受。但在高并发新系统应优先考虑 java.util.concurrent 包下的专用容器。
追问:有没有可能在 Lambda 中使用 SynchronizedList 时产生死锁?
回答:如果 Lambda 在同步块内执行并且尝试获取另一把锁可能发生死锁,这属于通用并发风险,不是 SynchronizedList 独有。一般建议流水线操作在锁外完成。
加分回答:Java 8 并发增强(如 ConcurrentHashMap 的 forEach、reduce 等并行操作)通常更高效,因此在性能敏感场景倾向于用并发容器 + 流式处理。但 SynchronizedList 的简单性与确定性使其在小规模工具代码中仍有价值。