Java 并发集合实战:CopyOnWriteArrayList 与 synchronizedList 全面对比

244 阅读14分钟

在 Java 多线程开发中,你是否遇到过这样的情况:一个普通 ArrayList 被多个线程同时访问,突然系统抛出了 ConcurrentModificationException,或者出现了数据不一致问题(如脏读、丢失更新)。为了解决这些并发安全问题,Java 提供了多种线程安全的 List 实现。其中,CopyOnWriteArrayList 和 Collections.synchronizedList 是两种最常用的方案。它们虽然都能保证线程安全,但内部机制和性能表现却截然不同,选择不当可能导致系统性能急剧下降。本文将深入分析这两种实现的本质区别,帮你在实际项目中做出正确选择。

基本概念

在深入比较之前,先来了解这两种集合的基本特点。

CopyOnWriteArrayList

CopyOnWriteArrayList 是 JUC(java.util.concurrent)包中的类,它通过"写时复制"技术实现线程安全:

  • 读操作:完全不加锁,直接读取当前数组引用
  • 写操作:创建底层数组的完整副本,在副本上修改,然后原子性地替换原数组引用

Collections.synchronizedList

这是 Collections 工具类提供的静态方法,它将普通 List 包装成线程安全的版本:

  • 所有操作:使用同一个锁对象(mutex)同步,保证线程安全,但导致读写互斥

内部实现机制

CopyOnWriteArrayList 的实现原理

CopyOnWriteArrayList 内部持有一个 volatile 数组引用,保证数组引用的可见性:

/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;

/**
 * 获取数组引用 - 读线程直接访问,无需加锁
 * volatile保证了happens-before关系,读线程总能看到最新数组
 */
final Object[] getArray() {
    return array;
}

/**
 * 设置数组引用 - 原子性替换数组
 */
final void setArray(Object[] a) {
    array = a;
}

关键方法实现:

// 读操作示例(不加锁)
public E get(int index) {
    // 获取当前数组的快照,直接访问
    Object[] elements = getArray();
    return (E) elements[index];
}

// 写操作示例(加锁并复制)
public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    // 写操作需要获取锁
    lock.lock();
    try {
        // 1. 获取当前数组
        Object[] elements = getArray();
        int len = elements.length;
        // 2. 创建新数组(完整复制,O(n)操作)
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        // 3. 修改新数组
        newElements[len] = e;
        // 4. 原子性替换数组引用
        setArray(newElements);
        return true;
    } finally {
        // 确保锁释放
        lock.unlock();
    }
}

// 修改元素示例(同样需要复制整个数组)
public E set(int index, E element) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        E oldValue = (E) elements[index];

        if (oldValue != element) {
            // 即使只修改一个元素,也需要复制整个数组
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len);
            newElements[index] = element;
            setArray(newElements);
        }
        return oldValue;
    } finally {
        lock.unlock();
    }
}

Collections.synchronizedList 的实现原理

Collections.synchronizedList 返回一个 SynchronizedList 实例,它是 Collections 的一个内部类,继承自 SynchronizedCollection:

// SynchronizedCollection持有锁对象
static class SynchronizedCollection<E> implements Collection<E> {
    final Collection<E> c;  // 被包装的集合
    final Object mutex;     // 同步锁对象(默认是this)

    SynchronizedCollection(Collection<E> c) {
        this.c = Objects.requireNonNull(c);
        mutex = this;  // 默认使用this作为锁
    }

    SynchronizedCollection(Collection<E> c, Object mutex) {
        this.c = Objects.requireNonNull(c);
        this.mutex = Objects.requireNonNull(mutex);  // 也可使用外部提供的锁
    }

    // 所有方法都使用同一个mutex锁同步
}

