HashMap线程不安全详细讲解

基于JDK1.7与JDK1.8HashMap线程安全问题

前言
通过学习HashMap,我们可以总结:HashMap是线程不安全的,而HashTable是线程安全的。那么,为何HashMap是线程不安全的呢?本篇文章通过JDK1.7和JDK1.8分别讲解为何HashMap是线程不安全的。

一.JDK1.7分析HashMap线程不安全

首先,先来看一下JDK1.7中HashMap的相关源码,只有通过源码,我们才能找出其中线程不安全的地方。
1.底层数据结构只数组:

//空的存储实体  
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

通过JDK1.7HashMap的源码可知,HashMap底层是使用了Entry类型的数组来存储变量。其中的transient表示HashMap是不需要序列化标识。
Entry是HashMap的一个内部类,里面存储的是一个键值对,如,Entry<K,V>。因此这是一个存储键值对的类型的数组。

2.Entry<K,V>源码:

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
        int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
 }

通过源码分析,HashMap除了有数组外,还有Entry类型的链表,Entry<K,V> next,每一个Entry块是一个节点,数据结构如下:
在这里插入图片描述
JDK1.7中HashMap的数据结构:数组+链表

那么为何HashMap使用这样的数据结构呢?

首先,我们知道数组在数据查询的效率上比链表快,而链表在增删元素的效率上比数组快,所以HashMap整合了这两种数据结构,数组用于存放key在散列表上的索引(位置),链表用来存放元素的具体空间。

注意
HashMap规定put进去的key值是不同的,而通过计算key的hashcode,可能出现相同的值。因此,不同的key通过hashcoed计算可能出现相同的索引,也就是散列表上相同的位置。这时,我们就可以使用链表存储这些元素,如果一个key只存放在一个数组中,这是最理想的;如果,同一个数组(索引位置)出现了多个相同的key,那么,我们就将这些元素存在同一个数组后对应的Entry中,每出现一个相同的key,就添加一个新节点,逐渐形成了像链表一样的存储空间。

为何数组查询效率比链表快,而增删比链表慢?
查询:
数组中,数据在内存中是连续的,成块的。我们可以根据数组的首地址+偏移量(数组下标)直接计算对应位置的元素,所以查询快
链表中,数据在内存中不是连续的一段空间,它的结构是「元素|下一个元素地址],当我们想要查找对应位置的元素时,它只能从首元素开始,依次获取下一个元素的地址,所以查询慢。
增删:
数组:由于数据在内存中是连续的,就要移动对应元素后面的所有元素,即,每增删一次,所有对应位置后面的元素都需要向前或向后移动,因此增删效率低。
链表:在添加元素时,只要将此元素位置的前一元素和后一元素关联到此元素,删除元素时,只要把要删除元素的前一元素和后一元素的关联断掉即可,不会影响其他元素,因此增删效率快。

3.put源码:

public V put(K key, V value) {
        //如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(=16)
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);//分配数组空间
        }
       //如果key为null,存储位置为table[0]或table[0]的冲突链上
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
        int i = indexFor(hash, table.length);//获取在table中的实际位置
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        //如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);//调用value的回调函数,其实这个函数也为空实现
                return oldValue;
            }
        }
        modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
        addEntry(hash, key, value, i);//新增一个entry
        return null;
    }

此步骤可能出现线程不安全

假设有两个线程A和B,当线程A执行int i = indexFor(hash, table.length)时,获取了当前元素在数组的位置,而此时B也执行了此方法,也获取了当前元素在数组的位置,如果线程A和线程B都得到同一个数组位置,那么线程B的元素就覆盖了线程A的元素在数组的位置,造成安全隐患,所以线程不安全。

4.put中的addEntry源码:

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容,新容量为旧容量的2倍
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);//扩容后重新计算插入的位置下标
        }

        //把元素放入HashMap的桶的对应位置
       createEntry(hash, key, value, bucketIndex);
 }

此步骤可能引起线程不安全

现在假设有线程A和线程B,同时对同一个数组位置调用addEntry,那么,这两个线程可能同时得到头结点,当线程A写入新的头结点时,线程B也写入新的头结点,那么线程B的写入操作就覆盖了线程A的写入操作,这会导致A线程的写入操作丢失,造成线程不安全。

5.addEntry中的resize源码:

//按新的容量扩容Hash表  
    void resize(int newCapacity) {  
        Entry[] oldTable = table;//老的数据  
        int oldCapacity = oldTable.length;//获取老的容量值  
        if (oldCapacity == MAXIMUM_CAPACITY) {//老的容量值已经到了最大容量值  
            threshold = Integer.MAX_VALUE;//修改扩容阀值  
            return;  
        }  
        //新的数组 
        Entry[] newTable = new Entry[newCapacity];  
        transfer(newTable, initHashSeedAsNeeded(newCapacity));//将老的表中的数据拷贝到新的结构中  
        table = newTable;//修改HashMap的底层数组  
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//修改阀值  
    }  

