深入浅出HashMap详解(JDK8)

1、什么是HashMap

  和 JDK7 版本的 HashMap 结构大体一致,多了红黑树。
在这里插入图片描述
  如果还没看过的或者忘记了的可以先去回顾下,这样可以更好的了解 JDK8 下的 HashMap 基于 JDK7 做了什么改动。分析 JDK8 下的 HashMap 主要是因为 JDK8 在目前使用已成主流,且其在某些性能程度远远大于 JDK7。下面逐一分析。

  深入浅出HashMap详解(JDK7)

2、构造函数

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
  和 JDK7 一样,有两个参数:loadFactorCapacity,分别为负载因子和容量。

  如果没有设定,会有默认值:
在这里插入图片描述
在这里插入图片描述
  和 JDK7 一样,我们依旧没有在构造函数中看到 table 的创建,它还是在put中进行创建。

3、table 的创建

  当我们对一个 HashMap 第一次put操作时,会开始创建 table:
在这里插入图片描述
  要研究 table ,得知道它的类型和容量。我们进入resize()方法:
在这里插入图片描述
  可以看到,代码量比较多,我们不一行一行来分析,我们只根据主要的来分析来龙去脉。首先知道,它返回一个newTab,找到这个newTab的赋值部分:
在这里插入图片描述
  可见,newTab是一个Node[]类型的数组,因为这里是第一次创建,oldTab就是为 null。

  JDK7 的 table 是Entry[]类型的数组,这个Node我们看一下定义:
在这里插入图片描述
  和 JDK7 的Entry对比一下:
在这里插入图片描述
  区别就在于,JDK8 给 key 也加了 final 关键字,猜测是为了防止键值对的键被修改导致和哈希值对应不上。其他的和 Entry 没多大区别。从下面开始就把键值对称为 Node,而不是 Entry。

  继续分析 table 的创建,上面已经知道 table 的每一个元素都是一个 Node,且 table 的容量为 newCap,继续追踪这个newCap的来源:
在这里插入图片描述
  第一次创建 table 时,没有所谓的旧 table,所以oldCapoldThr为0。可以看到容量和阈值都有初始值:DEFAULT_INITIAL_CAPACITYDEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY,查看他们的定义:
在这里插入图片描述
在这里插入图片描述
  即,table 的初始容量为 16,初始阈值为 16 × 0.75 = 12。

  至此,table 创建过程了解清楚了,接下来分析 HashMap 的两个核心方法:PutGet

4、Put 操作

  打开Put方法的源码:
在这里插入图片描述
  这个put方法只做了两件事:计算哈希值,然后调用putVal方法。

  我们查看hash()源码:
在这里插入图片描述
  与JDK7版本对比,这里简化了很多。这里先不分析它的原理,等放在后面单独一个小节来介绍。

  我们继续研究putVal方法。先来分析一下方法的参数,除了 key、value、hash 是我们在 JDK7 中了解的,后面还有两个参数 onlyIfAbsent 和 evict。通过注释可知:
  1、onlyIfAbsent :如果为 true,不改变已有的值,默认为 false;
  2、evict:如果为 false,说明 table 处于创建中,默认为 true。
  onlyIfAbsent 参数就是取决于后插入的数据如果存在相同的 key ,要不要覆盖的选项,我们默认是要覆盖。evict 参数表明 table 处于创建模式,这个创建模式有什么用这里暂时不去考虑,后面会用到。

  下面黄框里的就是第一次创建 table 时用,现在我们不是第一次put,则跳过:
在这里插入图片描述
  在上面黄框的 if 中也完成了两个赋值:tab = tablen = tab.length,分别是 table 实体和容量。

4.1 待插入的位置为空

  继续往下分析:
