众所周知 HashMap 底层是基于 数组 + 链表 组成的,不过在 Jdk1.7 和 1.8 中具体实现稍有不同。在 Jdk1.8 中HashMap 中又引入了红黑树的数据结构,这里我们主要以 Jdk1.8 中的 HashMap 为例,进行对其源码的深入理解。

首先我们先来看一看 Jdk1.8 中 HashMap 实现的数据结构,其示意图如下(Jdk1.7 中的HashMap没有下图中的红黑色部分,其余基本一致)
详解HashMap实现原理
我们先看上图中的最左面,是一个哈希表,也称为哈希数组,数组的每个元素都是一个单链表的头节点,链表是用来解决冲突的,如果不同的key映射到了数组的同一位置处,就将其放入单链表中。(这里待会我们会详细介绍,红黑树也可先行忽略)。


HashMap 初始化


这个数组是一个默认初始容量为 16 的,我们也可以自行设置它的容量,但是它要求我们设置时,必须是 2 的幂次方(原理待会详细介绍)
详解HashMap实现原理
详解HashMap实现原理
我们发现 HashMap 的无参构造方法,根本就没有对 HashMap 进行初始化,也没有看见我们定义其初始容量,其中只有对其 loadFactor 进行赋值为 0.75 。这里也是一个知识点,是我们 HashMap 中的加载因子,现在我们现行忽略,待会详细介绍,因为我们要先看看我们的 HashMap 的初始化呀。


其实我们的 HashMap 初始化并不是在其构造函数之中,而是在其 put() 方法时,才会进行初始化
详解HashMap实现原理
详解HashMap实现原理
详解HashMap实现原理
我们在 put() 方法时,先进行判断当前哈希表(哈希数组)table 是否为空,如果为空,则进入其 resize() 方法。
详解HashMap实现原理
详解HashMap实现原理
这里我们进入其 resize() 方法,会获取一系列的原来的旧值,我们现在还没创建都没有,所以都不用看,直接看上图红框内的信息,发现会取其默认值,其中一个是我们之前看到的加载因子 0.75,另一个就是我们默认的哈希数组默认大小了,就是 16 (其上面还有注释告诉我们一定要是 2 的幂次方)。然后我们用 16*0.75 得到 12 的值,这个值的作用稍后讲解。


我们自己在 resize() 方法向下,查看其方法
详解HashMap实现原理
我们会将刚刚计算的值 12 传递给 threshold,并且会初始化出一个 Node[] 数组出来,这个数组就是我们的哈希表(哈希数组),下面的判断肯定为空,直接会跳过,就先不看了,然后 resize() 方法结束了,把我们初始化的哈希数组返回出去。


我们之前还说过可以设置哈希数组的初始大小,但是代码中有注释要求我们一定要是 2 的幂次方,但是它怎么知道我们一定会听话呢?我非要传递个 15 进入,我们就像定义哈希数组的初始大小为 15

详解HashMap实现原理
详解HashMap实现原理
详解HashMap实现原理
详解HashMap实现原理
我们还设置了最大容量数,不能超过该 1 >> 30,否则按 1 >> 30 来算,这个我们只是随便提一下,我们主要的是要看我们的 tableSizeFor() 方法
详解HashMap实现原理
我们发现就算我们的设置了 15 之类不为 2 的幂次方的数,我们的构造函数中,也会通过 tableSizeFor() 方法将其强行变成 2 的幂次方数,如 15 --> 16,20 -->32 。这里我们就会发现这个 2 的幂次方对我们的 HashMap 真的是很非常非常的重要呀,为什么呢?我们接下来会详细讲解的。


Node的数据结构


刚刚我们已经把我们哈希表(哈希数组)初始化的过程,根据其源码详细了了解了其过程。我们知道了 HashMap 在进行第一次 put() 方法操作时,会初始化创建一个 Node[] 出来。我们都知道,我们在 HashMap 中的数据是以 key-value 的形式存在的。那么我们 HashMap 中对于 key-value 是如何存储的呢?显然是以 Node 节点,因为我们初始化哈希表时,就是初始化的 Node[] 数组,现在我们就来看一看 Node 的数据结构
详解HashMap实现原理
我们可以看出我们的 key-value 果然是存储在我们的 Node 数据结构之中,这个 Node 对象在 HashMap 中使用内部类完成的。除了存储我们的 key、value,其还存储了 hash、next,对于 next 我们应该很好理解,一开始在我们介绍的 HashMap 示意图中,数组的每个元素都是一个单链表的头节点,这个 next 也是一个Node类型,所以肯定是指向单链表的下一个节点的啦。
详解HashMap实现原理

