金三银四助力面试-手把手轻松读懂HashMap源码

前言

HashMap 对每个学习 Java 的人来讲熟悉的不能再熟悉了,然而就是这么一个熟悉的东西,真正深刻到源码层面却有许多值的学习和思考的地方,如今就让咱们一块儿来探索一下 HashMap 的源码。算法

HashMap 源码分析

HashMap 基于哈希表,且实现了 Map 接口的一种 key-value 键值对存储数据结构,其中的 keyvalue 均容许 null 值,在 HashMap 中,不保证顺序,线程也不安全。数组

HashMap 中的数据存储

HashMap 中,每次 put 操做都会对 key 进行哈希运算,根据哈希运算的结果真后再通过位运算获得一个指定范围内的下标,这个下标就是当前 key 值存放的位置,既然是哈希运算,那么确定就会有哈希冲突,对此在 jdk1.7 及其以前的版本,每一个下标存放的是一个链表,而在 jdk1.8 版本及其以后的版本,则对此进行了优化,当链表长度大于 8 时,会转化为红黑树存储,当红黑树中元素从大于等于 8 下降为 小于等于 6 时,又会从红黑树从新退化为链表进行存储。安全

下图就是 jdk1.8 中一个 HashMap 的存储结构示意图(每个下标的位置称之为 ):数据结构

HashMap 中是经过数组 + 链表 + 红黑树来实现数据的存储(jdk1.8),而在更早的版本中则仅仅采用了数组 + 链表的方式来进行存储。源码分析

为何建议初始化 HashMap 时指定大小

HashMap 初始化的时候,咱们一般都会建议预估一下可能大小,而后在构造 HashMap 对象的时候指定容量,这是为何呢?要回答这个问题就让咱们看一下 HashMap 是如何初始化的。性能

下图就是当咱们不指定任何参数时建立的 HashMap 对象:学习

负载因子

能够看到有一个默认的 DEFAULT_LOAD_FACTOR(负载因子),这个值默认是 0.75。负载因子的做用就是当 HashMap 中使用的容量达到总容量大小的 0.75 时,就会进行自动扩容。而后从上面的源码能够看到,当咱们不指定大小时,HashMap 并不会初始化一个容量,那么咱们就能够大胆的猜想当咱们调用 put 方法时确定会有判断当前 HashMap 是否被初始化,若是没有初始化,就会先进行初始化。优化

HashMap 默认容量

put 方法中会判断当前 HashMap 是否被初始化,若是没有被初始化,则会调用 resize 方法进行初始化,resize 方法不只仅用于初始化,也用于扩容,其中初始化部分主要是下图中红框部分:线程

能够看到,初始化 HashMap 时,主要是初始化了 2 个变量:一个是 newCap,表示当前 HashMap 中有多少个桶,数组的一个下标表示一个桶;一个是 newThr,主要是用于表示扩容的阈值,由于任什么时候候咱们都不可能等到桶所有用完了才去扩容,因此要设置一个阈值,达到阈值后就开始扩容,这个阈值就是总容量乘以负载因子获得。code

上面咱们知道了默认的负载因子是 0.75,而默认的桶大小是 16,因此也就是当咱们初始化 HashMap 而不指定大小时,当桶使用了 12 时就会自动扩容(如何扩容咱们在后面分析)。扩容就会涉及到旧数据迁移,比较消耗性能,因此在能预估出 HashMap 须要存储的总元素时,咱们建议是提早指定 HashMap 的容量大小,以免扩容操做。

PS:须要注意的是,通常咱们说 HashMap 中的容量都是指的有多少个桶,而每一个桶能放多少个元素取决于内存,因此并非说容量为 16 就只能存放 16key 值。

HashMap 最大容量是多少

当咱们手动指定容量初始化 HashMap 时,老是会调用下面的方法进行初始化:

看到 453 行,当咱们指定的容量大于 MAXIMUM_CAPACITY 时,会被赋值为 MAXIMUM_CAPACITY,而这个 MAXIMUM_CAPACITY 又是多少呢?

上图中咱们看到,MAXIMUM_CAPACITY 是 2 的 30 次方,而 int 的范围是 2 的 31 次方减 1,这岂不是把范围给缩小了吗?看上面的注释能够知道,这里要保证 HashMap 的容量是 2 的 N 次幂,而 int 类型的最大正数范围是达不到 2 的 31 次幂的,因此就取了2 的 30 次幂。