在这里插入图片描述
  上图黄框里面做了好多个动作,首先是(n - 1) & hash获取应该插入的索引,这里和 JDK7 一样,先 hash 后求 index。然后 table[i] 和待插入的 key 的 hash 进行与操作,如果为 null,则直接插入到 table[i],那什么情况下才会是 null 呢?当然是 table[i] 为 null 的情况啦。其实这个 if 就是把下面几件事情一行做完了:
  1、根据 hash 求待插入的位置索引并复制给 i;
  2、获取 table[i] 的 Node,赋值给 p;
  3、判断 p 是否为 null,是则直接插入。

  if 里面做这么多事情的巧妙之处在于:哪怕 if 条件不满足,else 代码块的准备工作也做好了,就是这个 p,不用在 else 代码块又重复计算一次。

  现在假设待插入的位置为空,则直接插入,我们继续分析插入后还做了什么:
在这里插入图片描述
  modCount定义和 JDK7 一样,就是统计 table 的结构修改次数,结构修改是指那些改变HashMap 中映射数量或修改其内部结构的修改(例如,重新哈希)。

  接着一个 if 就是判断 table 当前元素数量是否超过阈值,这一点和 JDK7 不同,JDK7 是插入之前进行阈值判断,JDK8 是插入后再判断。下面是 JDK7 的源码:
在这里插入图片描述

  回到 JDK8,接着调用了一个afterNodeInsertion()方法,查看一下:
在这里插入图片描述
  看到是给 LinkedHashMap 用的,所以这里就跳过,最后返回 null 表示插入成功:
在这里插入图片描述

4.2 待插入的位置不为空

  上面是分析了插入位置为空的情况,这里分析插入位置不为空的情况。
在这里插入图片描述
  代码量不少,我们来逐行分析。

4.2.1 第一个 if

在这里插入图片描述
  p 就是 table[i] 的 Node。
  首先看上图黄框里面的 if 内容,做了以下几件事:
  1、判断 table[i] 位置的 Node 的哈希值是否和待插入 key 的哈希值一样,不一样则进入下面的 else if;如果一样,进入第 2 步;
  2、table[i] 的 key 赋值给 k,判断 k 和待插入的 key 是否相同,如果相同,则把 p 赋值给 e;如果不相同,进入第 3 步。
  3、待插入的 key 不为空并且待插入的 key 和 k 不相等,如果满足,则把 p 赋值给 e,不满足则进入下一个 else if 代码块。

  这个 if 里面都是在确认 table[i] 和待插入的 Node 是否一样。

4.2.2 第二个 if

  如果第一个 if 不满足,即 table[i] 所在的 Node 和 待插入的 Node 不存在相同的可能。

  第二个 if 是判断 p 是否为红黑树,链表和红黑树的转换我们下面再看,这里假设不是红黑树,只是普通的 Node 链表,因此进入第三个 if。

4.2.3 第三个 if

在这里插入图片描述
  第三个 if 里说:如果链表的长度大于某个值,则转成红黑树:
在这里插入图片描述
  我们看一下这个TREEIFY_THRESHOLD是多少:
在这里插入图片描述
  也就是说,在插入之后,发现插入之前(插入后++binCount没有执行就break了)链表的长度大于等于 8 ,则把这个链表转成红黑树,这里减一是因为序数是从 0 开始数的,当数到 7 表示已经有 8个 Node 了。

  三个 if 会执行其中一个,执行完后:
在这里插入图片描述
  如果 e 不为空,说明找到了相同哈希值的 Node,这里就把待插入的 Node 的值覆盖已存在的相同哈希值的 Node。

4.3 Put 总结

  1、先会判断数组是否为空,如果为空则通过 resize() 函数来创建;
  2、根据 key 的哈希值与数组长度取模获取索引,对应节点为空则直接创建节点;
  3、如果对应节点不为空,先判断是否与插入元素相等,如果相等则进行替换;不相等继续判断;
  4、判断获取的节点是否是树形节点,如果是则通过树形节点添加元素;
  5、如果不是树形节点, 则一定是链表。然后遍历链表至最后一个节点,将节点添加至链尾。如果当前链表的数量(没有算新插入节点)大于等于转换树形的阈值-1,则需要将该链表进行树形转换。
  6、插入节点后,长度+1; 然后判断是否大于阈值进行扩容操作。

5、Get 方法

  查看源码:
