集合-List-SynchronizedList (从装饰器模式到锁机制的全景剖析)

6 阅读25分钟

概述

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 和批量操作的同步行为;并发对比篇用 VectorCopyOnWriteArrayList 做横向比较;实战陷阱篇给出错误示例与最佳实践;最终所有面试考点凝练在独立的高频面试模块中,便于读者直接检索。


Part 1:基础认知篇

模块1:定义、核心特性与适用场景

定义
Collections.synchronizedListjava.util.Collections 提供的一个静态工厂方法,它接收一个普通的 List 对象,返回一个线程安全的 List 包装器。这个包装器在内部将所有对原 List 的访问委托给原对象,并在每一个方法调用前后加上 synchronized 同步块,从而实现线程安全。这种实现方式是装饰器模式的教科书级范例——它不改变原有集合的数据结构,而是动态地为它叠加同步能力。

核心特性

  1. 装饰器模式实现:返回的 SynchronizedList 实现 List 接口,内部持有一个被包装的 List 实例 c。所有操作委托给 c,不侵入原集合类的结构。
  2. 基于 synchronized 同步块:每个关键方法都使用 synchronized(mutex) 格式的同步块,而不是同步方法。这样做允许更灵活的锁策略,并且可以避免无意间将内部锁暴露给外部。
  3. 默认互斥锁 mutex:如果调用 Collections.synchronizedList(list) 而不指定锁,mutex 会被默认设置为 this(即包装器对象自身)。也可以使用 Collections.synchronizedList(list, mutex) 指定自定义锁对象,便于多个包装集合共享一把锁以实现一致性。
  4. 迭代器自身不同步iterator()listIterator() 返回的迭代器未进行同步。设计者认为在迭代器内部加锁可能产生死锁,且无法保证在遍历期间数据不发生并发修改。因此必须由使用者在代码块外部手动加锁synchronized(list){})。
  5. 视图模式:包装器本身只是一个视图,底层的原 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:判断是否有遗留代码必须使用 ArrayListLinkedList 等具体实现。如果是,直接包装;因为不能替换为其它并发集合类,装饰器模式保留了原集合类型。
  • 步骤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)

类图解析

  • SynchronizedCollectionCollections 的私有静态内部类,实现了 Collection 接口,是所有同步包装器的根基。它持有 final Collection<E> cfinal Object mutex
  • SynchronizedList 继承 SynchronizedCollection 并实现 List,增加针对 List 接口的方法(add(int index, E)getsetremove(int)listIterator 等)。它内部保留了对 list 的引用,实际上 list 就是传入的 c
  • SynchronizedRandomAccessList 进一步继承 SynchronizedList 并实现 RandomAccess,用于在包装 ArrayList 等支持随机访问的集合时,保持 RandomAccess 标志,以便上层算法选择高效遍历策略。
  • 装饰器模式体现为:SynchronizedList 既是一个 List,又包裹着一个真实 List,在不改变原结构的基础上增加同步行为。

Part 2:源码实现与锁机制篇

模块3:SynchronizedList 的内部结构与构造方法(源码剖析)

我们基于 OpenJDK 8 的源码详细解析。Collections 类内部的同步包装器分成两个关键内部类:SynchronizedCollectionSynchronizedList

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) 是委托给原始 ArrayListLinkedList 的方法。同步块保证了原子性:在 add 执行期间,其它试图获取 mutex 锁的线程(如 getremove 等)将被阻塞。这消除了数据竞争,使线程安全得以保证。

为什么使用 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["返回结果给调用者"]

流程图深度说明

  1. 锁竞争入口:每个线程在进入 addsynchronized (mutex) 块前,必须成功获得 mutex 监视器锁。若锁已被占用,线程状态变为 BLOCKED,等待锁释放。
  2. 原子边界:从成功获取锁到退出 synchronized 块,其间所有操作(包括底层数组的修改)构成一个原子单元。对于 ArrayList.add 可能涉及的工作(检查容量、复制数组等),全部在锁保护之下。
  3. 重入性:Java 内置锁是可重入的。如果同一线程在已经持有 mutex 的情况下再次调用 SynchronizedList 的其他同步方法(如复合操作中 list.add 后又调用 list.get),不会发生死锁,因为 synchronized 允许该线程重入。
  4. 锁释放时机:无论 list.add 正常返回还是抛出异常(例如 OutOfMemoryError 或索引越界),synchronized 块结束都会释放锁,保证锁不会被永久占有。
  5. 数据一致性:在锁保护下,add 之前的状态是稳定的,add 之后的更新对所有后续获取锁的线程立即可见(因为锁的释放会执行 store 屏障,将线程本地内存刷新到主存)。这满足 Java 内存模型的 happens-before 规则。
  6. 与 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

时序说明

  • 步骤1T1 调用 iterator()。该方法没有同步,直接返回底层 ArrayList 迭代器。此时线程 T1 不持有任何锁。
  • 步骤2:几乎同时,T2 调用 add,获取 mutex 锁成功,执行结构修改(例如 ArrayListmodCount 增加)。
  • 步骤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 引用,因此原 ListsubList 的同步操作会竞争同一把锁,不会出现原列表增加元素而子列表视图不一致的情况。
  • 但要注意 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 锁固定为 thisSynchronizedList 锁可以是任意对象(默认 this)。这赋予了后者跨集合共享锁的能力。
  • 方法粒度Vector 每个公有方法都 synchronized,而 SynchronizedList 可以选择性地同步(虽然目前也同步了所有方法)。
  • 性能差异:两者在同一基准下性能接近,SynchronizedList 由于多一层委托调用,微基准测试中可能略慢,但在实际应用中差异可忽略。
  • 迭代器Vector 的迭代器同样未能解决并发修改,遍历时仍需客户端加锁;但 Vector 有一个古老的 Enumeration 可用,性质类似。
  • 额外能力Vector 提供了 capacityelementAt 等老旧方法,且扩展策略可定制;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) 阻塞

说明

  • 两种情况下,读操作和写操作均互斥,并发度相同,均为串行化访问。
  • SynchronizedListmutex 可自定义,可以通过让多个集合共享一个 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.get O(1)),加上锁竞争导致的等待。当线程数小于CPU核心数时,竞争不激烈,影响很小;激烈竞争时,锁将导致大量线程上下文切换。
  • 推荐使用场景:兼容现有 List 实现;读写数目相当且写不在极端高频;需要强一致性;需自定义锁进行多集合协调。
  • 回避场景:极高并发度写;读占绝对主导且接受弱一致性;集合规模巨大且写频繁。
  • 最佳实践:尽量缩小同步块的范围,仅在必须原子操作的复合语句外加锁;迭代时务必使用 synchronized 块;考虑并发集合类(如 ConcurrentHashMapKeySet 视图是 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 并发增强(如 ConcurrentHashMapforEachreduce 等并行操作)通常更高效,因此在性能敏感场景倾向于用并发容器 + 流式处理。但 SynchronizedList 的简单性与确定性使其在小规模工具代码中仍有价值。