// SynchronizedList实现
static class SynchronizedList<E> extends SynchronizedCollection<E>
    implements List<E> {
    private final List<E> list;

    SynchronizedList(List<E> list) {
        super(list);  // 调用父类构造器,设置mutex
        this.list = list;
    }

    // 所有方法都使用来自父类的mutex锁
    public E get(int index) {
        synchronized (mutex) {  // 读操作也需要获取锁
            return list.get(index);
        }
    }

    public boolean add(E e) {
        synchronized (mutex) {  // 写操作需要获取锁
            return list.add(e);
        }
    }

    // 修改元素 - 仅需加锁,无需复制
    public E set(int index, E element) {
        synchronized (mutex) {
            return list.set(index, element);  // O(1)操作
        }
    }
}

关键特性对比

1. 线程安全机制与并发模型

CopyOnWriteArrayList:

  • 读操作完全不加锁 - 直接读取 volatile 数组引用
  • 写操作使用 ReentrantLock 加锁,支持可中断等待和超时设置
  • 读写可并发进行(读线程不会被写线程阻塞)
  • 多个写操作互斥(通过 ReentrantLock 保证)

并发性能表现

  • 在 100 线程并发读取时,几乎无性能损失,吞吐量接近单线程
  • ReentrantLock 默认使用非公平策略,写线程获取锁的顺序不保证,可能导致部分线程等待时间过长

Collections.synchronizedList:

  • 所有操作(包括读和写)都使用同一个 mutex 锁对象同步
  • 读写互斥(读线程会被写线程阻塞,反之亦然)
  • 任何时候只有一个线程能访问 List,形成串行访问点

并发性能表现

  • 在 100 线程并发读取时,由于锁竞争,吞吐量可能下降 80%以上
  • synchronized 内置锁在 JDK 1.6 后引入锁升级机制,低竞争时性能接近无锁

2. 迭代器行为与异常处理

这是两者最显著的区别之一:

CopyOnWriteArrayList:

  • 迭代器保持创建时的数组快照,永远不会抛出 ConcurrentModificationException
  • 迭代过程中对列表的修改不会反映到当前迭代器中
  • 迭代器不支持修改操作(remove、set、add 都会抛出 UnsupportedOperationException)
  • 提供弱一致性(最终一致性)- 迭代器只能反映创建时的数据状态

就像拍了个照片一样,照片里的内容不会随现实变化而改变:

CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>();
cowList.add("A");
cowList.add("B");

// 获取迭代器(相当于拍照)
Iterator<String> iterator = cowList.iterator();

// 另一个线程修改列表(现实世界变化)
cowList.add("C");

// 迭代照片中的内容(不会看到"C")
while (iterator.hasNext()) {
    System.out.println(iterator.next());  // 只输出A和B
}

// 迭代器不支持修改
try {
    iterator.remove();  // 将抛出UnsupportedOperationException
} catch (UnsupportedOperationException e) {
    System.out.println("CopyOnWriteArrayList的迭代器不支持修改操作");
}

// 注意:即使列表后来被清空,迭代器仍基于快照工作
cowList.clear();
Iterator<String> oldIterator = iterator;  // 使用之前的迭代器
while (oldIterator.hasNext()) {
    System.out.println(oldIterator.next());  // 仍能输出A和B
}

Collections.synchronizedList:

  • 迭代时需要手动同步,否则可能抛出 ConcurrentModificationException
  • 迭代时看到的是当前列表的实时状态(强一致性)
  • 迭代器支持修改操作(如 remove),但必须在同步块内使用

类似于必须拿着钥匙才能安全地检查和改变一个不断变化的列表:

List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
List<String> syncList = Collections.synchronizedList(list);

// 错误方式:未同步迭代
try {
    for (String item : syncList) {
        System.out.println(item);
        // 如果此时其他线程修改了syncList
        // 会抛出ConcurrentModificationException
    }
} catch (ConcurrentModificationException e) {
    System.out.println("未同步迭代时被修改,引发并发修改异常");
}

