在Java中,ArrayList
、LinkedList
等常见List
实现类不是线程安全的(非同步)。当多个线程同时对其进行修改(如add
、remove
)或读写操作时,可能会导致数据不一致、ConcurrentModificationException
(并发修改异常)等问题。
要在多线程环境下安全地修改List
,需通过线程安全的容器或同步机制保证操作的原子性和可见性。以下是常用解决方案及实现方式:
一、使用线程安全的List
实现类
Java提供了几种线程安全的List
实现,可直接替换非线程安全的List
,无需手动处理同步。
1. Vector
(古老实现,不推荐)
Vector
是Java早期的线程安全List
实现,其所有方法都被synchronized
修饰(同步方法),保证线程安全。
缺点:同步粒度太粗(整个方法加锁),多线程并发效率低,且功能上被更优的方案替代,不推荐在新代码中使用。
// Vector是线程安全的,但性能较差
List<String> vector = new Vector<>();
// 多线程可安全调用add/remove等方法
vector.add("A");
vector.remove(0);
2. Collections.synchronizedList()
(包装同步,推荐基础场景)
Collections
工具类的synchronizedList()
方法可将任意非线程安全的List
包装为线程安全的List
。其原理是对所有方法添加同步锁(使用synchronized
块),保证同一时刻只有一个线程能操作List
。
使用方式:
// 1. 创建非线程安全的List(如ArrayList)
List<String> unsafeList = new ArrayList<>();
// 2. 包装为线程安全的List
List<String> safeList = Collections.synchronizedList(unsafeList);
// 多线程环境下可安全操作
// 线程1:添加元素
new Thread(() -> {
safeList.add("A");
}).start();
// 线程2:删除元素
new Thread(() -> {
if (!safeList.isEmpty()) {
safeList.remove(0);
}
}).start();
注意事项:
- 迭代操作需手动加锁:
synchronizedList
返回的List
在迭代时(如for-each
、iterator
)不自动同步,需手动用synchronized
块包裹,否则可能抛出ConcurrentModificationException
。// 迭代时必须手动同步(锁对象为safeList本身) synchronized (safeList) { for (String s : safeList) { System.out.println(s); } }
- 适合读写频率均衡的场景:由于所有操作都加锁,高并发下性能一般,但实现简单,适合大多数基础场景。
3. CopyOnWriteArrayList
(写时复制,推荐读多写少场景)
CopyOnWriteArrayList
是Java并发包(java.util.concurrent
)提供的线程安全List
,其核心原理是**“写时复制”**:
- 读操作:无需加锁,直接访问当前数组(性能极高)。
- 写操作(
add
、remove
等):先复制一份新的数组,在新数组上修改,然后将引用指向新数组(修改时加锁,保证原子性)。
适用场景:读操作远多于写操作(如缓存、配置列表),写操作频率低但读操作需高效。
使用方式:
import java.util.concurrent.CopyOnWriteArrayList;
// 初始化线程安全的CopyOnWriteArrayList
List<String> cowList = new CopyOnWriteArrayList<>();
// 多线程安全操作
// 线程1:添加元素(写操作,会复制数组)
new Thread(() -> {
cowList.add("A");
}).start();
// 线程2:读取元素(读操作,无锁,直接访问)
new Thread(() -> {
for (String s : cowList) {
System.out.println(s);
}
}).start();
优点:
- 读操作无锁,并发性能极佳(适合读多写少)。
- 迭代时不会抛出
ConcurrentModificationException
(迭代的是旧数组的快照)。
缺点:
- 写操作成本高(复制数组,内存占用翻倍)。
- 数据实时性差(读操作可能访问的是旧数组,修改后的数据需等新数组替换后才能被读取)。
二、手动同步(锁机制)
如果需要更灵活地控制同步粒度(如仅对关键修改操作加锁),可使用synchronized
关键字或Lock
接口手动实现同步。
1. 使用synchronized
块
通过synchronized
锁定List
对象或其他锁对象,保证同一时刻只有一个线程执行修改操作。
List<String> list = new ArrayList<>();
// 定义锁对象(也可直接用list本身作为锁)
Object lock = new Object();
// 线程1:添加元素
new Thread(() -> {
synchronized (lock) { // 加锁
list.add("A");
}
}).start();
// 线程2:删除元素
new Thread(() -> {
synchronized (lock) { // 加锁
if (!list.isEmpty()) {
list.remove(0);
}
}
}).start();
2. 使用ReentrantLock
(可重入锁)
java.util.concurrent.locks.ReentrantLock
提供比synchronized
更灵活的锁控制(如超时锁、公平锁),适合复杂场景。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
List<String> list = new ArrayList<>();
// 创建锁对象(可指定为公平锁,按请求顺序获取锁)
Lock lock = new ReentrantLock(true);
// 线程1:添加元素
new Thread(() -> {
lock.lock(); // 加锁
try {
list.add("A");
} finally {
lock.unlock(); // 必须在finally中释放锁,避免死锁
}
}).start();
// 线程2:删除元素
new Thread(() -> {
lock.lock(); // 加锁
try {
if (!list.isEmpty()) {
list.remove(0);
}
} finally {
lock.unlock();
}
}).start();
三、注意事项
复合操作的原子性:
即使使用线程安全的List
,复合操作(如“先判断再修改”)仍需额外同步。例如:// 错误示例:contains和add是两个独立操作,可能被其他线程打断 if (!safeList.contains("A")) { safeList.add("A"); // 可能重复添加 } // 正确:用同步块保证复合操作原子性 synchronized (safeList) { if (!safeList.contains("A")) { safeList.add("A"); } }
迭代器的线程安全:
synchronizedList
的迭代器需手动同步(见上文)。CopyOnWriteArrayList
的迭代器是“快照迭代器”,不支持remove
、add
等修改操作(会抛UnsupportedOperationException
),只能遍历。
性能权衡:
- 读多写少:优先
CopyOnWriteArrayList
(读无锁)。 - 读写均衡或写操作频繁:优先
Collections.synchronizedList()
或手动锁(避免CopyOnWriteArrayList
的复制开销)。 - 避免使用
Vector
(性能差,已过时)。
- 读多写少:优先
总结
多线程安全修改List
的核心是保证操作的原子性和可见性,常用方案对比:
方案 | 原理 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
Vector |
同步方法 | 简单直接 | 性能差,同步粒度粗 | 兼容旧代码(不推荐新用) |
synchronizedList |
同步块包装 | 适配所有List ,实现简单 |
所有操作加锁,并发性能一般 | 读写均衡的基础场景 |
CopyOnWriteArrayList |
写时复制 | 读操作无锁,性能极佳 | 写操作成本高,数据实时性差 | 读多写少(如缓存、配置) |
手动锁(synchronized /Lock ) |
自定义同步粒度 | 灵活控制锁范围 | 需手动处理锁释放,易出错 | 复杂场景(如复合操作) |
根据实际业务的读写频率和复杂度选择合适方案即可。