此步骤也可能引起线程不安全

当调用addEntry增加新的键值对后,如果键值对的总数量超过阈值,会调用resize扩容操作,这个操作会新生成一个新的容量的数组,然后对原数组的所有键值对重新进行计算和写入新数组,最后指向新的数组。
当多个线程同时检测到键值对的数量超过阈值时,会同时调用resize操作,然后生成新数组,最后赋给map底层的数组table,然而只有最后一个线程生成的新数组赋给table,其他的线程全部丢失,而且,当某些线程赋值新数组给table时,其他数组才开始执行,就会用到新赋值给table的数组作为原始数组,这样,不同的线程可能会有不同的原始数组,造成HashMap混乱,出现线程不安全。

6.resize中的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) {//如果是重新Hash,则需要重新计算hash值  
                    e.hash = null == e.key ? 0 : hash(e.key);  
                }  
                int i = indexFor(e.hash, newCapacity);//定位Hash桶  
                e.next = newTable[i];//元素连接到桶中,这里相当于单链表的插入,总是插入在最前面
                newTable[i] = e;//newTable[i]的值总是最新插入的值
                e = next;//继续下一个元素  
            }  
        }  
    }  

此步骤仍然可能出现线程不安全

transfer()方法是数组转换操作,通过for (Entry<K,V> e : table)循环遍历旧table的每一个元素。而最最重要的操作出现了:
e.next = newTable[i];
newTable[i] = e;
e = next;

可以分割成四大操作:
1.next=e.next//记录当前元素的下一个元素,在当前元素完成插入后,继续下一个元素的插入操作
2.e.next = newTable[i]//将当前元素的下一个元素指向链表头
3.newTable[i] = e//newTable[i]的值总是最新插入的值,即将当前元素替换成新的链表头
4.e = next//处理完当前元素后,继续处理下一个元素

首先举个例子
假设有一个数组大小为2,在table[1]处有两个相同hashcode的元素,存放在对应的链表内,分别是key是3和key是7的元素。
在这里插入图片描述
通过resize后,要将旧数组中的两个元素转换到新数组中按照步骤为:
第一次:
1.next=e.next//即next=key(3).next=key(7)
2.e.next = newTable[i]//即key(3).next=newTable[3]
注意:int i = indexFor(e.hash, newCapacity);//定位Hash桶
此时定位i为3,所以新数组大小为4
3.newTable[i] = e//即newTable[3] = key(3)
4.e = next//即e=key(7)
新数组为:
在这里插入图片描述
第二次
1.next=e.next//即next=key(7).next=null
2.e.next = newTable[i]//即key(7).next=newTable[3]=key(3),上面第一步已经将newTable[3] = key(3)
3.newTable[i] = e//即newTable[3] = key(7)
4.e = next//即e=null结束旧数组转换新数组
总结步骤为:
在这里插入图片描述
观察发现:每次将旧数组链表元素插入到新数组链表中时,总是将新的要插入的元素,插到链表头,这叫做链表的头插法。多线程下这有可能引起链表死循环,造成线程不安全。

例子:
假设有两个线程A和B,A线程执行完第一步next=e.next后阻塞,而B线程执行完,此时A才被唤醒,那么,转换图示及操作如下:

在这里插入图片描述
当e=key(3)时
1.线程A阻塞时已经执行的过程为next=e.next=key(3).next=key(7)
2.A被唤醒后执行e.next = newTable[i]//即key(3).next=null图中为线程B执行完,由于是链表的头插法,完成后的链表key(7)在key(3)前面,所以key(3)后为null
3.newTable[i] = e//即newTable[3]=key(3)
4.e=next//即e=key(7)

当e=key(7)时
1.next=key(7).next=key(3)
2.e.next = newTable[i]即key(7).next=newTable[3]=key(3)
3.newTable[i] = e//即newTable[3]=key(7)
4.e=next//即e=key(3)

当e=key(3)时
1.next=key(3).next=null
2.e.next = newTable[i]即key(3).next=newTable[3]=key(7)
3.newTable[i] = e//即newTable[3]=key(3)
4.e=next//即e=null,循环结束,但形成环路造成线程不安全。
在这里插入图片描述
所以以上部分就是讲解为什么HashMap为线程不安全,如果想解决此问题,要么使用ConcurrentHashMap要么给HashMap增加synchronized关键字保证线程安全。

二.JDK1.8分析HashMap线程不安全

在JDK1.8中HashMap实现了链表的尾插法,进行了put元素的优化
那么,其他的一些地方的线程不安全类似于JDK1.7,这里就不再详解。
如:put中的addEntry、addEntry中的resize
而且JDK1.8的优化还在于使用红黑树,数据结构变为:数组+链表+红黑树,具体的优化后面会进一步解释。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值