一个线程遍历列表,另一个线程往里面添加元素,是使用集合时经常会遇到的情况。普通 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 在某个时刻的状态。

最后修改:2018 年 03 月 03 日
如果觉得我的文章对你有用,请随意赞赏