至于其 hash 值,是判断我们 put 一个数据时,这个数据会存放在哈希数组的哪一个位置时使用的,可以看做 hash 值是可以确定其节点在哈希数组的位置的。那么它存储的是什么类型呢?
详解HashMap实现原理
就是我们 key 的 hashCode 的值,转换为二进制,让其高位和低位进行按位异或进行处理(这也就是我们所说的扰动函数),然后存储在 hash 之中。(至于为什么这么做,我们接下来介绍)。


如何确定插入哈希表中的位置


这里我们就紧接着上面,来谈谈我们是如何确定 put 进一个元素,它是如何确定其在哈希表中的位置的,我们上面已经介绍了 Node 中的 hash 值了
详解HashMap实现原理
我们看见我们会拿哈希表的容量大小 -1(如果是默认的话,就是 16-1 = 15),然后与我们存在的 hash 值进行 按位与运算。至于上面所说的问题,hash 值为什么要去 key 的值,将其高位和低位进行按位异或呢?

我们上面还有了哈希表一个位置,是可以存在多个元素的,多个元素会使用单链表的形式,但是单链表过长肯定就是查询的效率就很变得很低,所以我们肯定是希望我们每次 put 元素时,肯定是尽可能的分散开来,每个位置都可以存储些。

我们在判断其位置时,采用了 hash 值与数组大小进行按位与(&),如果数组大小为16,那么就是与15进行计算,15 的二进制就是 1111,所以我们的 hash 值前面的值都是无效的,只看最后面四位就可以判断去位置了,通过按位与运行(&),我们发现 15 的 1111 就不起作用了,就是意味着我们的元素位与哈希表的位置,完全有 hash 值取决。

但是我们的哈希值万一前几位不同,就是最后面 4 位相同,那么我们每次都会进入哈希表的同一位置,所有我们将 key 的 hashCode 高位与地位进行按位异或(^),来避免这个问题,那么你可能会问按位与(&)、按位或(|)可以么,我们可以看个图,就会发现按位异或是最公平的,是最可能使其分散开来插入哈希表中的。

详解HashMap实现原理


链表与红黑树


我们知道我们的 HashMap 有个哈希数组,我们并不是一个位置只存在一个元素,我们可以只存在一个元素,但是要是有别的元素也想存在在数组的同一位置,我们就将其作为一个链表存在,这就是我们 Jdk1.7 的HashMap。

虽然我们前面也说了,我们尽量会使其均匀分布,但是还有有可能会有很多元素都想存在哈希数组的同一个地方,那我们我们的链表就很非常的长,其查找效率就会降低,这是我们 Jdk1.8 就进行了改进,当一个单链表的节点数大于 8 时,它就会自动将其单链表转换为红黑树进行存储。


当我们 put 元素时,我们首先会判断其元素插入的位置,是否没空,为空直接插入;不为空,那就有三种情况了,已有一个元素、已有一个链表、已有一颗红黑树。当该位置已有一个元素时,我们就判断是否相等,是不是需要替换掉。
详解HashMap实现原理
然后我们在来看看其如果是链表的情况下,是如何的
详解HashMap实现原理
详解HashMap实现原理
我们就会发现,当其数量大于等于8时,我们就将其改造成红黑树。我们在其下面还看见另一个状态值为 6 ,这个其实就是当我们 remove 元素时,如果在红黑书中,会进行判断,如果元素个数小于等于6时,就会从红黑树变成链表结构。


另外我们的链表长度达到了 8 之后,一定会转化为红黑树么,我们可以进入其链表转红黑树的方法中查看一下
详解HashMap实现原理
详解HashMap实现原理
我们会发现就算链表的长度大于等于 8 后,我们在准备转换成红黑树之前,我们会进行判断,查看下当前的哈希表的大小是不是大于 64,否则的话就不会转化为红黑树,而是进行扩容,这样可以避免在哈希表建立初期(默认为 16),多个键值对恰好被放入了同一个链表中而导致不必要的转化。

因为哈希表容量较小时,同一位置重复可能性较高,我们对数组进行扩容,同样可以使同一位置的元素进行分散开来。


数组的扩容


我们再试下如果我们数组容量一直为16,但是我们 put 的元素非常非常多的话,那么我们哈希数组中每个位置下都存储了非常非常多数量的元素,那么性能肯定就非常低了。

那么我们怎么解决呢?当然是想把数组进行扩容呀,我们发现在 put() 方法内,会有个变量 modCount 帮助我们计数的,当其大于 threshold 的值,就需要进行数组的扩容(threshold 的值,上面也说明了,就是默认的加载因子乘以我们的数组大小(默认16))
详解HashMap实现原理
我们发现其哈希表的扩容,每次都是扩大一倍,然后我们的下次需要达到扩容数量的值,也会进行更新。
详解HashMap实现原理
扩容完就结束了么?当然没有,我们既然进行扩容了,那么就需要进行利用呀,不然进行扩容干嘛呢?


