无限循环的原因是以下两个因素的组合:
- 地图条目如何在内部存储
- 键迭代器如何工作
1
地图条目存储为链接列表的数组:
transient volatile Node<K,V>[] table
根据其哈希值(
hash % table.length
),每个地图条目最终都会出现在此数组中的一个链接列表中。
public V put(K key, V value) {
int hash = computeHash(key) % table.length
Node<K,V> linkedList = table[hash]
linkedList.add(new Node(key, value))
}
两个具有
相同哈希值的键(例如0和4294967297)将最终出现在同一个列表中。
2
迭代器的工作非常简单:逐个迭代条目。
由于内部存储基本上是一个集合的集合,它会遍历从
table[0]
列表开始的所有条目,然后是
table[1]
等等。
但是有一个实现细节使我们的示例仅对具有哈希冲突的映射永远运行:
public final K next() {
Node<K,V> p;
if ((p = next) == null)
throw new NoSuchElementException();
K k = p.key;
lastReturned = p;
advance();
return k;
}
方法 next() 的实现返回一个预先计算好的值,并计算将在未来调用时返回的值。当迭代器被实例化时,它收集第一个元素,当第一次调用 next()
时,它收集第二个元素并返回第一个元素。
以下是 advance()
方法的相关代码:
Node<K,V>[] tab;
Node<K,V> next;
. . .
final Node<K,V> advance() {
Node<K,V> e;
if ((e = next) != null)
e = e.next;
for (;;) {
Node<K,V>[] t; int i, n;
if (e != null)
return next = e;
. . .
}
}
以下是我们地图的内部状态如何发展的描述:
Map<Long, Long> map = new ConcurrentHashMap<>()
所有的桶(链表)都是空的,表示为
[ null, null, ... , null ]
。
map.put(0L, 0L);
"
[ 0:0, null, ... , null ]
第一个桶得到了一个条目
"
map.put((1L << 32) + 1, 0L);
“[ 0:0 -> 4294967297:0, null, ... , null ]”现在第一个桶中有两个条目。
在第一次迭代中,迭代器返回“0”,并将“4294967297:0”条目保留为“next”。
map.remove(0)
[ 4294967297:0, null, ... , null ]
map.put(0, 0) // the entry our iterator holds has its next pointer modified
[4294967297:0 -> 0:0,null,...,null]
第二次迭代
map.remove(4294967297)
[ 0:0,null,...,null ]
map.put(4294967297, 0)
[ 0:0 -> 4294967297:0, null, ... , null ]
经过两次迭代,我们回到了起点,因为我们的操作归结为从链表头部删除项目并将其添加到尾部,因此我们无法完成消耗。
如果没有哈希冲突的映射,它不会陷入无限循环,因为我们添加到的链表已经被迭代器留下。
以下是一个证明它的示例:
Map<Long, Long> map = new ConcurrentHashMap<>();
map.put(0L, 0L);
map.put(1L, 0L);
int iteration = 0;
for (long key : map.keySet()) {
map.put((1L << 32) + 1, 0L);
map.put((1L << 33) + 2, 0L);
map.put((1L << 34) + 4, 0L);
System.out.printf("iteration:%d key:%d map size:%d %n", ++iteration, key, map.size());
map.put(key, map.remove(key));
}
输出结果为:
迭代次数:1 键值:0 映射大小:5
迭代次数:2 键值:1 映射大小:5
循环中添加的所有项都会进入同一个桶中——第一个桶——这是我们的迭代器已经消耗的那个桶。