web-dev-qa-db-fra.com

Boucle infinie Java HashMap.get (Object)

Quelques réponses sur SO indiquent que la méthode get dans un HashMap peut tomber dans une boucle infinie (par exemple, celle-ci ou celle-ci ) si elle n'est pas synchronisée correctement (et généralement la partie inférieure). La ligne est "n'utilisez pas de HashMap dans un environnement multi-thread, utilisez un ConcurrentHashMap").

Bien que je puisse facilement voir pourquoi des appels simultanés à la méthode HashMap.put (Object) peuvent provoquer une boucle infinie, je ne comprends pas très bien pourquoi la méthode get (Object) peut rester bloquée lorsqu’elle tente de lire un HashMap en cours de redimensionnement à ce moment précis. J'ai jeté un œil à l'implémentation de openjdk et elle contient un cycle, mais la condition de sortie e != null devrait être remplie tôt ou tard. Comment peut-il boucler pour toujours? Un élément de code mentionné explicitement comme vulnérable à ce problème est le suivant:

public class MyCache {
    private Map<String,Object> map = new HashMap<String,Object>();

    public synchronized void put(String key, Object value){
        map.put(key,value);
    }

    public Object get(String key){
        // can cause in an infinite loop in some JDKs!!
        return map.get(key);
    }
}

Quelqu'un peut-il expliquer comment un thread qui insère un objet dans HashMap et en lit une autre lecture peut s'entrelacer de manière à générer une boucle infinie? S'agit-il d'un problème de cohérence de la mémoire cache ou d'un réarrangement des instructions de l'UC (le problème ne peut donc se produire que sur une machine multiprocesseur)?

19
UndefinedBehavior

Votre lien concerne HashMap en Java 6. Il a été réécrit en Java 8. Avant cette réécriture, une boucle infinie sur get(Object) était possible s'il y avait deux threads en écriture. Je ne suis pas au courant d'une manière dont la boucle infinie sur get peut se produire avec un seul écrivain.

Plus précisément, la boucle infinie se produit quand il y a deux appels simultanés à resize(int) qui appelle transfer :

 void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
         while(null != e) {
             Entry<K,V> next = e.next;
             if (rehash) {
                 e.hash = null == e.key ? 0 : hash(e.key);
             }
             int i = indexFor(e.hash, newCapacity);
             e.next = newTable[i];
             newTable[i] = e;
             e = next;
         }
     }
 }

Cette logique inverse le classement des noeuds dans le compartiment de hachage. Deux inversions simultanées peuvent faire une boucle.

Regarder:

             e.next = newTable[i];
             newTable[i] = e;

Si deux threads traitent le même nœud e, le premier thread s'exécute normalement, mais le second thread définit e.next = e, car newTable[i] a déjà été défini sur e par le premier thread. Le noeud e pointe maintenant sur lui-même et, lorsque get(Object) est appelé, il entre dans une boucle infinie.

En Java 8, le redimensionnement conserve l'ordre du noeud afin qu'une boucle ne puisse pas se produire de cette manière. Vous pouvez cependant perdre des données.

Les itérateurs de la classe LinkedHashMap peuvent rester bloqués dans une boucle infinie lorsqu'il y a plusieurs lecteurs et aucun rédacteur lorsque la commande d'accès est conservée. Avec plusieurs lecteurs et un ordre d'accès, chaque lecture supprime puis insère le nœud auquel on a accédé à partir d'une liste de nœuds double liée. Plusieurs lecteurs peuvent amener le même nœud à être réinséré plusieurs fois dans la liste, provoquant une boucle. Encore une fois, le cours a été réécrit pour Java 8 et je ne sais pas si ce problème existe toujours ou non.

10
Simon G.

Situation:

La capacité par défaut de HashMap est 16 et le facteur de charge est de 0,75, ce qui signifie que HashMap doublera sa capacité lorsque la 12ème paire clé-valeur entre dans la carte (16 * 0,75 = 12).

Lorsque 2 threads tentent d'accéder simultanément à HashMap, vous risquez de rencontrer une boucle infinie. Les threads 1 et 2 tentent de mettre la 12ème paire clé-valeur.

