盘点并发集合里那些容易误判的行为

之前遇到过 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 / forEachValueparallelismThreshold1 = 最大并行:批量切分到 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() 满时抛 IllegalStateExceptionoffer() 满时返回 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 只表示队列为空。

还需要区分下面几组操作:

  1. 它不是 FIFO,是按到期时间排序的优先队列。底层是 PriorityQueue<E>(最小堆),顺序由 Delayed.compareTo 决定,堆头是"最早到期"而不是"最先插入"。先插入但到期晚的元素排后面,两个同 delay 的元素之间也没有 FIFO 保证。

  2. 同一队列里,操作对"到期"的态度差别很大,全部源于"只检查堆头"这一实现:

操作 看不看"到期" 语义
peek() 不看 返回堆头(最早到期,未到期也返回);仅空队列返回 null
poll() / take() 只看堆头 堆头未到期 → poll 返回 null / take 挂起等待
size() / iterator() / toArray() 不看 统计/遍历全部元素(含未到期,expired 元素在 poll / take 前一直躺在堆里)
drainTo() 只看堆头逐个 只移走已到期的元素
remove(e) 不看 按 equals 删除,未到期也能删,合法但不直观
  1. 有个隐式契约:compareTo 必须和 getDelay 一致。Delayed 接口 javadoc 要求 compareTo 提供的排序与 getDelay 一致,但算法只检查堆头的 getDelay。如果 compareTo 的排序键和到期时间不一致(比如按插入顺序排),堆头就可能不是最早到期的元素:take() 会等错误的堆头,排在后面、已经到期的元素取不出来;错误堆头永不到期时,等待可能无限延长。运行时不会检查这种错误。

  2. 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 时能拿到元素。

这些查询方法判断不了另一线程此刻能否完成交接,跨线程协作应以 puttakeofferpoll 的实际结果为准,别把 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,不抛异常。视图迭代器弱一致。

总结

  1. 使用并发集合踩坑的本质,往往是按直觉使用。
  2. 看文档与契约,别靠直觉。 契约不能从名字推断。 CopyOnWrite 也抛 CME(subList 是视图),"看起来空"不代表"没有"(SynchronousQueue 恒空)
  3. 机制决定行为,行为可以预测。并发集合不承诺"要么精确、要么报错",迭代器要么弱一致、要么快照。弱一致不是随机的,可以通过游标理解其行为。
  4. 并发情况下查询操作只代表历史值。决策需要建立在操作本身的结果上,不建立在查询返回值上。
相关推荐
云边有个稻草人1 小时前
时序数据库如何告别手工分片?看金仓“超表”怎样简化海量数据管理
后端
Java编程爱好者1 小时前
面试官问"这段逻辑为什么这样写"——我才发现,这半年我写的代码,我自己都解释不了
后端
IT爱学堂2 小时前
java版数据结构和算法+AI算法和技能学习指南
java·开发语言
evans在进步2 小时前
LeetCode 394:字符串解码——Java 单栈模拟与嵌套解析详解
java·python·leetcode
苍何2 小时前
偷偷分享 DeepSeek Harness 热榜挖到的 2 个实⽤插件
后端
神奇小汤圆3 小时前
面试官问:"你们上线前怎么测Agent系统?上线后怎么知道它好不好?"
后端
FfHUCisI3 小时前
Golang - 信号量模式(Semaphore Pattern)
开发语言·后端·golang
神奇小汤圆3 小时前
Redis 和 MySQL 如何保证数据一致性?先更新数据库还是先删缓存,延迟双删、MQ、Canal 一次讲透
后端
m0_547486663 小时前
《Java程序设计与实践 》全套PPT课件2026
java·开发语言