并发容器遍历时不抛 ConcurrentModificationException,但它们到底保证了什么?遍历看到的是什么?这篇用真实的并发读写逐个验证:遍历、size()、跨多个键的不变量,以及 CopyOnWriteArrayList 的快照语义和写入代价。
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
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 之间)
遍历是否出现重复键(一次遍历里同一个键两次)= 0100 个账户各 1000(总和 100000),2 个线程不停地转账(每次先扣后加,两步各是一次原子 merge);读线程遍历求和 1512739 次,其中总和 ≠ 100000 的有 1194693 次,总和范围 [99560, 100350];写线程停止后总和 = 100000 对照:把整个映射放进 AtomicReference,每次转账复制一份新映射再整体替换:读线程 9149647 次读到的总和 ≠ 2000 的有 0 次(读到的是某一次替换后的完整快照) 对照:HashMap + synchronized(转账与求和在同一把锁里):读线程 914221 次,总和 ≠ 100000 的有 0 次(代价是读写互相阻塞)
元素 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(静止时精确)