上面我们在说对一个HashMap进行 put 操作时,想在一个位置插入一个元素,这个位置原来的情况一定会有这三种情况,要不为空,可以直接插入,要不就是该位置也存在了其他元素,所以要不就是以链表形式进行存储,要不就是以红黑树的形式进行存储。

那么我们在扩容后移动数据时,也是有着不同情况的数据,要进行移动,要不原来就是空,无需移动;如果只有一个元素存在着下次有元素存储在该处,那么我们直接以新的容量大小进行计算,我们先判断其 next 节点为空,保证其只有一个元素,然后直接以新的容量 newCap - 1 与 hash 值做按位与(&)运算,获取新的位置。
详解HashMap实现原理
我们第一次初始化的时候 oldTab 为空,我们就不需要看了,这次进行扩容,肯定是有原始大小的,除了这种为空的情况,还有就是我们的红黑树的情况。
详解HashMap实现原理
最下面的 else 就是我们的链表形式的移动了,这里就没有用我们的新的容量 -1 去重新计算新的位置了,而是采用了直接和旧的容量进行按位与(&)运算,然后要不就是还是原来的位置,要不就是原来的位置直接加上我们原容量的大小。这是为什么呢?
详解HashMap实现原理
其实,这是一个非常巧妙的方式,和我们之前按位与(&)现有容量 - 1 的结果一致,为什么呢?比如我们原来是 16 位,现在扩容一倍,也就是 32 位,原来我们是用 hash 值和 16-1 = 15,也是就 1111 进行按位与(&),现在我们应该是以 32-1 = 31,也就是 1 1111 进行按位与(&),但是我们没有,我们是直接以 hash 值与旧容量进行按位与(&),就是16,也就是 1 0000。

我们可以从上述看出,与 1 1111 按位与,就是需要比较 hash 值的后五位,但是与 1 0000 按位与,我们只比较了倒数第 5 位,后四位就没管了,为什么呢,因为我们之前已经比较过来呀,要是倒数第五位相同,那么结果肯定一致,要是不相同,那么就是倒数第五位的大小 2^(5-1) = 16,这个16就是当前位置多出了数值,刚好是我们原容量的大小。


为什么要强制哈希表的容量为2的幂次方


另外我们一直提到数组的容量必须是 2 的幂次方,经过上述我们的介绍,我们应该也清楚了,首先我们在通过 key 的 hashCode 在经过扰动函数后,与其 容量-1 进行按位与(&)取得其在哈希数组中的位置。

我们都知道 2 的幂次方 -1 用二进制都是 1111… 的形式,要是我们不是这样的呢?如 1000… 类似的,包含了 0 ,那么我们按位与(&)所有与 0 相与的数是不是都是无效的呢?

比如与 1000 进行按位与(&),其实有效位只有一位,那么我们元素选择哈希数组的位置是不是就不会尽量分散了,而是比较的集中。


再者,我们的数组扩容时,如果不是 2 的幂次方,如上面说我们和 1000 按位与,那我们的哈希表肯定就是 1001 也就是 9,那么我们进行扩容 9 << 1 = 18

我们看看原来我们应该和 9-1 = 8,即 1000 进行相与,现在我们要和 18-1 = 17,即 1 0001 进行相与,那我们我们还能只比较新增的那一位么,后四位我们不比较?还像不可以,因为后四位已经变了,不一样了。



上述就基本介绍完了,我们 Jdk.8 的HashMap的原理,以及一些小细节,也都是一些面试必问的点,还有就是 Jdk.7 的 HashMap 我们也说了基本与我们 Jdk.8 类似,就是没有红黑树,另外还有一点元素在 put 进哈希表时,如果该位置已经有元素存储了,我们就查到链表的尾部,即尾插法。
详解HashMap实现原理
我们一直取链表的下一个,直到最后一个,及它的 next 为空,我们就插入新元素,而在我们 Jdk1.7 中的HashMap 不是,Jdk1.7 中采用的是头插入,就是新的元素进来了,如果该位置已有元素存在,那么我们就直接插在链表的第一个。


另外我们在源码 put() 方法中,发现其是有返回值的,这个我们通常都是就直接 put 就完事了,还真没注意到其的返回值,我们来看一看
详解HashMap实现原理
我们发现,就是 put() 方法要是会覆盖某个值,它就会把旧值返回出来,否则返回 null
详解HashMap实现原理

相关文章: