web-dev-qa-db-fra.com

ConcurrentHashMap coincé dans une boucle infinie - Pourquoi?

Tout en faisant une analyse approfondie de ConcurrentHashMap, Trouvé un blog sur Internet qui dit que même ConcurrentHashMap peut rester coincé dans une boucle infinie.

Il donne cet exemple. Quand j'ai exécuté ce code - il est resté bloqué:

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

Veuillez expliquer pourquoi cette impasse se produit.

19
Joker

Comme d'autres l'ont déjà dit: ce n'est pas une impasse, mais une boucle infinie. Indépendamment de cela, le cœur (et le titre) de la question est: Pourquoi cela se produit-il?

Les autres réponses n'entrent pas dans les détails ici, mais j'étais aussi curieux de mieux comprendre cela. Par exemple, lorsque vous modifiez la ligne

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

à

map.put(1L, 0L);

alors il fait pas se coincer. Et encore une fois, la question est pourquoi .


La réponse est: c'est compliqué.

Le ConcurrentHashMap est l'une des classes les plus complexes du framework concurrent/collections, avec un énorme 6300 lignes de code, avec 230 lignes de commentaires expliquant uniquement le concept de base du implémentation, et pourquoi le code magique et illisible fonctionne. Ce qui suit est plutôt simplifié, mais devrait au moins expliquer le problème basique.

Tout d'abord: l'ensemble renvoyé par Map::keySet est un voir sur l'état interne. Et le JavaDoc dit:

Renvoie une vue d'ensemble des clés contenues dans cette carte. L'ensemble est soutenu par la carte, donc les modifications apportées à la carte sont reflétées dans l'ensemble, et vice-versa. Si la carte est modifiée alors qu'une itération sur l'ensemble est en cours (sauf via la propre opération de suppression de l'itérateur), les résultats de l'itération ne sont pas définis. L'ensemble prend en charge la suppression d'éléments, [...]

(Souligné par moi)

Cependant, le JavaDoc de ConcurrentHashMap::keySet dit:

Renvoie une vue d'ensemble des clés contenues dans cette carte. L'ensemble est soutenu par la carte, donc les modifications apportées à la carte sont reflétées dans l'ensemble, et vice-versa. L'ensemble prend en charge la suppression d'éléments, [...]

(Notez qu'il ne fait pas mentionner le comportement indéfini!)

Habituellement, la modification de la carte tout en itérant sur le keySet lancerait un ConcurrentModificationException. Mais le ConcurrentHashMap est capable de faire face à cela. Il reste cohérent et peut encore être itéré, même si les résultats peuvent toujours être inattendus - comme dans votre cas.


En ce qui concerne la raison du comportement que vous avez observé:

A table de hachage (ou carte de hachage) fonctionne essentiellement en calculant une valeur de hachage à partir de la clé et en utilisant cette clé comme indicateur du "compartiment" auquel l'entrée doit être ajoutée. Lorsque plusieurs clés sont mappées sur le même compartiment, les entrées du compartiment sont généralement gérées comme une liste liée. Il en va de même pour le ConcurrentHashMap.

Le programme suivant utilise des hacks de réflexion désagréables pour imprimer l'état interne de la table - en particulier, les "compartiments" de la table, constitués de nœuds - pendant l'itération et la modification:

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

La sortie pour le cas runTestInfinite est la suivante (parties redondantes omises):

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

On peut voir que les entrées pour la clé 0 et la clé 4294967297 (qui est votre (1L << 32) + 1) se terminent toujours par le compartiment 0 et sont conservés en tant que liste liée. Ainsi, l'itération sur keySet commence par ce tableau:

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

Dans la première itération, il supprime la clé 0, transformant essentiellement la table en celle-ci:

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

Mais la clé 0 est immédiatement ajouté par la suite et se termine dans le même compartiment que le 4294967297 - il est donc ajouté à la fin de la liste:

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

(Ceci est indiqué par le next 0=0 partie de la sortie).

Dans l'itération suivante, le 4294967297 est supprimé et réinséré, remettant la table dans le même état qu'au départ.

Et c'est de là que vient votre boucle infinie.


Contrairement à cela, la sortie pour le cas runTestFinite est la suivante:

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

On voit que les touches 0 et 1 se retrouver dans différent seaux. Il n'y a donc pas de liste liée à laquelle les éléments supprimés (et ajoutés) pourraient être ajoutés, et la boucle se termine après avoir parcouru les éléments pertinents (c'est-à-dire les deux premiers compartiments) une fois.

