HashMap 中的死循环

笔记摘录自疫苗:JAVA HASHMAP的死循环

1.存在的问题

  • 单线程的程序使用 HashMap 没有问题,但是由于程序性能的需求,程序由单线程变为多线程,这时使用 HashMap 就可能会产生问题。
  • 程序经常占了100%的CPU,查看堆栈,你会发现程序都 Hang 在了 HashMap.get() 这个方法上了,重启程序后问题消失。但是过段时间又会来。而且,这个问题在测试环境里可能很难重现。
  • Java的文档说HashMap是非线程安全的,应该用ConcurrentHashMap。

2. HashMap 的结构

  • HashMap 通常会用一个指针数组(假设为 table[])来做分散所有的 key
  • 当一个 key 被加入时,会通过 Hash 算法通过 key 算出这个数组的下标 i,然后就把这个<key, value>插到 table[i] 中
  • 如果有两个不同的 key 被算在了同一个 i,那么就叫冲突,又叫碰撞,这样会在 table[i] 上形成一个链表。
  • 如果 table[] 的尺寸很小,那么碰撞非常频繁,于是一个O(1)的查找算法,就变成了链表遍历,性能变成了O(n),这是Hash表的缺陷
  • 所以,Hash 表的尺寸和容量非常的重要。一般来说,Hash 表这个容器当有数据要插入时,都会检查容量有没有超过设定的 thredhold,如果超过,需要增大 Hash 表的尺寸,但是这样一来,整个Hash表里的无素都需要被重算一遍。这叫 rehash,这个成本相当的大

3.rehash

3.1关键源码

//========================= put ====================================
public V put(K key, V value)
{
    ......
    //算Hash值
    int hash = hash(key.hashCode());
    int i = indexFor(hash, table.length);
    //如果该key已被插入,则替换掉旧的value (链接操作)
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    //该key不存在,需要增加一个结点
    addEntry(hash, key, value, i);
    return null;
}
//============================== addEntry =============================
void addEntry(int hash, K key, V value, int bucketIndex)
{
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    //查看当前的size是否超过了我们设定的阈值threshold,如果超过,需要resize
    if (size++ >= threshold)
        resize(2 * table.length);
} 
//============================== resize ===============================
void resize(int newCapacity)
{
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ......
    //创建一个新的Hash Table
    Entry[] newTable = new Entry[newCapacity];
    //将Old Hash Table上的数据迁移到New Hash Table上
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}
//============================= transfer ===============================
void transfer(Entry[] newTable)
{
    Entry[] src = table;
    int newCapacity = newTable.length;
    //下面这段代码的意思是:
    //  从OldTable里摘一个元素出来,然后放到NewTable中
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
} 

3.2 正常 rehash

  • 假设了 hash算法就是简单的用 key mod 一下表的大小(也就是数组的长度)
  • 最上面的是old hash 表,其中的Hash表的size=2
  • 接下来的三个步骤是Hash表 resize成4,然后所有的<key,value> 重新rehash的过程
    在这里插入图片描述

3.3 并发下的 rehash

假设有两个线程,用红色和浅蓝色标注了一下。在 transfer 方法中:

do {
    Entry<K,V> next = e.next; // <--假设线程一执行到这里就被调度挂起了,线程二正常执行完毕
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
} while (e != null);
  1. 此时状态如下图:因为Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。我们可以看到链表的顺序被反转后。
    在这里插入图片描述
  2. 线程一被调度回来执行
    • 先是执行 newTalbe[i] = e;
    • 然后是e = next,导致了e指向了key(7),
    • 而下一次循环的next = e.next导致了next指向了key(3)
      在这里插入图片描述
  3. 线程一接着工作。把key(7)摘下来,放到newTable[i]的第一个,然后把e和next往下移。
    在这里插入图片描述
  4. e.next = newTable[i] 导致 key(3).next 指向了 key(7)。注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。
    在这里插入图片描述
  5. 我们的线程一调用到,HashTable.get(11)时,悲剧就出现了——Infinite Loop。

4. 后记

  • 在Java7和7之前,map里的链表在transfer或者说resize之后顺序会发生倒转,在把链表搬运到新数组中的时候,如果多个线程并发,就会出现问题,会导致链表的循环引用,变成了一个有环的链表。然后在map的get操作中,会遍历链表,因为有环,所以一直不会遍历完,就出现了死循环。
  • 有人把这个问题报给了 Sun,不过 Sun 不认为这个是一个问题。因为 HashMap 本来就不支持并发。要并发就用 ConcurrentHashmap
  • 不过,在Java8中,还是对扩容进行了优化,如果是链表,就不会倒转链表了,避免了循环引用的问题,就不会出现死循环了。
  • 归根结底,原因就是1.7链表新节点采用的是头插法,这样在线程一扩容迁移元素时,会将元素顺序改变,导致两个线程中出现元素的相互指向而形成循环链表,1.8采用了尾插法,从根源上杜绝了这种情况的发生
©️2020 CSDN 皮肤主题: 数字20 设计师:CSDN官方博客 返回首页