web-dev-qa-db-ja.com

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

このデッドロックが発生する理由を説明してください。

19
Joker

他の人がすでに言っているように:デッドロックではなく、無限ループです。それにもかかわらず、質問のコア(およびタイトル)は次のとおりです:なぜこれが起こるのですか?

他の回答についてはここでは詳しく説明しませんが、これについても理解を深めることに興味がありました。たとえば、行を変更すると

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

map.put(1L, 0L);

その後、スタックしません。そして再び、問題はwhyです。


答えは、複雑です。

ConcurrentHashMapは並行/コレクションフレームワークの最も複雑なクラスの1つで、コードがなんと6300行あり、230行のコメントが基本的なコンセプトのみを説明しています実装の理由と、魔法で読めないコードが実際に機能する理由。以下はかなり簡略化されていますが、少なくともbasicの問題を説明する必要があります。

まず、 Map::keySet によって返されるセットは、内部状態のviewです。そしてJavaDocは言う:

このマップに含まれるキーのセットビューを返します。セットはマップによってサポートされているため、マップへの変更はセットに反映され、その逆も同様です。 セットの反復処理が進行中(イテレータ自身の削除操作を除く)にマップが変更された場合、反復の結果は未定義ですセットは要素の削除をサポートします、[...]

(私による強調)

ただし、 ConcurrentHashMap::keySet のJavaDocは次のように述べています。

このマップに含まれるキーのセットビューを返します。セットはマップによってサポートされているため、マップへの変更はセットに反映され、その逆も同様です。セットは要素の削除をサポートします、[...]

(ただし、未定義の動作についてはではありませんことに注意してください!)

通常、keySetの反復中にマップを変更すると、ConcurrentModificationExceptionがスローされます。しかし、ConcurrentHashMapはこれに対処できます。あなたの場合のように、結果はまだ予想外であるかもしれませんが、それは一貫性を保ち、反復することができます。


あなたが観察した行動の理由に来る:

ハッシュテーブル(またはハッシュマップ) は基本的に、キーからハッシュ値を計算し、このキーを、エントリを追加する「バケット」のインジケータとして使用することで機能します。複数のキーが同じバケットにマッピングされている場合、バケット内のエントリは通常リンクリストとして管理されます。 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が最終的に異なるバケットになることがわかります。したがって、削除された(および追加された)要素を追加できるリンクされたリストはなく、ループは関連する要素(つまり、最初の2つのバケット)onceを反復した後に終了します。

15
Marco13

これは、ConcurrentHashMapが提供するスレッドセーフティとは関係ないと思います。デッドロックのようには見えませんが、無限ループです。

これは、同じマップによってサポートされているキーセットを繰り返し処理しているときにマップが変更されるためです。

map.keySet() のドキュメントからの抜粋を次に示します。

セットはマップによってサポートされているため、マップへの変更はセットに反映され、その逆も同様です。セットの反復処理の進行中にマップが変更された場合(イテレータ自体の削除操作を除く)、反復の結果は未定義です。

14
Kartik

デッドロックはありません。あなたは無限ループに陥っています。このコードを実行すると(そしてループでkeyを出力すると)、コンソールに繰り返し表示されます。

0
4294967297
0
4294967297
0
...

mapHashMapインスタンスにした場合、コードがConcurrentModificationExceptionを発生させることがわかります。したがって、キーを繰り返し処理しながらマップを変更するだけであり、ConcurrentHashMapは同時変更例外をスローしないため、ループが無限になります。

14
ernest_k

無限サイクルの原因は、

  1. マップエントリはどのように内部的に保存されますか
  2. キーイテレーターの仕組み

1

マップエントリは、リンクリストの配列として保存されます。
_transient volatile Node<K,V>[] table_
すべてのマップエントリは、ハッシュ(_hash % table.length_)に基づいて、この配列内のリンクされたリストの1つになります。

_//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))
}
_

同じハッシュ が付いた2つのキー(0や4294967297など)は同じリストになります

2

イテレータの仕事は非常に単純です。エントリを1つずつ繰り返します。
内部ストレージは基本的に、_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()が最初に呼び出されたときに2番目の要素が収集されて最初の要素が返されます。
これが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 ]_最初のバケットに2つのエントリがある

最初の反復では、イテレータは_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 ]_

2回目の反復

_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
Bax

デッドロックはありません。デッドロックとは、2つ(またはそれ以上)のスレッドが互いにブロックしていることです。

2
tony