在 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
选择正确的实现需要考虑以下关键因素:
- 数据一致性要求(最重要):
- 需要实时一致性:必须使用 Collections.synchronizedList
- 允许最终一致性:可以考虑 CopyOnWriteArrayList
- 列表大小:
- 小列表(<1000 个元素):两者都可接受,主要看读写比例
- 大列表(>1000 个元素):慎用 CopyOnWriteArrayList,特别是写入频繁时
- 读写比例:
- 读操作超过 90%且小列表:优先考虑 CopyOnWriteArrayList
- 读操作超过 95%且大列表:可考虑 CopyOnWriteArrayList
- 其他情况:通常选择 Collections.synchronizedList 更安全
- 修改操作类型:
- 频繁修改已有元素(set 操作):几乎必选 Collections.synchronizedList
- 主要是添加新元素:根据读写比例和列表大小选择
- 内存限制:
- 内存敏感环境:谨慎使用 CopyOnWriteArrayList,或监控列表大小
实用优化技巧
- 对于配置信息等典型读多写少场景:
// 小型配置列表,读多写极少
List<ConfigItem> configList = new CopyOnWriteArrayList<>();
- 对于大型日志或数据处理场景:
// 预分配空间减少扩容开销
List<LogEntry> logList = Collections.synchronizedList(new ArrayList<>(10000));
- 优化 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);
}
- 处理 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")); // 仅复制一次数组
- 生命周期阶段混合使用:
// 初始加载阶段(写多读少)用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);
// 现在可以高效地多线程读取商品目录
总结
| 特性 | CopyOnWriteArrayList | Collections.synchronizedList |
|---|---|---|
| 实现原理 | 写时复制(数组不可变) | 同步包装 |
| 线程安全机制 | 读不加锁,写时复制并加锁 | 所有操作都加锁 |
| 锁类型 | ReentrantLock(显式锁) | synchronized(内置锁) |
| 读操作性能 | 高(O(1)无锁) | 低(需加锁,有竞争) |
| 写操作性能 | 低(O(n)复制整个数组) | 中(O(1)加锁,无复制) |
| 元素修改性能 | 低(O(n)全数组复制) | 高(O(1)仅加锁) |
| 内存消耗 | 高(写操作时约 2 倍内存) | 低(几乎无额外开销) |
| GC 压力 | 高(产生大量待回收对象) | 低(无额外对象创建) |
| 迭代特性 | 快照迭代,永不抛出异常 不支持修改操作 | 需手动同步,否则抛异常 支持修改操作(需同步) |
| 数据一致性 | 弱一致性(最终一致性) | 强一致性(实时一致性) |
| 适用列表大小 | 推荐<1000 元素 | 推荐 ≥1000 元素或频繁写 |
| 适用场景 | 读多(>90%)写少,列表较小 允许数据短暂不一致 | 写频繁,大数据量 需要实时一致性 |
| 并发度影响 | 读线程越多优势越明显 写性能不受并发影响 | 并发增加导致锁竞争加剧 性能随并发度下降 |
| 弱点 | 大列表写性能极差 迭代器功能受限 占用更多内存 增加 GC 压力 | 读性能较差 高并发下锁竞争严重 迭代需手动同步 |
在 Java 并发编程中,没有完美的容器实现适合所有场景。选择 CopyOnWriteArrayList 还是 Collections.synchronizedList,关键在于理解你的应用需求:列表大小、读写比例、数据一致性要求和内存限制。通过正确选择和优化使用方式,能显著提升应用性能和稳定性。