Java HashMap详解

温馨提示:Java HashMap详解

  • 这篇相当的烧脑~~
  • 由于本文难度较大,文章较长,因此我对文章的结构进行了调整,将结论放到本文前面,对于不纠结原理的网友,可以直接记结论看一下简介即可,每天进步一点点哦
  • 看懂本文可能需要:
    • 懂中文 :)
    • 能看懂位运算
    • Java基础
      • 使用过HashMap相关API
      • 听过HashMap底层的一点原理
    • 对数据结构有一些了解

      • 握链表基本操作
      • 了解哈希表,数组,HashMap
      • 听过红黑树
    • 对算法有一些要求(这个不用太担心啦,有图辅助理解的)
      • 过时间复杂度这个名词
  • 代码过长,窗口可左右滑动哦
  • 您的点赞和观看是对本公众号的最大力支持~~



参考文章:

  • https://www.cnblogs.com/myseries/p/10876828.html Java8 HashMap详解(转)

  • https://blog.csdn.net/ddou_pan/article/details/98514764 java集合HashMap中链表树化的条件

  • https://mp.weixin.qq.com/s?__biz=MzIxMjE5MTE1Nw==&mid=2653191907&idx=1&sn=876860c5a9a6710ead5dd8de37403ffc 漫画:什么是HashMap?

  • https://zhuanlan.zhihu.com/p/33714985 HashMap扩容

目录

Java HashMap详解


结论

HashMap的线程不安全主要体现在下面两个方面:

  1. 在JDK1.7中,当并发执行扩容操作时会造成环形链和数据丢失的情况。
  2. 在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况。

Java7&Java8 HashMap

Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。

根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)

为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)

来一张图简单示意一下吧:

Java HashMap详解
img

回答几个问题:

1.HashMap的什么时候扩容,哪些操作会触发

当向容器添加元素的时候,会判断当前数据存储的数量(不是节点的数量,而是占用数组的长度),如果大于等于阈值,即当前数组的长度乘以加载因子的值的时候,就要自动扩容。(HashMap.size >= Capacity * LoadFactor)默认容量为16,扩容因子是0.75,阈值为12。

有参构造方法和put、merge操作都有可能导致扩容。

2.HashMap push方法的执行过程?

最先判断桶的长度是否为0,为0的话则需要先对桶进行初始化操作,接着,求出hashCode并通过扰动函数确定要put到哪个桶中,若桶中没有元素直接插入,若有元素则判断key是否相等,如果相等的话,那么就将value改为我们put的value值,若不等的话,那么此时判断该点是否为树节点,如果是的话,调用putTreeVal方法,以树节点的方式插入,如果不是树节点,那么就遍历链表,如果找到了key那么修改value,没找到新建节点插到链表尾部,最后判断链表长度是否大于8 是否要进行树化。

具体源码在下方

3.进行树化的条件

在HashMap具体实现类中,有两个成员变量,分别是TREEIFY_THRESHOLD = 8;MIN_TREEIFY_CAPACITY = 64;,第一个变量是链表树化的阈值,在每次插入操作时,会检查链表中元素的个数是否达到阈值,达到阈值以后才会进行树化操作。而进行树化操作的第一个判断就是哈希数组的容量是否大于MIN_TREEIFY_CAPACITY,如果小于,进行扩容操作,而不是树化操作。

Java HashMap详解
image-20201205154755398

综上:

哈希表的链表树化成红黑树有 两个条件:

  1. 链表长度大于TREEIFY_THRESHOLD

  2. 哈希数组的长度大于MIN_TREEIFY_CAPACITY

3.HashMap检测到hash冲突后,将元素插入在链表的末尾还是开头?

因为JDK1.7是用单链表进行的纵向延伸,采用头插法就是能够提高插入的效率,但是也会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

4.JDK1.8还采用了红黑树,讲讲红黑树的特性,为什么人家一定要用红黑树而不是AVL、B树之类的?

在CurrentHashMap中是加锁了的,实际上是读写锁,如果写冲突就会等待,如果插入时间过长必然等待时间更长,而红黑树相对AVL树B树的插入更快,AVL树查询确实更快一些,但是对于操作密集型,红黑树的旋转更少,效率更高。

