Java HashMap.get(Object)无限循环

时间:2016-02-21 10:22:07

标签: java multithreading concurrency hashmap

关于SO的一些答案提到HashMap中的get方法可能会落入无限循环(例如this onethis one),如果没有正确同步(通常底线为&#34) ;不要在多线程环境中使用HashMap,使用ConcurrentHashMap")。

虽然我可以很容易地看到为什么对HashMap.put(Object)方法的并发调用会导致无限循环,但我不能完全理解为什么get(Object)方法在尝试读取时会卡住正在调整大小的HashMap。我查看了implementation in openjdk并且它包含一个循环,但退出条件e != null迟早应该完成。它怎么能永远循环? 明确提到易受此问题影响的一段代码是:

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

有人可以解释一个线程如何将一个对象放入HashMap,另一个读取它是否可以交错以产生无限循环?它是否与某些缓存一致性问题或CPU指令重新排序有关(所以问题只能在多处理器机器上发生)?

3 个答案:

答案 0 :(得分:11)

你链接用于Java 6中的HashMap。它在Java 8中重写。在重写之前,如果有两个写入线程,则get(Object)上的无限循环是可能的。我不知道单个编写器可以在get上发生无限循环的方式。

具体来说,当有两个同时调用resize(int)来调用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;
         }
     }
 }

此逻辑反转了哈希桶中节点的顺序。两个同时反转可以形成循环。

看看:

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

如果两个线程正在处理同一个节点e,则第一个线程正常执行,但第二个线程设置e.next = e,因为newTable[i]已经设置为e第一个主题。节点e现在指向自身,当调用get(Object)时,它进入无限循环。

在Java 8中,resize维护节点排序,因此不能以这种方式发生循环。你可以丢失数据。

当有多个读取器时,LinkedHashMap类的迭代器可能陷入无限循环,而在维护访问顺序时没有写入器。使用多个读取器和访问顺序,每个读取都会删除,然后从双链接的节点列表中插入所访问的节点。多个读取器可能导致同一节点多次重新插入到列表中,从而导致循环。该类已经重写为Java 8,我不知道这个问题是否仍然存在。

答案 1 :(得分:3)

<强>情况:

HashMap的默认容量为16,加载因子为0.75,这意味着当第12个键值对在地图中输入时,HashMap的容量会翻倍(16 * 0.75 = 12)。

当2个线程同时尝试访问HashMap时,您可能会遇到无限循环。线程1和线程2尝试放置第12个键值对。

线程1获得执行机会:

  1. 线程1尝试输入第12个键值对
  2. 线程1发现达到阈值限制并创建新容量增加的桶。因此,地图的容量从16增加到32。
  3. 线程1现在将所有现有键值对传输到新存储桶。
  4. 线程1指向第一个键值对和下一个(第二个)键值对以开始传输过程。
  5. 线程1指向键值对之后,在开始传输过程之前,松开控件,线程2有机会执行。

    线程2获得执行机会:

    1. 线程2尝试输入第12个键值对
    2. 线程2发现达到阈值限制并创建新容量增加的桶。因此,地图的容量从16增加到32。
    3. 线程2现在将所有现有键值对传输到新存储桶。
    4. 线程2指向第一个键值对和下一个(第二个)键值对以开始传输过程。
    5. 在将旧存储桶中的键值对传输到新存储桶时,键值对将在新存储桶中反转,因为hashmap将在开始时而不是在结尾处添加键值对。 Hashmap在开始时添加新的键值对,以避免每次遍历链表并保持持续的性能。
    6. 线程2会将旧存储桶中的所有键值对传输到新存储桶,线程1将有机会执行。
    7. 线程1获得执行机会:

      1. 离开控制之前的线程1指向第一个元素和旧桶的下一个元素。
      2. 现在,当线程1开始将旧桶中的键值对放入新桶时。它成功地将(90,val)和(1,val)放入新的Bucket中。
      3. 当它试图将(1,val)的下一个元素(90,val)添加到新的Bucket中时,它将以无限循环结束。
      4. <强>解决方案:

        要解决此问题,请使用Collections.synchronizedMapConcurrentHashMap

        ConcurrentHashMap 是线程安全的,即一次只能由单个线程访问代码。

        可以使用Collections.synchronizedMap(hashMap)方法同步HashMap。通过使用此方法,我们得到一个HashMap对象,它等同于HashTable对象。所以每次修改都是在地图上锁定的地图对象上执行的。

答案 2 :(得分:1)

鉴于e.next = e方法中我看到无限循环的唯一可能性是get

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

这只能在调整大小期间transfer方法中发生:

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

如果只有一个线程正在修改Map,我相信只用一个线程就不可能有一个无限循环。在jdk 6(或5)之前使用get的旧实现更为明显:

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

即便如此,除非发生很多碰撞,否则案件似乎仍然不太可能。

P.S:我很想被证明是错的!