在这里插入图片描述
  做了下面几件事:
  1、计算 key 的哈希值;
  2、调用getNode(int hash, Object key)获取对应的 Node;
  3、判断 Node 是否为空,是则返回 null,否则返回 Node.value;

  因此,Get 方法的核心在于getNode(int hash, Object key)方法。源码如下:
在这里插入图片描述
  输入为待查询 key 的哈希值和 key 本身。

5.1 最外层 if

在这里插入图片描述
  上图黄框的 if 做了以下几件事:
  1、table 赋值给 tab,判断 tab 是否为空,如果是,则直接返回 null,否则进入第 2 步;
  3、获取 table 的容量,赋值给 n,判断 n 是否大于 0,如果不是,则直接返回 null;如果 n 大于 0,则进入第 3 步;
  5、计算 key 对应的索引并复制给 first,判断 first 是否为空,是则直接返回 null,否则进入代码块。

  即,最外层的 if 就是在各种判断,先判断 table 是否为空,接着判断索引是否不为 null。如果判断通过,说明有可能从 table 中获取 key 的值。

5.2 内层 if

  第一个 if:
在这里插入图片描述
  待查询的 key 的哈希值和 first (即 table[i])的哈希值一样,并且待查询的 key 和 first 的 key 一样,说明这个 first 就是要找的 Node,返回这个 first。

  如果上面的 if 不满足,则接着下面的 if:
在这里插入图片描述
  如果 first 没有下一个结点了,且上面的 if 判断出 first 不是要找的,说明 table 中没有要查询的 key 对应的 Node,返回 null。

  如果 first 有下一个结点 next,则进入代码块:
在这里插入图片描述
  如果 first 是红黑树结点,则调用红黑树的查找结点方法。否则 first 就是链表,遍历链表:
在这里插入图片描述
  当找到一个结点 Node,这个 Node 的哈希值、key 值都和待查询的 key 一样,则返回这个 Node。

5.3 Get 总结

  1、判断 table 是否为 null,或者容量为 0。如果是,则直接返回 null;
  2、计算 key 的哈希值,并计算出索引 i,判断 table[i] 是否要找的 Node ,是则返回,不是则遍历 table[i] 链表;
  3、如果链表遍历到最后都没有对应的哈希值,则返回 null;
  4、找到一个哈希值相同且 key 相同的,返回该 Node 的值。

6、扩容

  在put函数中,有一个 resize() 方法。当第一次创建 table 或者 存储的 Node 的个数超过了阈值,则调用。阈值为 table 的容量乘以负载因子。

  对于 resize 的源码,在 table 的创建中介绍了第一次创建 table 的情况,本节内容就介绍扩容的情况。

  resize 分为两大部分:
  1、创建新的 table;
  2、旧的 table 数据迁移到新的 table 当中。

6.1 创建新的 table

  源码如下:
在这里插入图片描述
  扩容走的步骤已在上图进行了解释,最后创建一个新的 table ,称为 newTab,新的阈值也已经更新。

6.2 迁移数据

  迁移部分的源码为:
在这里插入图片描述
  我们知道,HashMap 的每一个元素其实都是一条链表,所有遍历的过程分为两层,外层遍历数组,内层遍历链表。

  先来看最短的几个 if 语句和代码块:
在这里插入图片描述
  如果 e 是长度大于 1 的链表,则直接进入最后一个 else 代码块,代码块就是遍历 e 为头结点的链表:

  首先创建了五个索引 Node,除了 next 的其他四个就是要把 oldTab[j] 所在的链表拆分成两条链表,一个叫做 lo 链表,头结点用loHead指向,末尾结点用loTial指向;一个叫做 hi 链表,头结点用hiHead指向,末尾结点用hiTail指向:
在这里插入图片描述
  接着就是do{}while(condition)代码块,有一个地方要说明一下:
在这里插入图片描述
  我们知道,oldCap 表示旧的 table 的容量,它是 2 的幂,转成二进制就是最高位为 1,其他位全为 0。让 Node 的哈希值跟它进行“与”操作,其实就是判断哈希值的最高位是 1 还是 0 罢了。

  我们看一下如果遍历到的 e 的哈希值最高位为 0 会怎样:
