一个线程遍历列表,另一个线程往里面添加元素,是使用集合时经常会遇到的情况。普通 ArrayList 需要加锁协调,JUC 中的并发容器则提供了其他处理方式。下面先看几种常用容器,再用 CopyOnWriteArrayList 做个例子。
同步包装与并发容器
Collections.synchronizedList() 给列表操作加了同步包装,但遍历时仍然需要按它的约定在返回的列表对象上加锁。多次方法调用组成的判断和修改,也要作为整体保护。
JUC 提供了面向不同场景的容器:
| 需求 | 可考虑的容器 |
|---|---|
| 按键并发访问 | ConcurrentHashMap |
| 有序的键或范围查询 | ConcurrentSkipListMap、ConcurrentSkipListSet |
| 读多写少的列表、集合 | CopyOnWriteArrayList、CopyOnWriteArraySet |
| 并发入队出队 | ConcurrentLinkedQueue、各类 BlockingQueue |
这些容器的实现方式各不相同。使用时还要区分单次操作和组合操作,例如“先判断是否存在,再添加”就涉及两次方法调用。
CopyOnWriteArrayList
写时复制的思路是:更新时生成一个新的底层数组,完成修改后替换数组引用。已经取得旧数组的读者可以继续使用旧内容。
import java.util.Arrays;
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;
public class CopyOnWriteDemo {
public static void main(String[] args) {
CopyOnWriteArrayList<String> list =
new CopyOnWriteArrayList<>(Arrays.asList("a", "b"));
Iterator<String> iterator = list.iterator();
list.add("c");
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
System.out.println(list);
}
}迭代器只输出 a、b,最后的列表是 [a, b, c]。迭代器保存的是创建时的数组快照,不会因为后来的结构修改抛出 ConcurrentModificationException,也看不到后来加入的元素。
但这是引用数组的快照,不是元素对象的深拷贝。元素本身如果可变,其他线程修改元素字段,仍需要另外考虑线程安全。这个迭代器也不支持 remove()。
写入成本
大量连续 add() 会反复复制数组。列表越大,写入越频繁,内存分配和复制成本越明显。旧迭代器还可能继续持有旧数组,使它暂时不能被回收。
因此它更适合监听器列表、变化少的配置项等读多写少的数据。CopyOnWriteArraySet 基于同类思路去重,也适合规模较小、修改较少的集合。
如果要“没有才添加”,直接使用 addIfAbsent()。先 contains() 再 add() 是两个独立操作,中间可能被另一个线程插入。
遍历并不都等于快照
ConcurrentHashMap 和跳表容器的迭代器具有弱一致性,可以在并发修改时继续遍历。遍历过程中可能看到部分更新,所以得到的结果与上面 CopyOnWriteArrayList 的数组快照不同。例如一边更新 Map,一边遍历并累加所有值,算出的总数未必对应 Map 在某个时刻的状态。