// 正确方式:迭代时需要手动同步
// 注意:必须使用syncList作为锁对象(即mutex),而非原始list
synchronized (syncList) {  // 正确:持有锁进行安全迭代
    Iterator<String> iterator = syncList.iterator();
    while (iterator.hasNext()) {
        String value = iterator.next();
        System.out.println(value);
        // 支持修改操作,但必须在同步块内
        if (value.equals("A")) {
            iterator.remove();  // 安全的修改操作
        }
    }
}

3. 数据一致性模型与内存影响

CopyOnWriteArrayList:

  • 提供弱一致性(最终一致性)
  • 写操作完成前,读操作看到的是旧数据
  • 写操作完成后,新的读操作才能看到新数据
  • 已获取迭代器的线程永远看不到后续修改
  • 内存影响:频繁写入会产生大量待 GC 的旧数组对象,增加垃圾回收压力
  • 潜在问题:频繁大规模修改且生命周期长的列表可能引发内存问题
// 内存占用问题示例
public void memoryLeakRisk() {
    // 长期持有的大型CopyOnWriteArrayList
    CopyOnWriteArrayList<byte[]> largeList = new CopyOnWriteArrayList<>();

    // 频繁修改大列表
    for (int i = 0; i < 1000; i++) {
        // 每次添加1MB数据,产生约1GB待回收垃圾
        largeList.add(new byte[1024 * 1024]);

        // 触发频繁GC,可能导致性能抖动
        if (i % 100 == 0) {
            System.gc(); // 实际应用中不应手动调用GC
        }
    }
}

想象成一个公告板:更新时需要先准备一块新板子,写好后整块替换,观看者要么看到旧版内容,要么看到新版内容,不会看到中间状态,但旧板子会占用空间直到被回收。

Collections.synchronizedList:

  • 提供强一致性(实时一致性)
  • 写操作完成后,所有读操作立即看到新数据
  • 适合需要实时数据同步的场景(如计数器、状态跟踪)
  • 内存影响:几乎没有额外内存开销,无需创建临时对象
  • 潜在问题:如果原始 List 引用在其他地方被非同步访问,仍会有线程安全风险

4. 性能特性与规模影响

读操作性能:

  • CopyOnWriteArrayList: O(1),非常高(完全无锁)
  • synchronizedList: O(1)但需要获取锁,随并发增加性能下降(锁竞争)

写操作性能:

  • CopyOnWriteArrayList: O(n),每次写都复制整个数组,列表越大性能越差
  • synchronizedList: O(1)加锁开销,无复制成本

元素修改性能(set 操作):

  • CopyOnWriteArrayList: O(n),即使只修改一个元素也需复制整个数组
  • synchronizedList: O(1),仅需加锁,无需复制
// 元素更新性能对比
public void compareSetPerformance() {
    int size = 10000;
    List<Integer> baseList = new ArrayList<>();
    for (int i = 0; i < size; i++) {
        baseList.add(i);
    }

    List<Integer> syncList = Collections.synchronizedList(new ArrayList<>(baseList));
    CopyOnWriteArrayList<Integer> cowList = new CopyOnWriteArrayList<>(baseList);

    // 测试随机位置元素修改
    long start = System.currentTimeMillis();
    for (int i = 0; i < 1000; i++) {
        int randomIndex = new Random().nextInt(size);
        syncList.set(randomIndex, -i);  // 只需加锁,O(1)操作
    }
    long syncTime = System.currentTimeMillis() - start;

    start = System.currentTimeMillis();
    for (int i = 0; i < 1000; i++) {
        int randomIndex = new Random().nextInt(size);
        cowList.set(randomIndex, -i);  // 每次复制1万元素,O(n)操作
    }
    long cowTime = System.currentTimeMillis() - start;

    System.out.println("修改1000个元素耗时比较:");
    System.out.println("synchronizedList: " + syncTime + "ms");
    System.out.println("CopyOnWriteArrayList: " + cowTime + "ms");
    System.out.println("性能差距: " + cowTime / Math.max(1, syncTime) + "倍");
}

内存消耗:

  • CopyOnWriteArrayList: 写操作时临时需要 2 倍内存空间,在内存受限环境(如容器化部署、移动设备)可能引发问题
  • synchronizedList: 几乎无额外内存开销,只需包装原始列表对象

具体场景下的性能差异

  • 1 万元素的列表,单次写入:CopyOnWriteArrayList 可能慢 10-100 倍
  • 高并发只读(如 100 线程并发读):CopyOnWriteArrayList 可能快 5-20 倍
  • 混合读写(80%读 20%写):取决于列表大小和并发度

实际应用场景与代码示例

1. 读多写少场景(读操作>90%,列表大小<1000)

当应用需要频繁读取但很少修改列表时,CopyOnWriteArrayList 通常是理想选择:

// 缓存系统配置信息的场景
public class ConfigManager {
    // 配置很少变化,但经常被读取
    private final CopyOnWriteArrayList<ConfigItem> configs = new CopyOnWriteArrayList<>();

    public List<ConfigItem> getConfigs() {
        // 直接返回,不需要加锁,多线程并发读取性能高
        return configs;
    }

    // 偶尔更新配置(如系统启动或配置变更时)
    public void updateConfig(ConfigItem item) {
        // 由于配置项通常较少(<100)且更新不频繁,复制成本可接受
        configs.removeIf(c -> c.getName().equals(item.getName()));
        configs.add(item);
    }

    // 实际使用示例
    public ConfigItem findByName(String name) {
        // 高频并发读取
        for (ConfigItem item : configs) {
            if (item.getName().equals(name)) {
                return item;
            }
        }
        return null;
    }
}

2. 写多读少场景(写操作>50%或列表大小>10000)

当列表频繁修改或元素数量很大时,synchronizedList 通常更合适:

// 日志收集器场景
public class LogCollector {
    // 日志频繁写入,但不常读取
    private final List<LogEntry> logEntries =
            Collections.synchronizedList(new ArrayList<>(10000)); // 预分配容量

    public void addLog(LogEntry entry) {
        // 频繁调用,只有锁开销,没有复制开销
        logEntries.add(entry);
    }

    // 定期批量处理日志
    public void processBatch() {
        List<LogEntry> batchCopy;

        // 优化:同步块尽可能小,只用于安全获取数据副本
        synchronized (logEntries) {
            if (logEntries.isEmpty()) {
                return;  // 快速返回,减少锁持有时间
            }
            // 如果日志很多,复制和处理分离可减少锁持有时间
            batchCopy = new ArrayList<>(logEntries);
            logEntries.clear();
        }

        // 锁外处理数据,避免长时间持有锁
        for (LogEntry entry : batchCopy) {
            processEntry(entry);
        }
    }

    private void processEntry(LogEntry entry) {
        // 处理逻辑(如写入数据库、发送消息等)
    }
}

3. 大列表性能对比

对于元素非常多的列表,CopyOnWriteArrayList 的写操作性能会急剧下降:

public void comparePerformanceWithLargeList() {
    int size = 100000;
    List<Integer> baseList = new ArrayList<>(size);
    // 预填充基础列表
    for (int i = 0; i < size; i++) {
        baseList.add(i);
    }

    // 创建两种线程安全的列表
    List<Integer> synchronizedList = Collections.synchronizedList(new ArrayList<>(baseList));
    CopyOnWriteArrayList<Integer> copyOnWriteList = new CopyOnWriteArrayList<>(baseList);

    // 测试写性能
    long start = System.currentTimeMillis();
    for (int i = 0; i < 100; i++) {
        synchronizedList.add(size + i);
    }
    long syncTime = System.currentTimeMillis() - start;
    System.out.println("synchronizedList添加100个元素耗时: " + syncTime + "ms");

    start = System.currentTimeMillis();
    for (int i = 0; i < 100; i++) {
        copyOnWriteList.add(size + i);  // 每次都复制10万个元素
    }
    long cowTime = System.currentTimeMillis() - start;
    System.out.println("copyOnWriteList添加100个元素耗时: " + cowTime + "ms");
    System.out.println("性能比例: synchronizedList : copyOnWriteList = 1 : " +
                      (cowTime / Math.max(1, syncTime)));
}

典型测试结果可能显示 CopyOnWriteArrayList 在大列表上的写操作比 synchronizedList 慢 50-200 倍。

4. 需要实时数据一致性的场景

对于需要实时数据同步的场景,CopyOnWriteArrayList 的弱一致性会导致问题:

// 计数器场景 - 演示问题
public class Counter {
    // 错误示例:使用CopyOnWriteArrayList实现计数器
    private final CopyOnWriteArrayList<Integer> counterList = new CopyOnWriteArrayList<>(List.of(0));

    public void increment() {
        int current = counterList.get(0);
        // 问题:这里有并发问题!其他线程可能已经修改了值
        counterList.set(0, current + 1);  // 基于可能过时的读取结果
    }

    // 正确示例:使用synchronizedList确保原子性
    private final List<Integer> syncCounterList = Collections.synchronizedList(new ArrayList<>(List.of(0)));

    public void incrementCorrect() {
        synchronized (syncCounterList) {
            // 在同一个同步块中读和写,保证原子性
            int current = syncCounterList.get(0);
            syncCounterList.set(0, current + 1);
        }
    }

    // 更好的解决方案:直接使用AtomicInteger
    private final AtomicInteger atomicCounter = new AtomicInteger(0);

    public void incrementBest() {
        atomicCounter.incrementAndGet();  // 原子操作,无锁高效
    }
}

我用一个简单例子解释弱一致性和强一致性:

想象你和朋友共用一个购物清单:

  • CopyOnWriteArrayList 就像每次修改都是写在新纸上,旧的纸还在其他人手里。他们看到的是你修改前的版本。
  • synchronizedList 就像一个挂在墙上的单一清单,但每次只有一个人能看或改,保证所有人看到的都是最新版本,但需要排队等待。

如何选择正确的实现

flowchart TD
    A[需要选择线程安全List] --> B{数据一致性要求?}
    B -->|需要实时一致性| C[Collections.synchronizedList]
    B -->|允许最终一致性| D{列表大小?}

    D -->|小于1000元素| E{读写比例?}
    D -->|大于1000元素| F{读写比例?}

    E -->|读>90%| G[CopyOnWriteArrayList]
    E -->|读<90%| C

    F -->|读>95%| G
    F -->|读<95%| C

    G --> H{内存敏感?}
    H -->|是| I[监控列表大小或限制写频率]
    H -->|否| J[最终选择]

    C --> J
    I --> J

    linkStyle 5 stroke:#2962FF,fill:none
    linkStyle 6 stroke:#FF6D00,fill:none
    linkStyle 7 stroke:#00C853,fill:none

选择正确的实现需要考虑以下关键因素:

  1. 数据一致性要求(最重要):
  • 需要实时一致性:必须使用 Collections.synchronizedList
  • 允许最终一致性:可以考虑 CopyOnWriteArrayList
  1. 列表大小
  • 小列表(<1000 个元素):两者都可接受,主要看读写比例
  • 大列表(>1000 个元素):慎用 CopyOnWriteArrayList,特别是写入频繁时
  1. 读写比例
  • 读操作超过 90%且小列表:优先考虑 CopyOnWriteArrayList
  • 读操作超过 95%且大列表:可考虑 CopyOnWriteArrayList
  • 其他情况:通常选择 Collections.synchronizedList 更安全
  1. 修改操作类型
  • 频繁修改已有元素(set 操作):几乎必选 Collections.synchronizedList
  • 主要是添加新元素:根据读写比例和列表大小选择
  1. 内存限制
  • 内存敏感环境:谨慎使用 CopyOnWriteArrayList,或监控列表大小

