之前遇到过
CopyOnWriteArrayList.subList()抛出的ConcurrentModificationException的问题。CopyOnWrite 不是号称并发安全吗,怎么还会 CME?为了弄清相关问题,我把 JDK 8 里几个常用并发集合的实现梳理了一遍,发现类似反直觉的行为还有不少,简单整理出来,以飨读者。内容涉及不少底层实现,请做好心理准备。
1. 并发集合的契约不是 fail-fast
java.util.concurrent 里的集合遵循两套遍历契约,都不是"线程安全的 ArrayList"这么简单:
- 弱一致(weakly consistent):迭代器不抛 CME,但可能漏看并发新增的元素,也可能重复看到已删除的元素;
size()只是某一瞬间的近似值。 - 快照(snapshot):迭代器在创建时拷贝一份数据,之后的数据永远看不见。
弱一致迭代器会混入遍历期间的部分修改,可能也会漏掉一部分;快照迭代器则固定在创建那一刻。用户直觉里"要么抛 CME,要么实时可见"的两个极端,在并发集合里都不存在。
要理解这两套契约,得看迭代器内部那个"游标"在底层数据结构上是怎么前进的。
2. 弱一致性与游标
迭代器(快照型除外)不持有数据拷贝,只持有当前位置,也就是游标。遍历时游标在活数据结构上怎么走,决定了你会看到什么:新加的元素会不会漏看、删掉的元素会不会重复出现,全看游标的前进策略。
2.1 游标是节点引用,顺链前进
LinkedBlockingQueue、ConcurrentLinkedQueue、LinkedTransferQueue 的迭代器持有当前节点引用,沿着 next 链前进。链表删除节点时有两个处理:
- 被
remove(o)删掉的节点:unlink只把p.item置为 null,保留next指针。源码注释原话是"p.next is not changed, to allow iterators that are traversing p to maintain their weak-consistency guarantee"; - 被出队的头节点:
dequeue把旧头自链接为h.next = h(注释 "help GC",让被出队节点尽快被回收,也不再拖住旧链)。
于是 nextNode 对"节点已死"有两种处理:
java
// LinkedBlockingQueue.Itr.nextNode(JDK 8)
private Node<E> nextNode(Node<E> p) {
for (;;) {
Node<E> s = p.next;
if (s == p) // 追踪的节点已被出队(自链接)→ 直接跳回队头
return head.next;
if (s == null || s.item != null) // 跳过 item=null 的已删除节点
return s;
p = s;
}
}
ConcurrentLinkedQueue.succ 是同一个套路:return (p == next) ? head : next;。由此带来几个表现:
- 可能漏看新增。新元素总插在队尾,迭代器越过原链尾之后追加的元素看不到,没越过的可能看得到。
- 可能重复看到已删除的元素。被出队的节点自链接,游标撞上后跳回队头重新扫,已经遍历过的前缀可能再被扫一遍------javadoc 里的 "may... duplicate" 说的就是这个。
- 被
remove删的节点只清 item、保留 next,游标扫到就跳过,不会中断遍历。
2.2 游标是数组索引,靠 Itrs 补偿
ArrayBlockingQueue 的迭代器游标是索引(cursor / nextIndex)。队列和所有活跃迭代器通过 Itrs(弱引用链表)关联,元素被移除时回调每个迭代器调整索引:
removeAt(i)删掉的位置在游标之前:Itrs.removedAt把游标回拨(index--),让移位补进来的元素不漏掉;takeIndex前进越过游标(元素被 poll 走):incorporateDequeues把游标前拨到 takeIndex,跳过已出队的元素。
所以 ABQ 的迭代器是弱一致的活视图:删除游标之前的元素时会补偿索引,减少遗漏和重复;但对遍历期间的新入队不作承诺,也不保证完整遍历。
2.3 游标是表下标加 bin 指针,扩容时跨代迁移
ConcurrentHashMap 的迭代器(Traverser)游标是"表数组下标 + bin 内节点指针"。遍历途中遇到扩容,撞到 ForwardingNode 就跳到 nextTable 继续,下标跟着带过去,保证遍历能走完新表。遍历不会因扩容中断。
重复访问不会发生:旧表的每个 bin 恰好被访问一次,撞到 ForwardingNode 下降后也只扫该 bin 分裂出来的两个新 bin({i, i+n})。节点只会从旧 bin i 迁到新 bin {i, i+n},而这两个新 bin 唯一的入口就是"在旧 bin i 处下降"------迭代器经过旧 bin i 时若还没被转发,就访问一次、不下降,之后没有第二条路再碰到它。
会发生的漏看:迭代器扫过 bin i 之后、该 bin 被转发之前插入的元素,会随迁移进入新表,而新表 {i, i+n} 已无入口,本趟看不到;下一趟遍历从当前表重新开始,又会全部看到,所以漏看只影响当趟。从遍历结果里也判断不出 map 是否经历过扩容。
2.4 快照迭代器:游标指向拷贝
CopyOnWriteArrayList 和 PriorityBlockingQueue 的迭代器游标指向构造时捕获的数组,和活结构没有关系:不前进也不后退,没有补偿,永远看不到新数据,不抛 CME,也不重复。这是另一种性质的迭代。
小结
| 游标策略 | 实现要点 | 漏看新增 | 重复看到已删 | 代表 |
|---|---|---|---|---|
| 顺链 + 跳头 | 节点引用沿 next 走,遇 item=null 跳过,遇自链接跳回 head.next | 可能漏看后续追加 | 会(跳头重扫) | LBQ / CLQ / LTQ |
| 索引补偿 | 索引 + Itrs 回调:删前回拨、出队前拨 | 不保证看到后续入队 | 较少由移位造成重复 | ArrayBlockingQueue |
| 跨代迁移 | 表下标 + bin 指针,ForwardingNode 跳 nextTable | 迁移中可能漏 | 不会 | ConcurrentHashMap |
| 快照 | 游标指向构造时拷贝的数组 | 永远看不到 | 不会 | COW / PBQ |
判断一个陌生并发集合的迭代器行为,就看两点:游标是什么,以及元素被删除时游标怎么走。这两点基本决定了它的弱一致性表现。
3. ConcurrentHashMap
null 键值。不允许。put(null, ...) 显式抛 NPE;get(null)、containsKey(null)、getOrDefault(null, d) 是对 null 调 hashCode(),隐式抛 NPE。和 HashMap 正好相反。
size() / isEmpty()。弱一致,激烈并发下是近似值。要 long 计数用 mappingCount()------size() 的 int 可能装不下。还有一个坑:并发删除下 sumCount() 可能瞬态为负,isEmpty() 用 sumCount() <= 0 判断(源码注释 "ignore transient negative values"),所以非空 map 可能短暂报告 isEmpty() == true。
视图一致性。keySet().size() / values().size() 和 size() 是同一个调用------JDK 8 里视图的 size() 直接委托 map.size()(CollectionView 基类),同一时刻不可能互不一致。真正可能对不上的是遍历结果和 size() 报的数:迭代器弱一致,遍历到的元素个数可以和 size() 不同。
compute / computeIfAbsent / merge。映射函数在 bin 锁内执行(synchronized (f),空 bin 用 ReservationNode 占位并锁它)。javadoc 要求函数"短而简单,且不得更新这个 map 的其他映射"。函数里再碰这个 map:同 key 递归在 JDK 8 有两个结局------key 已存在则无限递归栈溢出(synchronized 可重入,不会死锁);key 缺失走 ReservationNode 路径则无限自旋忙等。JDK 9+ 才补了 "Recursive update" 检测(JDK-8177290),改成抛 IllegalStateException。跨线程互相等 bin 锁则死锁。
forEachKey / forEachValue 的 parallelismThreshold。1 = 最大并行:批量切分到 ForkJoinPool.commonPool(),batch 约为 commonPool 并行度的 4 倍;Long.MAX_VALUE = 串行。commonPool 被占满时调用线程会卡住。
扩容期间。写操作撞上 ForwardingNode 会参与迁移(helpTransfer),读操作沿 ForwardingNode 跳到 nextTable 继续查。两者都不用等整轮扩容完成,但普通 bin 锁竞争仍可能让写操作等待。
java
// 映射函数内修改 map:禁止
map.computeIfAbsent(1, k -> {
return map.computeIfAbsent(2, k2 -> k2 * 2); // 同 bin 递归:JDK 8 栈溢出或死循环,JDK 9+ 抛 ISE
});
4. 阻塞队列:Linked、Array、Priority、Delay
LinkedBlockingQueue。无参构造容量是 Integer.MAX_VALUE,所以 offer() 几乎永不返回 false,put() 几乎永不阻塞。容量约束机制是真实存在的(put 里有 while (count.get() == capacity) 检查),只是默认上限大到现实不可达,OOM 先于阻塞。"有界"在这里的实际含义是"上限不可达",不是"没有边界"。
ArrayBlockingQueue。iterator() 不是快照,是弱一致的活视图:迭代器只记录游标索引,通过 Itrs(队列与活跃迭代器的弱引用共享结构)在元素移除时回调调整游标;之后入队且未越过游标的元素能看到;还支持 remove()(真实删除)。"快照 + 不支持 remove"是 PriorityBlockingQueue 的特征,别混淆。
PriorityBlockingQueue。iterator() / toArray() / toString() 返回的是底层堆数组的顺序(父 ≤ 子的部分有序),不是排序后的有序序;只有 poll / peek 保证取最小。迭代器是快照型,基于 toArray 拷贝。remove(Object) 是 O(n) 线性扫描 + O(log n) 堆修复。另外注意比较器:调用点在锁内的 siftUp / siftDown,异常会跳过 size = n + 1 且数组已部分写入,堆不变量被破坏,之后行为未定义(锁会被 finally 正确释放,不会死锁)。
BlockingQueue 通用。同一个"放不进去":add() 满时抛 IllegalStateException,offer() 满时返回 false。drainTo(c) 在操作期间 c 被并发修改时行为未定义;drainTo(自己) 抛 IllegalArgumentException。
DelayQueue:容易混淆的语义
peek() 不检查元素是否到期:队列非空就返回堆头。堆头尚未到期时,返回 null 的是 poll()(first.getDelay(NANOSECONDS) > 0);peek() 返回的是下一个将到期的元素(javadoc 原话:"Unlike {@code poll}, if no expired elements are available in the queue, this method returns the element that will expire next")。所以 peek() == null 只表示队列为空。
还需要区分下面几组操作:
-
它不是 FIFO,是按到期时间排序的优先队列。底层是
PriorityQueue<E>(最小堆),顺序由Delayed.compareTo决定,堆头是"最早到期"而不是"最先插入"。先插入但到期晚的元素排后面,两个同 delay 的元素之间也没有 FIFO 保证。 -
同一队列里,操作对"到期"的态度差别很大,全部源于"只检查堆头"这一实现:
| 操作 | 看不看"到期" | 语义 |
|---|---|---|
| peek() | 不看 | 返回堆头(最早到期,未到期也返回);仅空队列返回 null |
| poll() / take() | 只看堆头 | 堆头未到期 → poll 返回 null / take 挂起等待 |
| size() / iterator() / toArray() | 不看 | 统计/遍历全部元素(含未到期,expired 元素在 poll / take 前一直躺在堆里) |
| drainTo() | 只看堆头逐个 | 只移走已到期的元素 |
| remove(e) | 不看 | 按 equals 删除,未到期也能删,合法但不直观 |
-
有个隐式契约:compareTo 必须和 getDelay 一致。
Delayed接口 javadoc 要求 compareTo 提供的排序与 getDelay 一致,但算法只检查堆头的 getDelay。如果 compareTo 的排序键和到期时间不一致(比如按插入顺序排),堆头就可能不是最早到期的元素:take()会等错误的堆头,排在后面、已经到期的元素取不出来;错误堆头永不到期时,等待可能无限延长。运行时不会检查这种错误。 -
take()是 leader-follower 优化。多线程 take 时只有 leader 执行精确的awaitNanos(delay),其余线程一律await()无限等待;leader 到期取走元素后 signal 接力,等待的精确性和线程数无关。offer 时若新元素成为堆头,会清空 leader 并 signal------等待时间要重算。
java
// DelayQueue:不是 FIFO;peek 与 poll 语义相反
DelayQueue<Delayed> q = new DelayQueue<>();
q.offer(expireIn(1, TimeUnit.HOURS)); // 先插入:1 小时后到期
q.offer(expireIn(1, TimeUnit.MINUTES)); // 后插入:1 分钟后到期
q.peek(); // 返回"1 分钟后到期"的元素 ------ 未到期也返回!null 只表示队列空
q.poll(); // null ------ 堆头未到期。这才是"看起来空,实际 size()==2"
// 一分钟后:先取出的是后插入的 1 分钟元素,一小时后再取出先插入的 ------ 完全不是 FIFO
5. CopyOnWriteArrayList / CopyOnWriteArraySet
subList()。是视图,不是快照:内部持原 list 引用 + offset + expectedArray(构造时捕获的底层数组引用),每次操作先比对 l.getArray() != expectedArray,不一致就抛 CME。COW 每次修改都用新数组整体替换引用,所以任何线程对原 list 的修改(add/remove、换值 set------COW 的 set 也整体拷贝数组)都会让 subList 失效。subList 自己的操作会刷新 expectedArray,不抛 CME,改动实时反映到原 list。javadoc 的定义是:原 list 只要不是通过这个返回的 subList 修改的,subList 的语义就未定义。
iterator() / listIterator()。创建时快照:之后 add 的元素永远看不见,也不抛 CME;remove() / set() / add() 抛 UnsupportedOperationException。
复杂度。contains / indexOf / remove(Object) 是 O(n) 全扫描;add / set 每次 O(n) 全量数组拷贝(换值 set 也拷贝)。removeAll / retainAll 不是 O(n²):单趟扫描 + 分配一次临时数组 + 结尾一次 copyOf 收尾,总拷贝量 O(n);复杂度取决于 c.contains() 的成本------c 是 HashSet 时整体 O(n),c 是 ArrayList 时 O(n·m)。
java
// subList 的 CME(注意:set 换值同样触发)
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.addAll(Arrays.asList("a", "b", "c"));
List<String> sub = list.subList(0, 2);
list.add("d"); // 任何线程的结构性修改
sub.size(); // ConcurrentModificationException!
6. SynchronousQueue
size() / remainingCapacity() 恒为 0,isEmpty() 恒为 true,contains() / remove(Object) 恒为 false,peek() 恒为 null,iterator() 恒为空;poll() 在有等待中的 putter 时能拿到元素。
这些查询方法判断不了另一线程此刻能否完成交接,跨线程协作应以 put、take、offer、poll 的实际结果为准,别把 size()、peek()、isEmpty() 当决策依据。
原因在于它保存的不是元素:
- 队列里存的是等待者。底层是 dual stack / dual queue 算法:不保存数据元素,只保存未完成的交接请求(putter 或 taker,相遇即匹配)。有等待的 taker 时生产者可立即交付,有等待的 putter 时消费者可立即取得元素;没有对侧等待者时,阻塞操作才会等待。
put/take是同一个方法的两条对称路径。transfer(e, timed, nanos)中 e 非 null = 生产者、e 为 null = 消费者,源码注释明说 "the put and take operations are symmetrical"。- 公平 / 非公平是两套数据结构:非公平用
TransferStack(LIFO,后到的先匹配,可能饿死),公平用TransferQueue(FIFO,带头节点)。构造器fair ? new TransferQueue<>() : new TransferStack<>()决定。
7. ConcurrentLinkedQueue / LinkedTransferQueue
ConcurrentLinkedQueue 的 size()。O(n)。javadoc 明确说 size 不是常数时间操作,遍历期间被修改时结果可能不准确。JDK 8 没有缓存计数,每次从头遍历。
LinkedTransferQueue。无界 dual queue:所有入队 / 出队先尝试与队首对侧模式的未匹配节点 CAS 匹配。有等待消费者时,offer() 是一次"握手",元素直接被移交、从未入队,队列瞬时为空:
| 操作 | 模式 | 语义 |
|---|---|---|
| transfer(e) | SYNC | 阻塞直到元素被消费者接收(不只是入队),被中断抛 InterruptedException |
| tryTransfer(e) | NOW | 有等待消费者就立即移交;否则返回 false,元素既不入队也不保留 |
| offer(e) / put(e) / add(e) | ASYNC | 永不阻塞、永不返回 false;但若有等待消费者,走匹配分支直接移交而不入队 |
| poll(t) / tryTransfer(e, t, u) | TIMED | 限时等待 |
所以不能用 isEmpty() / peek() 判断它能否立即完成交接:有消费者等待时,isEmpty() 返回 true(第一个未匹配节点是请求节点),peek() 返回 null,但 offer() 仍可立即交付。要观察等待消费者用 hasWaitingConsumer();getWaitingConsumerCount() 只提供估计值,不适合做精确协调。size() 遇到对侧请求节点直接返回 0。
8. ConcurrentSkipListMap / ConcurrentSkipListSet
null 键值。禁止。TreeMap 允许 null value,跳表版不允许;key 在 doPut 检查,value 在 put 检查。
范围视图。subMap / headMap / tailMap 的写操作(put 系)往视图里放范围外的 key 抛 IllegalArgumentException("key out of range");读操作(get / remove)对范围外 key 返回 null / false,不抛异常。视图迭代器弱一致。
总结
- 使用并发集合踩坑的本质,往往是按直觉使用。
- 看文档与契约,别靠直觉。 契约不能从名字推断。 CopyOnWrite 也抛 CME(subList 是视图),"看起来空"不代表"没有"(SynchronousQueue 恒空)
- 机制决定行为,行为可以预测。并发集合不承诺"要么精确、要么报错",迭代器要么弱一致、要么快照。弱一致不是随机的,可以通过游标理解其行为。
- 并发情况下查询操作只代表历史值。决策需要建立在操作本身的结果上,不建立在查询返回值上。