HashMap 之线程不安全

问:简单说说 HashMap 为什么是线程不安全的?具体体现在哪些方面?

答:对于 JDK1.7 和 JDK1.8 的 HashMap 中迭代器的 fail-fast 策略导致了并发不安全,即如果在使用迭代器的过程中有其他线程修改了 HashMap 就会抛出 ConcurrentModificationException 异常(fail-fast 策略)。

对于 JDK1.7 的 HashMap 并发 put 操作触发扩容导致潜在可能的死循环现象,而 JDK1.8 的 HashMap 并发 put 操作不会导致潜在的死循环。对于 JDK1.7 来说哈希冲突的链表结构在扩容前后会进行一次逆向首尾对调操作,而对于 JDK1.8 来说扩容前后链表顺序性不变,所以对于 JDK1.7 扩容的核心代码如下:

    void transfer(HashMapEntry[] newTable) {
        //扩容新数组的容量
        int newCapacity = newTable.length;
        // 遍历旧数组index元素
        for (HashMapEntry<K, V> e : table) {
            // 对应index数组位置上哈希冲突的链表元素逆向颠倒
            while (null != e) {
                HashMapEntry<K, V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

接着我们模拟并发 put 操作(为了简单我们假设扩容后链表元素在数组的 index 位置不变),即同一时刻有 Thread-1、Thread-2 进行 put 操作且这次 put 操作恰巧触发扩容操作,假设 Thread-1 线程的操作执行到如图所示语句:

《HashMap 之线程不安全》

即 Thread-1 进行 put 操作触发了扩容,但是这时候仅仅是准备好了新容量的数组然后进入上面 transfer 方法的 HashMapEntry<K,V> next = e.next; 语句,即此时 e=keyHash(9),next=keyHash(1),然后由于并发导致此时 Thread-1 线程被挂起;此时 Thread-2 线程也在进行 put 操作,假设 Thread-2 线程很顺利的抢占到资源顺利的执行完了 transfer 方法,即如下图所示:

《HashMap 之线程不安全》

紧接着当 Thread-2 执行完上面时间片段后假设被挂起的 Thread-1 线程得到了执行机会,这时候尴尬的事情就发生了,我们看下 transfer 方法的交换核心代码:

    //对于哈希冲突的index上链表进行逆向 
        while (null != e) {
            HashMapEntry<K, V> next = e.next;
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }

Thread-1 在第一圈 while 循环时 e 和 next 都是之前挂起时的指向,即 e=keyHash(9)、next=keyHash(1),接着被唤醒后重新计算 index 假设还是 1,然后 e.next =keyHash(1),因为此时 newTable 已经被 Thread-2 进行过扩容重放操作了,然后 newTable[1]=keyHash(9),然后进行循环操作,如下图:

《HashMap 之线程不安全》

可以看见,当 Thread-1 与 Thread-2 同时进行 put 操作触发扩容出现上面所示场景情况时,就可能在扩容后导致链表出现环形,因此当我们接着进行 put 或者 get 操作恰巧又在这个 index 位置时就会出现死循环,源代码如下:

    public V put(K key, V value) {
        ......
        int i = indexFor(hash, table.length);
        //此循环中 e=e.next 由于前面并发扩容导致的循环链表永远不为null
        for (HashMapEntry<K, V> e = table[i]; e != null; e = e.next) {
            ......
        }
        ......
    }

    public V get(Object key) { ......Entry<K, V> entry = getEntry(key);
        return null == entry ? null : entry.getValue();
    }

    final Entry<K, V> getEntry(Object key) {
        ......
        //此循环中 e=e.next 由于前面并发扩容导致的循环链表永远不为null
        for (HashMapEntry<K, V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) { 
            ......
        } return null;
    }

所以在 JDK1.7 中并发扩容操作可能会导致哈希碰撞的链表结构为循环链表,从而导致在后续 put、get 操作时发生死循环。而对于 JDK1.8 中扩容链表的顺序是不会发生逆向的,所以自然怎么遍历都不会出现循环链表的情况,故 JDK1.8 中不会出现并发循环链表,但由于 JDK1.7 与 JDK1.8 中都是无锁保护的,所以依然是并发不安全的。

    原文作者:Little丶Jerry
    原文地址: https://www.jianshu.com/p/5d24b962904b
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