5.HashMap get方法的执行过程?

首先和put一样,确定对应的key在哪一个桶中,如果桶容量为0或者该桶内没有元素直接返回空,反之会判断该桶会检查桶中第一个元素是否和要查的key相等,相等的话直接返回,不相等的话判断该节点是否为树节点,是的话以树节点方式遍历整棵树来查找,不是的话那就说明存储结构是链表,以遍历链表的方式查找。

下面难度开始提高,属于了解内容,目前如果看不懂没关系


源码与其中的算法技巧

HashMap开胃菜

HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。

HashMap数组每一个元素的初始值都是Null。

Java HashMap详解
img

对于HashMap,我们最常使用的是两个方法:get 和put

1.put方法

调用Put方法的时候发生了什么呢?比如调用 hashMap.put(“apple”, 0) ,插入一个Key为“apple”的元素。这时候我们需要利用一个哈希函数来确定Entry的插入位置(index):

index = Hash("apple")

假定最后计算出的index是2,那么结果如下:

Java HashMap详解
img

但是,因为HashMap的长度是有限的,当插入的Entry越来越多时,再完美的Hash函数也难免会出现index冲突的情况。比如下面这样:

Java HashMap详解
img

这时候该怎么办呢?我们可以利用链表来解决。

HashMap数组的每一个元素不止是一个Entry对象,也是一个链表的头节点。每一个Entry对象通过Next指针指向它的下一个Entry节点。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可:

Java HashMap详解
img

需要注意的是,新来的Entry节点插入链表时,使用的是“头插法”。至于为什么不插入链表尾部,是因为HashMap的发明者认为,后插入的Entry被查找的可能性更大

思路讲解完毕,源码在下面

2.get方法

使用Get方法根据Key来查找Value的时候,发生了什么呢?首先会把输入的Key做一次Hash映射,得到对应的index:

index = Hash("apple")

由于刚才所说的Hash冲突,同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找。假设我们要查找的Key是“apple”:

Java HashMap详解
img

第一步,我们查看的是头节点Entry6,Entry6的Key是banana,显然不是我们要找的结果。

第二步,我们查看的是Next节点Entry1,Entry1的Key是apple,正是我们要找的结果。

哈希表的初始长度

HashMap的默认初始长度是16,并且每次自动扩展或是手动初始化时,长度必须是2的幂,那16有什么特殊意义呢?

Java HashMap详解
img

之前说过,从Key映射到HashMap数组的对应位置,会用到一个Hash函数:

index = Hash("apple")

如何实现一个尽量均匀分布的Hash函数呢?我们通过利用Key的HashCode值来做某种运算。

Java HashMap详解
img

index = HashCode(Key) % Length ?

错!取模运算的方式固然简单,但是效率很低。为了实现高效的Hash算法, HashMap的发明者采用了位运算的方式

如何进行位运算呢?有如下的公式(Length是HashMap的长度):

index = HashCode(Key) & (Length - 1)( 这是JDK1.8的,对于公式,网上有另一种说法:index = HashCode(key)% Length,这是JDK1.7的,当你经过运算的时候,你就会发现,两种方式得到的结果是一致的,所以都正确。实际上在jdk1.7中使用的是取模算法,而jdk1.8中使用的是高位与运算。因为&运算比%运算速度更快)

下面我们以值为“book”的Key来演示整个过程:

  1. 计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。

  2. 假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。

  3. 把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。

可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位。

Java HashMap详解
img

这样做不但效果上等同于取模,而且还大大提高了性能。至于为什么采用16,我们可以试试长度是10会出现什么问题,假设HashMap的长度是10,重复刚才的运算步骤:

Java HashMap详解
img

单独看这个结果,表面上并没有问题,我们再来尝试一个新的HashCode 101110001110101110 1011

Java HashMap详解
img

让我们再换一个 HashCode 101110001110101110 1111 试试 :

Java HashMap详解
img