实用优化技巧

  1. 对于配置信息等典型读多写少场景
// 小型配置列表,读多写极少
List<ConfigItem> configList = new CopyOnWriteArrayList<>();
  1. 对于大型日志或数据处理场景
// 预分配空间减少扩容开销
List<LogEntry> logList = Collections.synchronizedList(new ArrayList<>(10000));
  1. 优化 synchronizedList 的同步范围
List<Task> taskList = Collections.synchronizedList(new ArrayList<>());

// 错误方式:同步代码块过大
synchronized (taskList) {
    for (Task task : taskList) {
        processTask(task);  // 可能是耗时操作
    }
}

// 正确方式:最小化锁持有时间
List<Task> copyForProcess;
synchronized (taskList) {
    copyForProcess = new ArrayList<>(taskList);
}
// 锁外处理,不阻塞其他线程
for (Task task : copyForProcess) {
    processTask(task);
}
  1. 处理 CopyOnWriteArrayList 中的批量修改
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>(Arrays.asList("A", "B", "C"));

// 错误方式:每次remove都复制整个数组
for (String item : new ArrayList<>(list)) {
    if (item.startsWith("A")) {
        list.remove(item);  // 每次调用都复制数组!
    }
}

// 正确方式:一次性批量操作
list.removeIf(item -> item.startsWith("A"));  // 仅复制一次数组
  1. 生命周期阶段混合使用
// 初始加载阶段(写多读少)用synchronizedList
List<Product> tempList = Collections.synchronizedList(new ArrayList<>());

// 多线程并行加载数据
CountDownLatch loadLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
    final int batchId = i;
    executor.submit(() -> {
        try {
            List<Product> batchData = loadProductBatch(batchId);
            synchronized (tempList) {
                tempList.addAll(batchData);
            }
        } finally {
            loadLatch.countDown();
        }
    });
}
loadLatch.await();  // 等待全部加载完成

// 加载完毕后转为读多写少场景,切换实现(如果列表不太大)
List<Product> productCatalog = new CopyOnWriteArrayList<>(tempList);
// 现在可以高效地多线程读取商品目录

总结

特性CopyOnWriteArrayListCollections.synchronizedList
实现原理写时复制(数组不可变)同步包装
线程安全机制读不加锁,写时复制并加锁所有操作都加锁
锁类型ReentrantLock(显式锁)synchronized(内置锁)
读操作性能高(O(1)无锁)低(需加锁,有竞争)
写操作性能低(O(n)复制整个数组)中(O(1)加锁,无复制)
元素修改性能低(O(n)全数组复制)高(O(1)仅加锁)
内存消耗高(写操作时约 2 倍内存)低(几乎无额外开销)
GC 压力高(产生大量待回收对象)低(无额外对象创建)
迭代特性快照迭代,永不抛出异常
不支持修改操作
需手动同步,否则抛异常
支持修改操作(需同步)
数据一致性弱一致性(最终一致性)强一致性(实时一致性)
适用列表大小推荐<1000 元素推荐 ≥1000 元素或频繁写
适用场景读多(>90%)写少,列表较小
允许数据短暂不一致
写频繁,大数据量
需要实时一致性
并发度影响读线程越多优势越明显
写性能不受并发影响
并发增加导致锁竞争加剧
性能随并发度下降
弱点大列表写性能极差
迭代器功能受限
占用更多内存
增加 GC 压力
读性能较差
高并发下锁竞争严重
迭代需手动同步

在 Java 并发编程中,没有完美的容器实现适合所有场景。选择 CopyOnWriteArrayList 还是 Collections.synchronizedList,关键在于理解你的应用需求:列表大小、读写比例、数据一致性要求和内存限制。通过正确选择和优化使用方式,能显著提升应用性能和稳定性。