【问题标题】:Java HashMap.get(Object) infinite loopJava HashMap.get(Object) 无限循环
【发布时间】:2016-02-21 10:22:07
【问题描述】:

关于 SO 的一些答案提到,如果未正确同步(通常底线是“不要使用 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 指令重新排序有关(所以问题只能发生在多处理器机器上)?

【问题讨论】:

  • 你真的可以编译它并让它永远运行吗?似乎异常会比无限循环抛出更多
  • 为什么不使用AtomicReference“锁定”您的地图?您将得到其余的非线程安全问题。
  • 这个练习毫无意义。 HashMap 不是线程安全的,并且在另一个线程写入时从中取出对象,即使它从未进入无限循环,也可能返回错误结果、破坏 HashMap、引发异常或其他任何事情。你为什么要让这一切发生?只需同步 get 方法:有必要使代码线程安全。
  • @DavidHaim 我没有尝试运行它:我很想知道一个线程对“put”的调用和另一个线程对“get”的同时调用是否属实可以导致无限循环,如果是真的,我想知道它是怎么可能的:例如两个线程中的指令必须如何交错?这会发生在什么架构上?通过查看 openjdk 中的实现,我看不出如何以可能导致无限循环的方式将此 Java 代码转换为机器指令。谁能解释一下?
  • @JBNizet 这不是“练习”,而是一个精确的问题:在某些架构上的某些 jdk 实现下,对“get”方法的调用是否会陷入无限循环,或者这是不可能的?我提到的答案声称这可能发生,但没有解释如何发生。我很清楚,当共享对象可以被另一个线程修改时,不应该读取它(这意味着:始终同步对可修改共享对象的访问,或者使它们成为原子的)。我只是想了解为什么人们声称这会导致无限循环,为什么没有意义?理解永远没有意义。

标签: java multithreading concurrency hashmap


【解决方案1】:

您的链接适用于 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 中,调整大小保持节点顺序,因此不会以这种方式发生循环。但您可能会丢失数据。

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

【讨论】:

  • 地图会如何丢失数据?
【解决方案2】:

情况:

HashMap 的默认容量为 16,Load factor 为 0.75,即当第 12 个 Key-Value 对进入 map(16 * 0.75 = 12)时,HashMap 的容量会翻倍。

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

线程 1 获得执行机会:

  1. 线程 1 尝试放入第 12 个键值对,
  2. 线程 1 发现已达到阈值限制,它创建了容量增加的新存储桶。所以地图的容量从 16 增加到 32。
  3. 线程 1 现在将所有现有的键值对转移到新的存储桶中。
  4. 线程 1 指向第一个键值对和下一个(第二个)键值对以开始传输过程。

线程 1 在指向键值对之后,在开始传输过程之前,松开控制,线程 2 有机会执行。

线程 2 获得执行机会:

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

线程 1 获得执行机会:

  1. 离开控制之前的线程 1 指向旧存储桶的第一个元素和下一个元素。
  2. 现在,当线程 1 开始将键值对从旧存储桶放入新存储桶时。它成功地将 (90, val) 和 (1, val) 放入新的 Bucket。
  3. 当它试图将 (1, val) 的下一个元素 (90, val) 添加到新的 Bucket 中时,它将陷入无限循环。

解决方案:

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

ConcurrentHashMap 是线程安全的,即代码一次可以被单个线程访问。

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

【讨论】:

  • 这描述的是哪个版本的 HashMap? Java8中的重写版本?我很好奇 Java8+ 中的 HashMap 是否仍然会发生无限循环。
【解决方案3】:

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

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

如果只有一个线程在修改地图,我相信只有一个线程是不可能无限循环的。在 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:不过我很乐意被证明是错误的!

【讨论】:

    猜你喜欢
    • 2016-05-13
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-08-29
    • 2012-01-24
    • 2013-04-06
    • 2011-01-06
    • 2020-02-09
    相关资源
    最近更新 更多