是的,虽然HashCode的倒数第二第三位从0变成了1,但是运算的结果都是1001。也就是说,当HashMap长度为10的时候,有些index结果的出现几率会更大,而有些index结果永远不会出现,比如0111

显然不符合 Hash 算法均匀分布的原则。

而反观长度16或者其他2的幂,Length-1 的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的

难度继续加大,进入到源码分析

构造方法

public HashMap(int initialCapacity, float loadFactor) {
    // 当指定的 initialCapacity (初始容量) < 0 的时候会抛出 IllegalArgumentException 异常
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);

    // 当指定的 initialCapacity (初始容量)= MAXIMUM_CAPACITY(最大容量) 的时候 
    if (initialCapacity > MAXIMUM_CAPACITY)
        // 初始容量就等于 MAXIMUM_CAPACITY (最大容量)
        initialCapacity = MAXIMUM_CAPACITY;

    // 当 loadFactory(负载因子)< 0 ,或者不是数字的时候会抛出 IllegalArgumentException 异常
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
    this.loadFactor = loadFactor;

    // tableSizeFor()的主要功能是返回一个比给定整数大且最接近2的幂次方整数
    // 比如我们给定的数是12,那么tableSizeFor()会返回2的4次方,也就是16,因为16是最接近12并且大于12的数
    this.threshold = tableSizeFor(initialCapacity);
}

注意:创建HashMap对象的时候没有初始化table,要到put的时候才会初始化

执行顺序注释写的很清楚了,但有些同学对最后对 tableSizeFor 方法很有疑问,这是用来求传入容量的最小2的幂次方整数的。

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

这是一系列的或操作,举个例子,假设cap = 65

 cap = 65;
 n = cap - 1 = 64 =100 0000 // n = cap - 1;
    100 0000|010 0000=110 0000 // n |= n >>> 1
    110 0000|001 1000=111 1000 // n |= n >>> 2
    111 1000|000 0111=111 1111 // n |= n >>> 4
    111 1111|111 1111=111 1111 // n |= n >>> 8
    111 1111|111 1111=111 1111 // n |= n >>> 16
    111 1111 + 1 = 1000 0000   // return 128

看出规律来了吧,右移多少位,就把最高位右边的第x位设置为1;第二次,就把两个为1的右边xx位再设置为1;第n次,就把上一步出现的1右边xxxx位置为1;

这样执行完,原来是1000000,变成了1111111,最后加1,就变成2的整数次方数了。之所以先减一是因为有可能本身就是最小2的幂次方整数。

put方法

put方法的核心就是 putVal,源码和执行过程如下。