咱们再回到前面的带有参数的构造器,最后调用了一个 tableSizeFor 方法,这个方法的做用就是调整 HashMap 的容量大小:

这个方法若是你们不了解位运算,可能就会看不太明白这个究竟是作什么操做,其实这个里面就作了一件事,那就是把咱们传进来的指定容量大小调整为 2 的 N 次幂,因此在最开始要把咱们传进去的容量减 1,就是为了统一调整。

咱们来举一个简单的例子来解释一下上面的方法,位运算就涉及到二进制,因此假如咱们传进来的容量是一个 5,那么转化为二进制就是 0000 0000 0000 0000 0000 0000 0000 0101,这时候咱们要保证这个数是 2 的 N 次幂,那么最简单的办法就是把咱们当前二进制数的最左边的 1 开始,一直到最右边,全部的位都是 1,那么获得的结果就是获得对应的 2 的 N 次幂减 1,好比咱们传的 5 进来,要确保是 2 的 N 次幂,那么确定是须要调整为 2 的 3 次 幂,即:8,这时候我么须要作的就是把后面的 3101 调整为 111 ,就能够获得 2 的 3 次幂减 1,最后的总容量再加上 1 就能够调整成为 2 的 N 次幂。

仍是以 5 为例,无符号右移 1 位,获得 0000 0000 0000 0000 0000 0000 0000 0010,而后再与原值 0000 0000 0000 0000 0000 0000 0000 0101 执行 | 操做(只有两边同时为 0,结果才会为 0),就能够获得结果 0000 0000 0000 0000 0000 0000 0000 0111,也就是把第二位变成了 1,这时候不论再右移多少位,右移多少次,结果都不会变,保证了后三位为 1,然后面还要依次右移,是为了确保当一个数的第 31 位为 1 时,能够保证除了最高位以外的 31 位所有为 1

到这里,你们应该就会有疑问了,为何要如此大费周章的来保证 HashMap 的容量,即桶的个数为 2 的 N 次幂呢?

为何 HashMap 容量大小要为 2 的 N 次幂

之因此要确保 HashMap 的容量为 2 的 N 次幂的缘由其实很简单,就是为了尽量避免哈希分布不均匀而致使每一个桶中分布的数据不均匀,从而出现某些桶中元素过多,影响到查询效率。

咱们继续看一下 put 方法,下图中红框部分就是计算下标位置的算法,就是经过当前数组(HashMap 底层是采用了一个 Node 数组来存储元素)的长度 - 1 再和 hash 值进行 & 运算获得的:

& 运算的特色就是只有两个数都是 1 获得的结果才是 1,那么假如 n-1 转化为二进制中含有大量的 0,如 1000,那么这时候再和 hash 值去进行 & 运算,最多只有 1 这个位置是有效的,其他位置所有是 0,至关于无效,这时候发生哈希碰撞的几率会大大提高。而假如换成一个 1111 再和 hash 值进行 & 运算,那么这四位都有效参与了运算,大大下降了发生哈希碰撞的几率,这就是为何最开始初始化的时候,会经过一系列的 | 运算来将其第一个 1 的位置以后全部元素所有修改成 1 的缘由。

谈谈 HashMap 中的哈希运算

上面谈到了计算一个 key 值最终落在哪一个位置时用到了一个 hash 值,那么这个 hash 值是如何获得的呢?

下图就是 HashMap 中计算 hash 值的方法:

咱们能够看到这个计算方法很特别,它并不只仅只是简单经过一个 hashCode 方法来获得,而是还同时将 hashCode 获得的结果无符号右移 16 位以后再进行异或运算,获得最终结果。

这么作的目的就是为了将高 16 位也参与运算,进一步避免哈希碰撞的发生。由于在 HashMap 中容量老是 2 的 N 次幂,因此若是仅仅只有低 16 位参与运算,那么有很大一部分状况其低 16 位都是 1,因此将高 16 位也参与运算能够必定程度避免哈希碰撞发生。然后面之因此会采用异或运算而不采用 &| 的缘由是若是采用 & 运算会使结果偏向 1,采用 | 运算会使结果偏向 0^ 运算会使得结果能更好的保留原有特性。

put 元素流程

put 方法前面的流程上面已经提到,若是 HashMap 没有初始化,则会进行初始化,而后再判断当前 key 值的位置是否有元素,若是没有元素,则直接存进去,若是有元素,则会走下面这个分支:

