【Java】集合类 - Map - HashMap的并发问题

Posted by 西维蜀黍 on 2019-03-14, Last Modified on 2023-08-27

背景

由于HashMap不是线程安全的,因此在并发环境下可能会发生死锁问题,且导致CPU占用率接近100%。

原因是在Java语言在并发情况下使用HashMap造成Race Condition,从而导致死循环。

可以搜索“HashMap Infinite Loop”以深入了解这个问题。

分析

put()方法的执行过程

该问题的成因涉及到四个方法,最初的起因是调用put()方法。

下面,我们来看一下Java的HashMap的源代码。

调用put() 方法一讲一个<Key,Value>添加到HashMap表中:

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

检查容量是否超标

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

新建一个更大尺寸的hash表,然后把数据从老的Hash表中迁移到新的Hash表中。

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

将数据从老的Hash表中迁移到新的Hash表的过程:

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

正常的ReHash的过程

  • 我假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。
  • 最上面的是old hash 表,其中的Hash表的size=2, 所以key = 3, 7, 5,在mod 2以后都冲突在table[1]这里了。
  • 接下来的三个步骤是Hash表 resize成4,然后所有的<key,value> 重新rehash的过程

并发下的Rehash

假设我们有两个线程,在下图中分别用红色和浅蓝色标注。

同时,假设线程一执行到下面代码位置就被调度器挂起了,而轮到线程二执行。

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

线程一被挂起时,处于以下状态:

而线程二开始执行了,执行完了整个rehash过程,此时状态如下图所示:

值得一提的是,这时,key为7的next指针指向了key为3的元素。


这时,又轮到线程一执行了(线程一被调度回来),紧接着执行循环体中的下面三句代码:

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

此时的状态如下所示:

这时候一切正常。


线程一继续执行下一个循环:

当执行完 Entry<K,V> next = e.next; 后:


继续执行下面代码:

int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;

执行完成后,为此状态:


线程一继续仍然再执行下一个循环:

    Entry<K,V> next = e.next;
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;

执行完成 Entry<K,V> next = e.next; 后,如下情况:

接下来,执行 e.next = newTable[i]; 后会导致下面的情况:

而执行完 newTable[i] = e;e = next; 后,则会形成环,如下所示:


此后,由于e为null,因此跳出循环。

当调用到hashMap.get()方法,且进入索引为3的Bucket,而且所提供的key不在索引为3的Bucket中所包含的所有元素的key范围内时(比如调用hashMap.get(11)),则会进入无限循环(infinite loop)。

Reference