在这里插入图片描述
  如果是一开始,lo 链表的末尾是 null,则把 lo 链表的头结点和尾结点都指向 e 。如果 lo 链表已经有结点存在,即loTail不为空,那么就直接添加在 lo 链表的末尾。

  同理,如果 e 的哈希值最高位为 1,则:
在这里插入图片描述
  同理,当遍历完 oldTab[j] 的链表,会按照哈希值最高位是 1 还是 0 分成 hi 和 lo 两条链表。继续看源码:
在这里插入图片描述
  把 lo 链表放在newTab[j]的位置,把 hi 链表放在newTab[j+oldCap]的位置。

6.3 扩容小结

  1、如果原数组为空,则需要初始化;如果不为空则扩容,容量为原来的两倍。然后更新阈值;

  2、遍历原数组中的元素,将其添加至新数组中:
  2.1 如果当前节点只有一个节点时,则根据其 hash 值与 新容量-1 进行取模操作取得下标,将元素添加到此位置上。
  2.2 如果当前节点是树节点,则需要根据树形节点特性进行调整。
  2.3 如果当前节点是链表,则根据节点的 hash 值最高位判断,如果是 0 则添加到新数组上的原索引位置上;如果是 1 ,则添加至新数组的 原索引+原容量 的位置上。

7、并发下线程不安全

  JDK7 的线程不安全出现在扩容的时期,造成循环链表。

  在 JDK8 中对 HashMap 进行了优化,在发生 hash 碰撞,不再采用头插法方式,而是直接插入链表尾部,因此不会出现环形链表的情况,但是在多线程的情况下仍然不安全,这里我们看 JDK8 中 HashMap 的put操作源码:
在这里插入图片描述
  注意框出来的地方,假设现在有线程 A 和线程 B。线程 A 判断完 if 语句,得知 table[i] 是空的,准备进行tab[i] = newNode(hash, key, value, null)进行插入,但是现在是只判断完 if,没来得及执行插入,就暂停了,轮到线程 B 了。

  线程 B 刚好也进入到黄框这里,因为线程 A 还没插入,所以此时的 table[i] 依旧是 null。线程 B 眼疾手快,刷刷地put了好几个元素,好死不死,这些元素 hash 过后的值全是 i,即线程 B 往 table[i] 放入了一个链表。然后线程 B 睡眠,轮到线程 A 上场了。

  线程 A 的视角里,它刚判断的是 table[i] 为 null,于是直接tab[i] = newNode(hash, key, value, null),线程 B 白干了。

  线程不安全的点就在这,不同线程的数据产生了覆盖。

8、JDK7 和 JDK8 的区别

  1、底层数据结构有变化。
  JDK7:数组+链表。在极端的情况下会形成一条单链表,那么它的查找时间复杂度会达到O(n)。
  JDK8: 数组+链表+红黑树。 当容量超过最小树化容量 64 时,如果存在链表节点大于等于 8 时就会树化,形成红黑树(类似平衡查找二叉树)。所以最坏的情况下的查找时间复杂度为 O(logN). 比 JDK7 效率要好。

  2、计算 Hash 值的计算方式 JDK8 比 JDK7 要简化。所以数据量大时也会有明显的差异。

  3、当 hash 冲突时,插入链表不一样:JDK7是头插法(同索引下的节点顺序相反),JDK8是尾插法(同索引下的节点顺序不变)。

  4、扩容途径 JDK8 比 JDK7 多一种。JDK8多一种:当某链表长度大于等于 8 且当前容量还没达到树化容量时,会进行扩容减少冲突(链表转成红黑树)。

  5、扩容的具体操作不一样,JDK8 要优于 JDK7。 JDK7 需要重新进行索引下标的计算,而 JDK8 不需要,通过判断高位(与原容量比较)是 0 还是 1,要么依旧是原 index,要么是 oldCap + 原 index。

  6、JDK8 下的 HashMap 不会产生死循环。但依然是线程不安全的。

  • 3
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值