Le fil 1 a une chance d'exécution:

  1. Le fil 1 essaie de mettre la 12ème paire clé-valeur,
  2. Le thread 1 constate que la limite de seuil est atteinte et crée de nouveaux compartiments de capacité accrue. La capacité de la carte est donc passée de 16 à 32.
  3. Le fil 1 transfère désormais toutes les paires clé-valeur existantes vers de nouveaux compartiments.
  4. Le fil 1 pointe vers la première paire clé-valeur et la prochaine (deuxième) paire clé-valeur pour démarrer le processus de transfert.

Thread 1 après avoir pointé sur les paires clé-valeur et avant de commencer le processus de transfert, perdez le contrôle et Thread 2 a eu une chance d'être exécuté.

Le fil 2 a une chance d'exécution:

  1. Le fil 2 essaie de mettre la 12ème paire clé-valeur,
  2. Le thread 2 établit que la limite de seuil est atteinte et crée de nouveaux compartiments de capacité accrue. La capacité de la carte est donc passée de 16 à 32.
  3. Le fil 2 transfère désormais toutes les paires clé-valeur existantes vers de nouveaux compartiments.
  4. Le fil 2 pointe vers la première paire clé-valeur et la prochaine (deuxième) paire clé-valeur pour démarrer le processus de transfert.
  5. Lors du transfert de paires clé-valeur d'anciens compartiments vers de nouveaux compartiments, les paires clé-valeur seront inversées dans les nouveaux compartiments, car hashmap ajoutera des paires clé-valeur au début et non à la fin. Hashmap ajoute de nouvelles paires clé-valeur au début pour éviter de parcourir la liste chaînée à chaque fois et de maintenir des performances constantes.
  6. Thread 2 transférera toutes les paires clé-valeur d'anciens compartiments vers de nouveaux compartiments et Thread 1 aura une chance d'être exécuté.

Le fil 1 a une chance d'exécution:

  1. Le thread 1 avant de quitter le contrôle pointait sur le premier élément et sur le prochain élément de l'ancien compartiment.
  2. Maintenant, lorsque Thread 1 a commencé à mettre des paires clé-valeur d'un ancien compartiment à un nouveau compartiment. Il met avec succès (90, val) et (1, val) dans un nouveau seau.
  3. Quand il essaie d'ajouter l'élément suivant de (1, val) qui est (90, val) dans le nouveau Bucket, il se termine en boucle infinie.

Solution:

Pour résoudre ce problème, utilisez un Collections.synchronizedMap ou ConcurrentHashMap.

ConcurrentHashMap est un thread-safe, c'est-à-dire que le code est accessible par un seul thread à la fois.

HashMap peut être synchronisé à l'aide de la méthode Collections.synchronizedMap(hashMap). En utilisant cette méthode, nous obtenons un objet HashMap équivalent à l’objet HashTable. Ainsi, chaque modification est effectuée sur la carte est verrouillé sur l'objet de la carte.

3
Avijit Karmakar

Étant donné que la seule possibilité que je vois pour une boucle infinie serait e.next = e dans la méthode get:

for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next)

Et cela ne pouvait se produire que dans la méthode transfer lors d'un redimensionnement:

 do {
     Entry<K,V> next = e.next;
     int i = indexFor(e.hash, newCapacity);
     e.next = newTable[i]; //here e.next could point on e if the table is modified by another thread
     newTable[i] = e;
     e = next;
 } while (e != null);

Si un seul thread modifie la carte, je pense qu'il est tout à fait impossible d'avoir une boucle infinie avec un seul thread. C’était plus évident avec l’ancienne implémentation de get avant le jdk 6 (ou 5):

public Object get(Object key) {
        Object k = maskNull(key);
        int hash = hash(k);
        int i = indexFor(hash, table.length);
        Entry e = table[i]; 
        while (true) {
            if (e == null)
                return e;
            if (e.hash == hash && eq(k, e.key)) 
                return e.value;
            e = e.next;
        }
    }

Même dans ce cas, le cas semble toujours assez improbable, sauf s’il ya beaucoup de collisions.

P.S: J'aimerais bien avoir tort!

1
Adonis