15
Marco13

Je ne pense pas que cela ait quoi que ce soit à voir avec la sécurité des threads qu'offre ConcurrentHashMap. Cela ne ressemble même pas du tout à une impasse, mais à une boucle infinie.

Et cela est dû à la modification de la carte lors de l'itération sur le jeu de clés, qui est soutenu par la même carte!

Voici un extrait de la documentation de map.keySet() :

L'ensemble est soutenu par la carte, donc les modifications apportées à la carte sont reflétées dans l'ensemble, et vice-versa. Si la mappe est modifiée pendant qu'une itération sur l'ensemble est en cours (sauf via la propre opération de suppression de l'itérateur), les résultats de l'itération ne sont pas définis.

14
Kartik

Il n'y a pas d'impasse. Vous courez juste dans une boucle infinie. Lorsque j'exécute ce code (et que j'imprime key dans la boucle), la console affiche ceci à plusieurs reprises:

0
4294967297
0
4294967297
0
...

Si vous avez créé map une instance HashMap, vous verriez que le code déclenche une ConcurrentModificationException. Vous modifiez donc simplement la carte tout en parcourant ses clés, et ConcurrentHashMap ne lance pas d'exception de modification simultanée, rendant ainsi votre boucle sans fin.

14
ernest_k

La cause du cycle infini est une combinaison de

  1. Comment les entrées de carte sont-elles stockées en interne
  2. Comment fonctionne l'itérateur clé

1

Les entrées de carte sont stockées sous la forme d'un tableau de listes liées:
transient volatile Node<K,V>[] table
Chaque entrée de carte se retrouvera dans l'une des listes liées de ce tableau, en fonction de son hachage (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))
}

2 clés avec le même hachage (comme 0 et 4294967297) se retrouveront dans la même liste

2

Le travail d'un itérateur est assez simple: itérer les entrées une par une.
Étant donné que le stockage interne est essentiellement une collection de collections, il parcourt toutes les entrées de la liste table[0], Que table[1] Et ainsi de suite. Mais il y a un détail d'implémentation qui fait que notre exemple ne fonctionne pour toujours que pour les cartes avec des collisions de hachage:

public final K next() {
    Node<K,V> p;
     if ((p = next) == null)
         throw new NoSuchElementException();
     K k = p.key;
     lastReturned = p;
     advance();
     return k;
}

L'implémentation de la méthode next () renvoie une valeur qui a été pré-calculée avant et calcule la valeur à renvoyer lors d'une future invocation. Lorsque l'itérateur est instancié, il rassemble le 1er élément, lorsque next() est appelé la première fois, il rassemble le 2e élément et renvoie le 1er.
Voici le code pertinent de la méthode 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
        . . .
    }
}

Voici comment évolue l'état interne de notre carte:

Map<Long, Long> map = new ConcurrentHashMap<>();

[ null, null, ... , null ] Tous les compartiments (listes liées) sont vides

map.put(0L, 0L);

[ 0:0, null, ... , null ] Le premier compartiment a reçu une entrée

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

[ 0:0 -> 4294967297:0, null, ... , null ] Le premier compartiment a maintenant deux entrées

1ère itération, l'itérateur renvoie 0 Et contient l'entrée 4294967297:0 Sous la forme 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ème itération

map.remove(4294967297)

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

map.put(4294967297, 0)

[ 0:0 -> 4294967297:0, null, ... , null ]

Donc, après 2 itérations, nous sommes de retour lorsque nous avons commencé, car nos actions se résument à supprimer un élément de la tête d'une liste liée et à l'ajouter à sa queue, nous ne pouvons donc pas terminer de le consommer.
Il ne s'exécute pas dans une boucle infinie pour les cartes sans collisions de hachage car la liste liée à laquelle nous ajoutons a déjà été laissée par l'itérateur.
Voici un exemple qui le prouve:

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

La sortie est:
iteration:1 key:0 map size:5
iteration:2 key:1 map size:5

Tous les articles ajoutés à l'intérieur de la boucle se retrouvent dans le même seau - le premier - celui que notre itérateur a déjà consommé.

4
Bax

Il n'y a pas de blocage. Un blocage se produit lorsque deux threads (ou plus) se bloquent mutuellement. Évident , Vous n'avez qu'un seul thread principal ici.

2
tony