最先判断桶的长度是否为0,为0的话则需要先对桶进行初始化操作,接着,求出hashCode并通过扰动函数确定要put到哪个桶中,若桶中没有元素直接插入,若有元素则判断key是否相等,如果相等的话,那么就将value改为我们put的value值,若不等的话,那么此时判断该点是否为树节点,如果是的话,调用putTreeVal方法,以树节点的方式插入,如果不是树节点,那么就遍历链表,如果找到了key那么修改value,没找到新建节点插到链表尾部,最后判断链表长度是否大于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;

    //如果table为空或者长度为0,则进行resize()(扩容)
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    //确定插入table的位置,算法是上面提到的 HashCode(Key) & (Length - 1),在 n 为 2 的时候,相当于取模操作
    if ((p = tab[i = (n - 1) & hash]) == null)
        //找到key值对应的位置并且是第一个,直接插入
        tab[i] = newNode(hash, key, value, null);

    //在table的 i 的位置发生碰撞,分两种情况
    //   1、key值是一样的,替换value值
    //   2、key值不一样的
    //   而key值不一样的有两种处理方式:1、存储在 i 的位置的链表 2、存储在红黑树中
    else {
        Node<K,V> e; K k;

        //第一个Node的hash值即为要加入元素的hash
        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 {
            //如果不是TreeNode的话,即为链表,然后遍历链表
            for (int binCount = 0; ; ++binCount) {
                //链表的尾端也没有找到key值相同的节点,则生成一个新的Node
                //并且判断链表的节点个数是不是到达转换成红黑树的上界达到,则转换成红黑树
                if ((e = p.next) == null) {
                    //创建链表节点并插入尾部
                    p.next = newNode(hash, key, value, null);

                    //超过了链表的设置长度(默认为8)则转换为红黑树
                    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;
            }
        }

        //如果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;
}

为了方便理解,配图

Java HashMap详解
img

扩容方法

putVal中有一段代码提到了resize(),也就是扩容方法

HashMap的容量是有限的。当经过多次元素插入的时候,使得HashMap达到一定的饱和度,Key映射位置的几率不断变大。这个时候,HashMap就需要扩容了,也就是Resize

扩容方法的发生条件

当向容器添加元素的时候,会判断当前数据存储的数量(不是节点的数量,而是占用数组的长度),如果大于等于阈值,即当前数组的长度乘以加载因子的值的时候,就要自动扩容。(HashMap.size >= Capacity * LoadFactor)默认容量为16,扩容因子是0.75,阈值为12。

有参构造方法和put、merge操作都有可能导致扩容。

影响发生Resize的因素有两个,分别是capacityloadFactor

capacity

Capacity是HashMap的当前长度,HashMap的长度必是2的幂。

// 测试一下:
// 请问如下map的长度分别为多少呢?
HashMap<String,String> map = new HashMap<String, String>(); // 16
HashMap<String,String> map = new HashMap<String, String>(8);// 8
HashMap<String,String> map = new HashMap<String, String>(5);// 5

原理很简单,就是HashMap的长度必是2的幂,默认大小是16(创建HashMap对象的时候没有初始化table,要到put的时候才会初始化,看上面两个源码就知道啦)

loadFactor

LoadFactor是HashMap负载因子,默认是0.75f。

HashMap.size >= Capacity * LoadFactor 时,HashMap可能会进行Resize。

也就是说Hashmap的扩容需要满足两个条件:当前数据存储的数量(即size())大小必须大于等于阈值;当前加入的数据是否发生了hash冲突。

因为上面这两个条件,所以存在下面这些情况

  1. 就是hashmap在存值的时候(默认大小为16,负载因子0.75,阈值12),可能达到最后存满16个值的时候,再存入第17个值才会发生扩容现象,因为前16个值,每个值在底层数组中分别占据一个位置,并没有发生hash碰撞。
  2. 当然也有可能存储更多值(超多16个值,最多可以存26个值)都还没有扩容。原理:前11个值全部hash碰撞,存到数组的同一个位置(这时元素个数小于阈值12,不会扩容),后面所有存入的15个值全部分散到数组剩下的15个位置(这时元素个数大于等于阈值,但是每次存入的元素并没有发生hash碰撞,所以不会扩容),前面11+15=26,所以在存入第27个值的时候才同时满足上面两个条件,这时候才会发生扩容现象。

我们来看下源码

思路都写在代码中啦,有点难度哦,慢慢理解哈

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table; // 旧表
    int oldCap = (oldTab == null) ? 0 : oldTab.length; // 旧表长度
    int oldThr = threshold; // 阀值大小(容量*负荷系数) 初始容量是放置在阈值,还记得构造方法最后一行吗
    int newCap, newThr = 0// 新表长度,新表阈值

    // 判断旧表大小,如果不为零
    if (oldCap > 0) {
        //判断旧表长度,如果当前长度超过 MAXIMUM_CAPACITY(最大容量值)
        if (oldCap >= MAXIMUM_CAPACITY) { // MAXIMUM_CAPACITY = 1 << 30;
            //直接更改新增阀值为 Integer.MAX_VALUE并返回旧表
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // newCap扩大到原来的两倍
        // 同时如果小于这个 MAXIMUM_CAPACITY(最大容量值)并且大于 DEFAULT_INITIAL_CAPACITY (默认16)
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 阀值也进行2倍扩大
            newThr = oldThr << 1// double threshold
    }
    // 如果旧表长度为0,但是阈值不为0,指定新增阀值
    // 初始容量是放置在阈值,还记得构造方法最后一行吗
    else if (oldThr > 0
        newCap = oldThr;
    // 如果旧表长度为0,并且阈值为0,指定默认阈值和默认容量
    else {
        // 指定数组长度为默认长度 = 16
        newCap = DEFAULT_INITIAL_CAPACITY; //  DEFAULT_INITIAL_CAPACITY = 1 << 4 = 16
        //使用默认的加载因子(0.75)
        //指定新增的阀值为默认大小 = 0.75 * 16 = 12
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 如果新阈值等于0,给定一下
    if (newThr == 0) {
        //按照给定的初始大小计算扩容后的新增阀值
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }

    //扩容后的新增阀值
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    //扩容后的新表存储结构
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;

    //如果数组不为空,将原数组中的元素放入扩容后的数组中
    if (oldTab != null) {
        // 遍历所有链表
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;

                //如果节点为空,则直接计算在新数组中的位置,放入即可
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode) // 如果是树节点
                    //拆分树节点
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    //如果节点不为空,且为单链表,则将原数组中单链表元素进行拆分
                    Node<K,V> loHead = null, loTail = null;//保存在原有索引的链表
                    Node<K,V> hiHead = null, hiTail = null;//保存在新索引的链表
                    Node<K,V> next;
                    // 拆分单链表
                    do {
                        next = e.next;

                        //哈希值和原数组长度进行&操作,为0的节点则在原数组的索引位置
                        //非0的节点 则在原数组索引位置 + 原数组长度的新位置
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

为什么会hash冲突(碰撞)

就是根据key即经过一个函数f(key)得到的结果的作为地址去存放当前的key,value键值对(这个是hashmap的存值方式),但是却发现算出来的地址上已经有数据。这就是所谓的hash冲突。

hash冲突的几种情况:

  1. 两个节点的key值相同(hash值一定相同),导致冲突
  2. 两个节点的key值不同,由于hash函数的局限性导致hash值相同,导致冲突
  3. 两个节点的key值不同,hash值不同,但hash值对数组长度取模后相同,导致冲突

hash冲突的方法

如何解决hash冲突?解决hash冲突的方法主要有两种,一种是开放寻址法,另一种是链表法 。

开放寻址法–线性探测

开放寻址法的原理很简单,就是当一个Key通过hash函数获得对应的数组下标已被占用的时候,我们可以寻找下一个空档位置

比如有个Entry6通过hash函数得到的下标为2,但是该下标在数组中已经有了其它的元素,那么就向后移动1位,看看数组下标为3的位置是否有空位

比如有个Entry6通过hash函数得到的下标为2,但是该下标在数组中已经有了其它的元素,那么就向后移动1位,看看数组下标为3的位置是否有空位

Java HashMap详解
img

但是下标为3的数组也已经被占用了,那么久再向后移动1位,看看数组下标为4的位置是否为空

数组下标为4的位置还没有被占用,所以可以把Entry6存入到数组下标为4的位置。这就是开放寻址的基本思路,寻址的方式有很多种,这里只是简单的一个示例

链表法

链表法也正是被应用在了HashMap中,HashMap中数组的每一个元素不仅是一个Entry对象,还是一个链表的头节点。每一个Entry对象通过next指针指向它的下一个Entry节点。当新来的Entry映射到与之冲突的数组位置时,只需要插入到对应的链表中即可

Java HashMap详解
img

JDK7和JDK8中的HashMap都是线程不安全的,主要体现在哪些方面?

只要是对于集合有一定了解的一定都知道HashMap是线程不安全的,我们应该使用ConcurrentHashMap。但是为什么HashMap是线程不安全的呢,之前面试的时候也遇到到这样的问题,但是当时只停留在知道是的层面上,并没有深入理解为什么是。于是今天重温一个HashMap线程不安全的这个问题。

首先需要强调一点,HashMap的线程不安全体现在会造成死循环数据丢失数据覆盖这些问题。其中死循环和数据丢失是在JDK1.7中出现的问题,在JDK1.8中已经得到解决,然而1.8中会有数据覆盖这样的问题。

扩容引发的线程不安全

JDK1.7中的线程不安全

HashMap的线程不安全主要是发生在扩容函数中,即根源是在transfer函数中,JDK1.7中HashMaptransfer函数如下:

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

这段代码是HashMap的扩容操作,重新定位每个桶的下标(因为每个节点的下标都是通过HashCode(Key) & (Length - 1)计算而得到,桶扩大了,length自然发生变化,也就导致了计算出的结果发生变化),并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点。理解了头插法后再继续往下看是如何造成死循环以及数据丢失的。

现在进入烧脑过程,准备好了吗

扩容造成死循环和数据丢失的分析过程

假设现在有两个线程A、B同时对下面这个HashMap进行扩容操作:Java HashMap详解

正常扩容后的结果是下面这样的:

Java HashMap详解
img

现在进行第一次循环

Java HashMap详解
image-20201205200612034

当线程A执行到上面transfer函数的第11行代码时,CPU时间片耗尽,线程A被挂起。即如下图中位置所示:

Java HashMap详解
img

此时线程A中:e=3、next=7、e.next=null

Java HashMap详解
img

当线程A的时间片耗尽后,CPU开始执行线程B,并在线程B中成功的完成了数据迁移

Java HashMap详解
img

重点来了,根据Java内存模式可知,线程B执行完数据迁移后,此时主内存中newTable和table都是最新的,也就是说:7.next=3、3.next=null(上图)

随后线程A获得CPU时间片,数据恢复(e=3、next=7、e.next=null)继续执行newTable[i] = e,将3放入新数组对应的位置,执行完此轮循环后线程A的情况如下:Java HashMap详解

注意:这个对象关系已经发生变化,7的后面跟的并不是5而是3,我这么画是为了方便理解,现在java内存中对象关系应该为Java HashMap详解

接着继续执行第二轮循环,此时e=7,从主内存中读取e.next时发现主内存中7.next=3,于是乎next=3(看上面注意部分)

Java HashMap详解
img

然后并将7采用头插法的方式放入新数组中

Java HashMap详解
image-20201205202253192

并继续执行完此轮循环,结果如下:

Java HashMap详解
img

执行第三次循环可以发现,next=e.next=null(对象关系还是前面说的,不要懵),所以此轮循环将会是最后一轮循环。

接下来当执行完e.next=newTable[i]即3.next=7后,3和7之间就相互连接了,当执行完newTable[i]=e后,3被头插法重新插入到链表中

Java HashMap详解
image-20201205203309876

执行结果如下图所示:

Java HashMap详解
img

上面说了此时e.next=null即next=null,当执行完e=null后,将不会进行下一轮循环。到此线程A、B的扩容操作完成,很明显当线程A执行完后,HashMap中出现了环形结构,当在以后对该HashMap进行操作时会出现死循环。

并且从上图可以发现,元素5在扩容期间被莫名的丢失了,这就发生了数据丢失的问题。

怎么样,还看得懂吧,后面就简单啦~~

JDK1.8中的线程不安全

根据上面JDK1.7出现的问题,在JDK1.8中已经得到了很好的解决,如果你去阅读1.8的源码会发现找不到transfer函数,因为JDK1.8直接在resize函数中完成了数据迁移。另外说一句,JDK1.8在进行元素插入时使用的是尾插法

为什么说JDK1.8会出现数据覆盖的情况喃,我们来看一下下面这段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// 如果没有hash碰撞则直接插入元素
            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;
    }

其中第六行代码是判断是否出现hash碰撞

Java HashMap详解
image-20201205205905223

假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

图片解释一下吧~

Java HashMap详解
image-20201205205734115
Java HashMap详解
image-20201205205743794
Java HashMap详解
image-20201205205804432
Java HashMap详解
image-20201205205813121
Java HashMap详解
image-20201205205827096

除此之前,还有就是代码的第38行处有个++size,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的zise大小为10,当线程A执行到第38行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。(这段话和上面的图解意思相似哦,仔细看看上面图片再来理解这段话会更好一些)

如果看完了,别忘了对这篇文章的结论在开头哦


end

您的点赞和观看是对本公众号的最大力支持~~


原文始发于微信公众号(一个调皮的bug):Java HashMap详解

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/43591.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!