【问题标题】:ConcurrentHashMap stuck in infinite loop - Why?ConcurrentHashMap 陷入无限循环 - 为什么?
【发布时间】:2019-07-05 05:22:24
【问题描述】:

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

请解释为什么会发生这种死锁。

【问题讨论】:

  • 你有线程转储吗?

标签: java concurrenthashmap


【解决方案1】:

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

其他答案在这里没有详细说明,但我也很想更好地理解这一点。比如换行时

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

map.put(1L, 0L);

那么它确实不会卡住。同样,问题是为什么


答案是:这很复杂。

ConcurrentHashMap 是并发/集合框架中最复杂的类之一,代码高达 6300 行,其中 230 行 cmets 仅解释了基本的概念实现,以及为什么神奇和不可读的代码实际上有效。以下内容相当简化,但至少应该解释基本问题。

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

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

(我强调)

但是,ConcurrentHashMap::keySet 的 JavaDoc 说:

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

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

通常,在迭代 keySet 时修改地图会抛出 ConcurrentModificationException。但是ConcurrentHashMap 能够应对这种情况。它保持一致并且仍然可以迭代,即使结果可能仍然出乎意料 - 就像您的情况一样。


了解您观察到的行为的原因:

hash table (or hash map) 的工作原理是从键中计算哈希值,并使用该键作为条目应添加到的“桶”的指示符。当多个key映射到同一个bucket时,bucket中的entry通常以链表的形式进行管理。 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 &lt;&lt; 32) + 1)的条目总是以bucket 0结尾,并且它们被维护为一个链表。所以对keySet 的迭代从这个表开始:

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

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

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

但是 0 之后会立即添加 key,并且它与 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 的键最终位于不同的 存储桶中。因此,没有可以将已删除(和添加)的元素附加到的链表,并且循环在遍历相关元素(即前两个存储桶)一次后终止。

【讨论】:

    【解决方案2】:

    我认为这与ConcurrentHashMap 提供的线程安全无关。它甚至根本不像死锁,而是无限循环。

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

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

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

    【讨论】:

      【解决方案3】:

      没有死锁。你只是陷入了一个无限循环。当我运行这段代码(并在循环中打印key)时,控制台会重复显示:

      0
      4294967297
      0
      4294967297
      0
      ...
      

      如果您将map 设为HashMap 实例,您会看到代码引发了ConcurrentModificationException。所以你只是在遍历它的键时修改映射,ConcurrentHashMap 不会抛出并发修改异常,从而使你的循环无限。

      【讨论】:

        【解决方案4】:

        无限循环的原因是结合

        1. 地图条目如何在内部存储
        2. 键迭代器如何工作

        1

        映射条目存储为链表数组:
        transient volatile Node&lt;K,V&gt;[] 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 的 2 个键(如 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 -&gt; 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 -&gt; 0:0, null, ... , null ]

        第二次迭代

        map.remove(4294967297)
        

        [ 0:0, null, ... , null ]

        map.put(4294967297, 0)
        

        [ 0:0 -&gt; 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

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

        【讨论】:

        • 我考虑添加一些关于迭代器内部工作的细节(这是另一个与无限循环相关的难题),很高兴在这里有这个,+1
        【解决方案5】:

        没有死锁。死锁是两个(或多个)线程互相阻塞的时候。很明显,你这里只有一个主线程。

        【讨论】:

          猜你喜欢
          • 2010-11-01
          • 2019-12-23
          • 1970-01-01
          • 1970-01-01
          • 2023-03-22
          • 2016-04-08
          • 2022-08-19
          • 2020-05-13
          相关资源
          最近更新 更多