这个 else 分支主要有 4 个逻辑:

  1. 判断当前 key 和原有 key 是否相同,若是相同,直接返回。
  2. 若是当前 key 和原有 key 不相等,则判断当前桶存储的元素是不是 TreeNode 节点,若是是则表示当前是红黑树,则按照红黑树算法进行存储。
  3. 若是当前 key 和原有 key 不相等,且当前桶存放的是一个链表,则依次遍历每一个节点的 next 节点是否为空,为空则直接将当前元素放进链表,不为空则先判断两个 key 是否相等,相等则直接返回,不相等则继续判断 next 节点,直到 key 相等,或者 next 节点为空。
  4. 插入链表以后,若是当前链表长度大于 TREEIFY_THRESHOLD,默认是 8,则会将链表进行切换到红黑树存储。

处理完以后,最后还有一个判断就是判断是否覆盖旧元素,若是 e != null,则说明当前 key 值已经存在,则继续判断 onlyIfAbsent 变量,这个变量默认就是 false,表示覆盖旧值,因此接下来会进行覆盖操做,而后再把旧值返回。

扩容

HashMap 中存储的数据量大于阈值(负载因子 * 当前桶数量)以后,会触发扩容操做:

因此接下来让咱们看看 resize 方法:

第一个红框就是判断当前容量是否已经达到了 MAXIMUM_CAPACITY,这个值前面提到了是 2 的 30 次幂,若是达到了这个值,就会将扩容阈值修改成 int 类型存储的最大值,也就是不会再出发扩容了。

第二个红框就是扩容,扩容的大小就是将旧容量左移 1 位,也就是扩大为原来的 2 倍。固然,扩大以后的容量若是不知足第二个红框中的条件,则会在第三个红框中被设置。

扩容以后原有数据怎么处理

扩容很简单,关键是原有的数据应该如何处理呢?不看代码,咱们能够大体梳理出迁移数据的场景,没有数据的场景不作考虑:

  1. 当前桶位置只有本身,也就是下面没有其余元素。
  2. 当前桶位置下面有元素,且是链表结构。
  3. 当前桶位置下面有元素,且是红黑树。

接下来让咱们看看源码中的 resize 方法中的数据迁移部分:

红框部分比较好理解,首先就是看当前桶内元素是不是孤家寡人,若是是,直接从新计算下标而后赋值到新数据便可,若是是红黑树,则打散了重组,这部分暂且略过,最后一个 else 部分就是处理链表部分,接下来就让咱们重点看一下链表的处理。

链表数据处理

链表的处理有一个核心思想:链表中元素的下标要么保持不变,要么在原先的基础上在加上一个 oldCap 大小

链表的数据处理完整部分源码以下图所示:

关键条件就是 e.hash & oldCap,为何这个结果等于 0 就表示元素的位置没有发生改变呢?

在解释这个问题以前,须要先回忆一下 tableSizeFor 方法,这个方法会将 n-1 调整为相似 00001111 的数据结构,举个例子:好比初始化容量为 16,长度 n-1 就是 01111,而 n 就是 10000,因此若是 e.hash & oldCap ==0 就说明 hash 值的第 5 位是 010000 扩容以后获得的就是 100000,对应的 n-1 就是 011111,和原先旧的 n-1 的差别之处就是第 5 位(第 6 位是 0 不影响计算结果),因此当 e.hash & oldCap==0 就说明第 5 位对结果也没有影响,那么就说明位置不会变,而若是 e.hash & oldCap !=0,就说明第 5 位数影响到告终果,而第 5 位若是计算结果为 1,则获得下标的位置刚好多出了一个 oldCap 的大小,即 16。其余位置扩容也是一样的道理,因此只要 e.hash & oldCap==0,说明下标位置不变,而若是不等于 0,则下标位置应该再加上一个 oldCap

最后的循环完节点以后,处理源码以下所示:

同理的 32 就是 100000,这就说明了一件事,那就是只须要关注最高位的 1 便可,由于只有这一位数和 e.hash 参与 & 运算时可能获得 1

总结

本文主要分析了 HashMap 中是如何进行初始化,以及 put 方法是如何进行设置,同时也介绍了为了防止哈希冲突,将 HashMap 的容量设置为 2 的 N 次幂,最后介绍了 HashMap 中的扩容。

相关文章
相关标签/搜索