小白理解HashMap线程非安全

面试官总是喜欢问我这个小白HashMap为什么线程非安全,经过多次打击的的我下定决心要理解HahsMap线程非安全的原因。
经过多方面的了解,我了解到HashMap线程非安全在不同JDK版本有不同的体现:

  • JDK1.7:多个线程进行扩容时会出现链表死循环数据丢失问题
  • JDK1.8:数据被覆盖

下面结合源码详细了解其中的奥秘。

JDK1.7线程非安全

jdk1、7中线程非安全主要是因为在扩容函数 HashMap#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;
        }
    }
}

一定要看懂这段代码,对后面理解出现死循环,数据丢失原因很重要。从最后几行代码可以看出,扩容时是通过头插法将旧元素复制到新table里,头插法就是导致死循环的关键点。
下面分析下出现死循环,数据丢失的具体场景。

现有线程A和线程B同时对以下HashMap进行扩容操作:
在这里插入图片描述
正常扩容后的结果是下面这样的:
在这里插入图片描述
但是当线程A第一次执行到 HashMap#transfer() 函数的以下代码时,由于执行线程A的CPU时间片耗尽,需要将线程A挂起。
在这里插入图片描述
此时线程A的相关变量值为:e=3,next=7,e.next=null。
在这里插入图片描述
将线程A挂起后,CPU开始执行线程B,并在线程B中完成了整个扩容过程。
在这里插入图片描述
接下来就是重点了,根据Java内存模式可知,线程B执行完数据迁移后,此时主内存中newTable和table的数据都是最新的,也就是说:7.next=3、3.next=null。

随后线程A获得CPU时间片继续执行 newTable[i] = e 这行代码,将元素3放入新数组对应的位置,执行完这一轮循环后的线程A数据情况如下:
在这里插入图片描述

接着继续执行下一轮循环,此时e=7,从主内存中读取e.next时发现主内存中7.next=3,所以此时next=3,并将元素7采用头插法的方式放入数组中,并继续执行完此轮循环,结果如下:

在这里插入图片描述
此时没有任何问题

上一次循环执行的结果next=3,e=3,执行下一次循环可以发现,3.next=null,所以此轮循环将会是最后一轮循环。

接下来当执行到e.next=newTable[i]即3.next=7后,3和7之间就互相连接了,当执行完newTable[i]=e后,3被头插法重新插入到链表中,执行后的结果如下所示:
在这里插入图片描述
上面提到此轮循环是最后一次循环。所以线程A的扩容操作到此结束,很明显线程A扩容后的HashMap出现了环形结构,后面对该HashMap进行操作室会出现死循环。且从上图可知,元素5在扩容完成后莫名奇妙丢失了,这就发生了数据丢失问题。

JDK1.8线程非安全

上面提到的扩容期间死循环、数据丢失问题已在JDK1.8中解决了。如果你去阅读1.8的HashMap相关源码会发现找不到 HashMap#transfer() 这个导致出现死循环,数据丢失问题的方法。因为JDK1.8直接在HashMap#resize中完成了数据迁移。
与JDK1.7相比,JDK1.8导致HashMap线程非安全的的场景就没那么复杂了。主要是会出现数据覆盖的情况。
先来看下下面这段JDK1.8中的put操作代码:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

我们只需要分析该方法的第6行和第38行代码,主要是这两行代码会出现数据覆盖。
首先看下第6行代码:
在这里插入图片描述
该代码的作用是判断是否出现hash碰撞,如果不出现则直接进行插入。那么假设线程A和线程B同时执行putVal这个方法,当线程A执行到第六行代码,根据hash函数计算出下标,检测不会出现hash碰撞,准备执行直接插入逻辑时,cpu分配的时间片被消耗完,线程A被挂起。线程B得到时间片开始执行,执行到第六行代码时根据hash函数计算出与线程A一样的下标,由于线程A还未进行插入,所以线程B判断不会出现hash碰撞,于是直接执行正常插入逻辑。线程B执行完后时间片又分给了线程A,线程A之前已经判断过是否会有hash碰撞,此时不会再判断,而是直接执行插入逻辑,从而将线程B插入的数据覆盖掉。

我们再看下第38行的代码。
在这里插入图片描述
该行代码有个++size逻辑。同样还是线程A和线程B同时执行putVal这个方法。假设当前HashMap的size为10,当线程A执行到第38行代码时,从主内存中获取到size的值为10,准备进行+1操作,刚好这时间片被消耗完,线程A又被挂起了。线程B分配到了时间片,从主内存中获取到size为10并进行+1操作,完成了put操作并将size+1后的值写回主内存。然后时间片又分给了线程A继续刚才的操作,由于线程A获取的size为10,所以对size+1的值为11,并执行完put操作将size=11写入主内存。此时线程A和线程B都执行了putVal方法,但是size的值只增加了1,所以还是由于数据覆盖导致的线程不安全。

总结

HashMap线程不安全原因

  • JDK1.7
    某个线程执行扩容操作过程中被挂起,此时其他线程完成了扩容操作,该线程分配到CPU资源继续进行扩容操作时会出现死循环,数据丢失
  • JDK1.8:
    1、多个线程进行hash碰撞判断时,若根据hash函数计算的下标是相同,就会出现线程A检测不会发生碰撞,然后线程A被挂起,线程B检测不会发生碰撞,直接进行插入操作。线程A分配到CPU资源继续刚刚的逻辑执行,会将线程B的结果覆盖掉。
    2、对++size进行操作时,由于线程调度原因,线程A和线程B从主内存中获取相同size的值,然后进行+1,并写入主内存,就会出现线程A和线程B各执行一次,但size只增加1的问题。

参考链接

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值