bigywater.tech
Java · 235

并发容器的弱一致性:遍历时看到的到底是什么

并发容器遍历时不抛 ConcurrentModificationException,但它们到底保证了什么?遍历看到的是什么?这篇用真实的并发读写逐个验证:遍历、size()、跨多个键的不变量,以及 CopyOnWriteArrayList 的快照语义和写入代价。

0.4%
并发写入时,ConcurrentHashMap 遍历结果恰好等于某一时刻真实条目数的比例
79%
转账场景下遍历求和与总和不一致的比例
0.9 → 16.8 μs
CopyOnWriteArrayList.add:1000 → 10 万个元素

遍历时修改

实测 · 本机执行输出
  ArrayList                    ConcurrentModificationException(遍历到第 3 个元素之后)
  HashMap.keySet               ConcurrentModificationException(遍历到第 3 个元素之后)
  synchronizedMap.keySet       ConcurrentModificationException(遍历到第 3 个元素之后)
  ConcurrentHashMap.keySet     正常遍历,看到 [1, 2, 3, 4, 5, 6, 7, 9, 10, 11]
  ConcurrentSkipListMap        正常遍历,看到 [1, 2, 3, 4, 5, 6, 7, 9, 10, 11]
  CopyOnWriteArrayList         正常遍历,看到 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];遍历结束后列表 = [1, 2, 3, 4, 5, 6, 7, 9, 10, 11]
  ConcurrentLinkedQueue        正常遍历,看到 [1, 2, 3, 4, 5, 6, 7, 9, 10, 11]
  LinkedBlockingQueue          正常遍历,看到 [1, 2, 3, 4, 5, 6, 7, 9, 10, 11]
  ArrayBlockingQueue           正常遍历,看到 [1, 2, 3, 4, 5, 6, 7, 9, 10, 11]
  CopyOnWriteArrayList 迭代器的 remove() → UnsupportedOperationException(迭代器操作的是快照)
  ConcurrentHashMap 迭代器的 remove() 可用:删除后 chm.size() = 9

并发写入时遍历与 size()

实测 · 本机执行输出
  N = 1000,写线程不停地「先加入一个新键、再删除一个旧键」,所以任何时刻里的条目数是 N 或 N+1;读线程 2 秒内遍历 288086 次:
  遍历数到的条目数 − N:最小 -1000,最大 1048,中位 6;其中等于 0 或 1(即与某一时刻的真实条目数一致)的占 0.4%
  同时 m.size() − N 的分布:{0=104982, 1=183104}(size() 始终在 N 到 N+1 之间)
  遍历是否出现重复键(一次遍历里同一个键两次)= 0

跨多个键的不变量

实测 · 本机执行输出
  100 个账户各 1000(总和 100000),2 个线程不停地转账(每次先扣后加,两步各是一次原子 merge);读线程遍历求和 1512739 次,其中总和 ≠ 100000 的有 1194693 次,总和范围 [99560, 100350];写线程停止后总和 = 100000
  对照:把整个映射放进 AtomicReference,每次转账复制一份新映射再整体替换:读线程 9149647 次读到的总和 ≠ 2000 的有 0 次(读到的是某一次替换后的完整快照)
  对照:HashMap + synchronized(转账与求和在同一把锁里):读线程 914221 次,总和 ≠ 100000 的有 0 次(代价是读写互相阻塞)

CopyOnWriteArrayList

实测 · 本机执行输出
  元素   1000 个:CopyOnWriteArrayList.add 每次 0.9 μs(ArrayList.add 0.066 μs),创建迭代器 0.33 μs(不复制,拿的是数组引用)
  元素  10000 个:CopyOnWriteArrayList.add 每次 2.7 μs(ArrayList.add 0.028 μs),创建迭代器 0.13 μs(不复制,拿的是数组引用)
  元素 100000 个:CopyOnWriteArrayList.add 每次 16.8 μs(ArrayList.add 0.027 μs),创建迭代器 0.08 μs(不复制,拿的是数组引用)
  迭代器创建后列表又被修改(加 d、删 a):迭代器看到的仍是创建时的快照 [a, b, c],列表现在是 [b, c, d]
  ConcurrentHashMap.reduceValuesToLong(并行度阈值 1)对静止映射求和 = 1000;mappingCount() = 1000,size() = 1000(静止时精确)

分步演示

弱一致性
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK(Temurin 17.0.20、Homebrew 21.0.12 与 27,每篇注明实际用的版本);用 javac 编译、java 直接运行,需要第三方库时用 Maven 解析依赖;计时用 System.nanoTime,数字每次运行会有小幅抖动。JDK 21.0.12,10 核 macOS
    • 没有验证:ConcurrentSkipListMap 的并发遍历数目分布、parallel stream 与批量操作(forEach、reduce)在并发写入下的行为、ConcurrentLinkedQueue 的 size() 复杂度、遍历时计数的结果与表大小、扩容(resize)的关系(这篇只统计了结果,没有解释分布的形状)
    京ICP备2026067502号-1京公网安备11010502063487号