多线程下使用HashMap究竟会发生什么,你真的明白么?

288 阅读5分钟

回顾

在JDK7中,HashMap::transfer在多线程情况下会产生死循环。而在JDK8中,HashMap做了较大的改版。transfer中的死锁被解决。而且Table的存储结构由7中的数组+链表改为了数组+链表/红黑树。

数组+链表
数组+红黑树

在JDK8中,HashMap会优先使用数组+链表存储。当Hash冲突达到8个时,会将整个Table转为数组+红黑树的形式来存储,以此提高Hash冲突时的查询效率。

JDK7下的死循环问题

HashMap 死循环主要发生在扩容时。发生死锁的核心代码如下,为方便分析省略了部分代码,在重要代码上标记了行数,方便后面分析。

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) { // 语句1
            Entry<K,V> next = e.next;  // 语句2
            int i = indexFor(e.hash, newCapacity);  
            e.next = newTable[i];  // 语句3
            newTable[i] = e;  // 语句4
            e = next;  // 语句5
        }
    }
}

先说结论。死循环发生后,CPU飙升,而内存中的数据可能如下:

死循环-1
具体是如何造成的呢?来,一步一步分析;假设内存中原数据如下
原数据
此时HashMap发生扩容,而且~ 好巧不巧,扩容前后,元素a和b在新表中的index也是一样的。同时有两个线程1, 2进入该段代码。线程1先执行,一切如常,直到线程1执行完毕准备退出transfer方法。此时线程1中的数据应该是
线程1的数据结构
此时由于JMM的原因,所有对于原表数据(a,b)的操作都未刷入主存储,简单来说就是线程2内存中并看不到线程1对原数据的修改。线程2中的看到的原表数据还是这样
线程2-旧数据
线程2开始执行。

第一次循环,开始! e 指向 a 元素,next指向a.next。注意,此时a.next仍然为b,因为线程1的修改对线程2不可见。因此,next指向b。在语句5执行后内存中数据如下

线程2第一次循环结束后
此时,注意!不管什么原因,线程1的数据刷新入了主存,而且!线程2感知到了主存中的数据修改,并将其加载至当前线程的执行内存(可能术语不是那么专业,不过意思应该到了)。这时,内存中的数据应该如下
内存同步后的数据结构
线程2第二次循环,开始! e指向b元素,next指向a元素,b.next指向a,并将b元素放在newTable[i]。此时,内存中的数据结构应该是
线程2第二次循环结束后
然后重头戏来了。线程2第三次循环,开始! e指向a元素,next为null,a.next指向b元素,并将a元素放在newTable[i]中。此时,内存中的数据结构应该是
线程2第三次循环结束后
此时虽然扩容已经完成了。但是当再次使用到该元素时,一个死循环就出现了。CPU重复着无尽头的运算,这就是CPU飙升的原因了。

JDK8的改进

在JDK8中,对7中的死循环做了改进,我对核心代码做了精简,方便理解

private Node<K, V>[] resize() {
    // .... some operation
    
    Node<K,V> loHead = null, loTail = null;
    Node<K,V> next;
    do {
        next = e.next;
        if ((e.hash & oldCap) == 0) {
            if (loTail == null)
                loHead = e;
            else
                loTail.next = e;
            loTail = e;
        }
    } while ((e = next) != null);
    if (loTail != null) {
        loTail.next = null;
        newTab[j] = loHead;
    }
    
    // .... some operation
    return newTab;
}

7 中死锁的本质原因是由于对链表的倒序重排,JDK8中取消了这种操作。因此在多线程情况下,这段代码造成的最坏影响是丢失数据,而不再是死循环了。

JDK8下的死循环问题

即使JDK8对HashMap做了优化,但是在多线程下还是会产生死循环。参考了HashMap在1.8中也会死循环的测试代码和堆栈信息,我们来开始分析。 死循环发生在HashMap::TreeNode::root方法中,其代码如下

final TreeNode<K,V> root() {
    for (TreeNode<K,V> r = this, p;;) {
        if ((p = r.parent) == null)
            return r;
        r = p;
    }
}

我们可大致推出其内存中的数据结构应该如下

死循环-2
大胆猜测一下,应该发生在红黑树旋转的时候。因为在旋转时,子节点才会和父节点调换位置。找到核心代码如下

static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root, TreeNode<K,V> p) {
    TreeNode<K,V> l, pp, lr;
    if (p != null && (l = p.left) != null) { // 语句1
        if ((lr = p.left = l.right) != null)  // 语句2
            lr.parent = p;  // 语句3
        if ((pp = l.parent = p.parent) == null) // 语句4
            (root = l).red = false;
        else if (pp.right == p)
            pp.right = l;
        else
            pp.left = l;
        l.right = p;  语句5
        p.parent = l;
    }
    return root;
}

这段代码处理了四种场景下的子树右旋转,详情如下,这里我们先忽略变色,专注于数据的变换。

右平衡旋转

此时,同样当两个线程同时开始右旋转时,假设均为场景2。 内存中数据此时为(未标注的均假设为null)

原始数据
线程1执行到语句5时挂起,此时内存的数据,且其内容已被同步至其它线程内存中。
线程1语句5
线程2开始旋转,此时,线程2看到的树则是符合场景1的。
线程2看到的数据
开始旋转。执行语句1。即,p的左子树指向了l,l的右子树指向了p,p的parent指向了l,l的parent指向了p。
线程2语句1
然后执行语句4,数据就变成了
线程2语句4
然后线程1,2继续开始执行,l和p元素的父节点同时指向对方。便是死循环了。
数据死循环

扩展

JDK8中对于数据寻址做了简化,且在resize时位运算做的非常巧妙,非常值得一看。

左旋转核心代码如下

static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root, TreeNode<K,V> p) {
    TreeNode<K,V> r, pp, rl;
    if (p != null && (r = p.right) != null) {
        if ((rl = p.right = r.left) != null)
            rl.parent = p;
        if ((pp = r.parent = p.parent) == null)
            (root = r).red = false;
        else if (pp.left == p)
            pp.left = r;
        else
            pp.right = r;
        r.left = p;
        p.parent = r;
    }
    return root;
}

这段代码处理了四种情况下的子树左旋转,详情如下

左平衡旋转