ConcurrentHashMap陷入循环-为什么?

时间:2019-07-05 05:22:24

标签: java concurrenthashmap

在对ConcurrentHashMap进行深入分析的同时,我在互联网上找到了一篇博客文章,其中说甚至ConcurrentHashMap都可能陷入无限循环。

给出了这个例子。当我运行这段代码时-卡住了:

public class Test {
    public static void main(String[] args) throws Exception {
        Map<Long, Long> map = new ConcurrentHashMap<>();
        map.put(0L, 0L);
        map.put((1L << 32) + 1, 0L);
        for (long key : map.keySet()) {
            map.put(key, map.remove(key));
        }
    }
}

我无法理解根本原因。请解释为什么会发生这种僵局。

5 个答案:

答案 0 :(得分:14)

我认为这与ConcurrentHashMap提供的线程安全无关。甚至看起来根本没有死锁,而是无限循环。

这是由于在迭代键集时修改了映射,该键集由相同的映射支持!

以下是map.keySet()文档的摘录:

  

该集合由地图支持,因此对地图的更改反映在   集合,反之亦然。如果在迭代时修改了地图   集合中正在进行中(通过迭代器自己的remove除外)   操作),则迭代结果不确定。

答案 1 :(得分:14)

没有死锁。您正陷入无限循环。当我运行此代码(并在循环中打印==时,控制台会反复显示此信息:

key

如果将0 4294967297 0 4294967297 0 ... 设置为map实例,则会看到代码引发了HashMap。因此,您只是在迭代地图的同时修改地图,而ConcurrentModificationException不会引发并发修改异常,从而使循环无休止。

答案 2 :(得分:9)

正如其他人已经说过的:这不是死锁,而是无限循环。无论如何,问题的核心(和标题)是:为什么会这样?

其他答案在这里没有详细介绍,但我很好奇也很好奇。例如,当您更改行

map.put((1L << 32) + 1, 0L);

map.put(1L, 0L);

然后卡住。再一次,问题是为什么


答案是:很复杂。

ConcurrentHashMap是并发/集合框架中最复杂的类之一,具有多达6300行代码,其中230行注释仅解释了基本的概念实施,以及为何神奇且难以读取的代码实际上起作用。以下内容相当简化,但至少应说明基本问题。

首先:Map::keySet返回的集合是内部状态的 view 。 JavaDoc说:

  

返回此映射中包含的键的Set视图。该集合由地图支持,因此对地图的更改会反映在集合中,反之亦然。 如果在对集合进行迭代时修改了地图(通过迭代器自己的remove操作除外),则迭代的结果是不确定的。该集合支持元素删除,[...]

(我强调)

但是,ConcurrentHashMap::keySet的JavaDoc说:

  

返回此映射中包含的键的Set视图。该集合由地图支持,因此对地图的更改会反映在集合中,反之亦然。该集合支持元素删除,[...]

(请注意,它没有提到未定义的行为!)

通常,在迭代keySet时修改地图会抛出ConcurrentModificationException。但是ConcurrentHashMap可以解决这个问题。即使结果可能仍然出乎意料,它仍然保持一致,并且仍然可以迭代(如您所愿)。


出现您所观察到的行为的原因:

hash table (or hash map)的工作原理基本上是:根据键计算散列值,并将此键用作应添加条目的“存储桶”的指示符。当多个键映射到同一存储桶时,存储桶中的条目通常作为链接列表管理。 ConcurrentHashMap的情况也是如此。

以下程序在迭代和修改过程中使用了一些讨厌的反射技巧来打印表的内部状态,特别是表的“桶”(由节点组成):

import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class MapLoop
{
    public static void main(String[] args) throws Exception
    {
        runTestInfinite();
        runTestFinite();
    }

    private static void runTestInfinite() throws Exception
    {
        System.out.println("Running test with inifinite loop");

        Map<Long, Long> map = new ConcurrentHashMap<>();
        map.put(0L, 0L);
        map.put((1L << 32) + 1, 0L);

        int counter = 0;
        for (long key : map.keySet())
        {
            map.put(key, map.remove(key));

            System.out.println("Infinite, counter is "+counter);
            printTable(map);

            counter++;
            if (counter == 10)
            {
                System.out.println("Bailing out...");
                break;
            }
        }

        System.out.println("Running test with inifinite loop DONE");
    }

    private static void runTestFinite() throws Exception
    {
        System.out.println("Running test with finite loop");

        Map<Long, Long> map = new ConcurrentHashMap<>();
        map.put(0L, 0L);
        map.put(1L, 0L);

        int counter = 0;
        for (long key : map.keySet())
        {
            map.put(key, map.remove(key));

            System.out.println("Finite, counter is "+counter);
            printTable(map);

            counter++;
        }

        System.out.println("Running test with finite loop DONE");
    }


    private static void printTable(Map<Long, Long> map) throws Exception
    {
        // Hack, to illustrate the issue here:
        System.out.println("Table now: ");
        Field fTable = ConcurrentHashMap.class.getDeclaredField("table");
        fTable.setAccessible(true);
        Object t = fTable.get(map);
        int n = Array.getLength(t);
        for (int i = 0; i < n; i++)
        {
            Object node = Array.get(t, i);
            printNode(i, node);
        }
    }

    private static void printNode(int index, Object node) throws Exception
    {
        if (node == null)
        {
            System.out.println("at " + index + ": null");
            return;
        }
        // Hack, to illustrate the issue here:
        Class<?> c =
            Class.forName("java.util.concurrent.ConcurrentHashMap$Node");
        Field fHash = c.getDeclaredField("hash");
        fHash.setAccessible(true);
        Field fKey = c.getDeclaredField("key");
        fKey.setAccessible(true);
        Field fVal = c.getDeclaredField("val");
        fVal.setAccessible(true);
        Field fNext = c.getDeclaredField("next");
        fNext.setAccessible(true);

        System.out.println("  at " + index + ":");
        System.out.println("    hash " + fHash.getInt(node));
        System.out.println("    key  " + fKey.get(node));
        System.out.println("    val  " + fVal.get(node));
        System.out.println("    next " + fNext.get(node));
    }
}

runTestInfinite情况的输出如下(省略冗余部分):

Running test with infinite loop
Infinite, counter is 0
Table now: 
  at 0:
    hash 0
    key  4294967297
    val  0
    next 0=0
at 1: null
at 2: null
...
at 14: null
at 15: null
Infinite, counter is 1
Table now: 
  at 0:
    hash 0
    key  0
    val  0
    next 4294967297=0
at 1: null
at 2: null
...
at 14: null
at 15: null
Infinite, counter is 2
Table now: 
  at 0:
    hash 0
    key  4294967297
    val  0
    next 0=0
at 1: null
at 2: null
...
at 14: null
at 15: null
Infinite, counter is 3
...
Infinite, counter is 9
...
Bailing out...
Running test with infinite loop DONE

您可以看到,密钥0和密钥4294967297(即您的(1L << 32) + 1)的条目始终以存储桶0结尾,并且被维护为链接列表。因此,keySet上的迭代从此表开始:

Bucket   :   Contents
   0     :   0 --> 4294967297
   1     :   null
  ...    :   ...
  15     :   null

在第一次迭代中,它删除了键0,基本上将表变成了这个键:

Bucket   :   Contents
   0     :   4294967297
   1     :   null
  ...    :   ...
  15     :   null

但是密钥0之后会立即添加,并且它与4294967297在同一个存储段中-因此它会附加在列表的末尾:

Bucket   :   Contents
   0     :   4294967297 -> 0
   1     :   null
  ...    :   ...
  15     :   null

(这由输出的next 0=0部分指示)。

在下一次迭代中,4294967297将被删除并重新插入,使表恢复到最初的状态。

这就是无限循环的来源。


与此相反,runTestFinite情况的输出是这样的:

Running test with finite loop
Finite, counter is 0
Table now: 
  at 0:
    hash 0
    key  0
    val  0
    next null
  at 1:
    hash 1
    key  1
    val  0
    next null
at 2: null
...
at 14: null
at 15: null
Finite, counter is 1
Table now: 
  at 0:
    hash 0
    key  0
    val  0
    next null
  at 1:
    hash 1
    key  1
    val  0
    next null
at 2: null
...
at 14: null
at 15: null
Running test with finite loop DONE

可以看到,键01最终出现在不同存储桶中。因此,没有链表可以添加已删除(和添加)的元素,并且循环遍历相关元素(即前两个存储桶)一次后终止。

答案 3 :(得分:3)

无限循环的原因是

的组合
  1. 地图条目如何在内部存储
  2. 密钥迭代器如何工作

1

地图条目存储为链接列表的数组:
transient volatile Node<K,V>[] table
每个地图条目都将基于其哈希值(hash % table.length)最终出现在此数组的链接列表之一中:

//simplified pseudocode
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))
}

两个带有same hash的键(如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;        // current table; updated if resized
Node<K,V> next;         // the next entry to use
. . .

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; // our example will always return here
        . . .
    }
}

这是我们地图内部状态的演变方式:

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 ]

因此,经过2次迭代,我们又回到了起点,因为我们的行动归结为从链接列表的开头删除一项并将其添加到其尾部,因此我们无法结束使用它。
对于没有哈希冲突的地图,它不会陷入无限循环,因为我们添加到的链表已经被迭代器遗忘了。
这是一个证明这一点的例子:

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));
}

输出为:
iteration:1 key:0 map size:5
iteration:2 key:1 map size:5

循环中添加的所有项目最终都在同一存储桶中-第一个存储桶-我们的迭代器已经消耗的存储桶。

答案 4 :(得分:2)

没有死锁。死锁是指两个(或多个)线程彼此阻塞。很明显,这里只有一个主线程。