众所周知 HashMap 底层是基于 数组 + 链表 组成的,不过在 Jdk1.7 和 1.8 中具体实现稍有不同。在 Jdk1.8 中HashMap 中又引入了红黑树的数据结构,这里我们主要以 Jdk1.8 中的 HashMap 为例,进行对其源码的深入理解。
首先我们先来看一看 Jdk1.8 中 HashMap 实现的数据结构,其示意图如下(Jdk1.7 中的HashMap没有下图中的红黑色部分,其余基本一致)
我们先看上图中的最左面,是一个哈希表,也称为哈希数组,数组的每个元素都是一个单链表的头节点,链表是用来解决冲突的,如果不同的key映射到了数组的同一位置处,就将其放入单链表中。(这里待会我们会详细介绍,红黑树也可先行忽略)。
HashMap 初始化
这个数组是一个默认初始容量为 16 的,我们也可以自行设置它的容量,但是它要求我们设置时,必须是 2 的幂次方(原理待会详细介绍)
我们发现 HashMap 的无参构造方法,根本就没有对 HashMap 进行初始化,也没有看见我们定义其初始容量,其中只有对其 loadFactor 进行赋值为 0.75 。这里也是一个知识点,是我们 HashMap 中的加载因子,现在我们现行忽略,待会详细介绍,因为我们要先看看我们的 HashMap 的初始化呀。
其实我们的 HashMap 初始化并不是在其构造函数之中,而是在其 put() 方法时,才会进行初始化
我们在 put() 方法时,先进行判断当前哈希表(哈希数组)table 是否为空,如果为空,则进入其 resize() 方法。
这里我们进入其 resize() 方法,会获取一系列的原来的旧值,我们现在还没创建都没有,所以都不用看,直接看上图红框内的信息,发现会取其默认值,其中一个是我们之前看到的加载因子 0.75,另一个就是我们默认的哈希数组默认大小了,就是 16 (其上面还有注释告诉我们一定要是 2 的幂次方)。然后我们用 16*0.75 得到 12 的值,这个值的作用稍后讲解。
我们自己在 resize() 方法向下,查看其方法
我们会将刚刚计算的值 12 传递给 threshold,并且会初始化出一个 Node[] 数组出来,这个数组就是我们的哈希表(哈希数组),下面的判断肯定为空,直接会跳过,就先不看了,然后 resize() 方法结束了,把我们初始化的哈希数组返回出去。
我们之前还说过可以设置哈希数组的初始大小,但是代码中有注释要求我们一定要是 2 的幂次方,但是它怎么知道我们一定会听话呢?我非要传递个 15 进入,我们就像定义哈希数组的初始大小为 15
我们还设置了最大容量数,不能超过该 1 >> 30,否则按 1 >> 30 来算,这个我们只是随便提一下,我们主要的是要看我们的 tableSizeFor() 方法
我们发现就算我们的设置了 15 之类不为 2 的幂次方的数,我们的构造函数中,也会通过 tableSizeFor() 方法将其强行变成 2 的幂次方数,如 15 --> 16,20 -->32 。这里我们就会发现这个 2 的幂次方对我们的 HashMap 真的是很非常非常的重要呀,为什么呢?我们接下来会详细讲解的。
Node的数据结构
刚刚我们已经把我们哈希表(哈希数组)初始化的过程,根据其源码详细了了解了其过程。我们知道了 HashMap 在进行第一次 put() 方法操作时,会初始化创建一个 Node[] 出来。我们都知道,我们在 HashMap 中的数据是以 key-value 的形式存在的。那么我们 HashMap 中对于 key-value 是如何存储的呢?显然是以 Node 节点,因为我们初始化哈希表时,就是初始化的 Node[] 数组,现在我们就来看一看 Node 的数据结构
我们可以看出我们的 key-value 果然是存储在我们的 Node 数据结构之中,这个 Node 对象在 HashMap 中使用内部类完成的。除了存储我们的 key、value,其还存储了 hash、next,对于 next 我们应该很好理解,一开始在我们介绍的 HashMap 示意图中,数组的每个元素都是一个单链表的头节点,这个 next 也是一个Node类型,所以肯定是指向单链表的下一个节点的啦。
至于其 hash 值,是判断我们 put 一个数据时,这个数据会存放在哈希数组的哪一个位置时使用的,可以看做 hash 值是可以确定其节点在哈希数组的位置的。那么它存储的是什么类型呢?
就是我们 key 的 hashCode 的值,转换为二进制,让其高位和低位进行按位异或进行处理(这也就是我们所说的扰动函数),然后存储在 hash 之中。(至于为什么这么做,我们接下来介绍)。
如何确定插入哈希表中的位置
这里我们就紧接着上面,来谈谈我们是如何确定 put 进一个元素,它是如何确定其在哈希表中的位置的,我们上面已经介绍了 Node 中的 hash 值了
我们看见我们会拿哈希表的容量大小 -1(如果是默认的话,就是 16-1 = 15),然后与我们存在的 hash 值进行 按位与运算。至于上面所说的问题,hash 值为什么要去 key 的值,将其高位和低位进行按位异或呢?
我们上面还有了哈希表一个位置,是可以存在多个元素的,多个元素会使用单链表的形式,但是单链表过长肯定就是查询的效率就很变得很低,所以我们肯定是希望我们每次 put 元素时,肯定是尽可能的分散开来,每个位置都可以存储些。
我们在判断其位置时,采用了 hash 值与数组大小进行按位与(&),如果数组大小为16,那么就是与15进行计算,15 的二进制就是 1111,所以我们的 hash 值前面的值都是无效的,只看最后面四位就可以判断去位置了,通过按位与运行(&),我们发现 15 的 1111 就不起作用了,就是意味着我们的元素位与哈希表的位置,完全有 hash 值取决。
但是我们的哈希值万一前几位不同,就是最后面 4 位相同,那么我们每次都会进入哈希表的同一位置,所有我们将 key 的 hashCode 高位与地位进行按位异或(^),来避免这个问题,那么你可能会问按位与(&)、按位或(|)可以么,我们可以看个图,就会发现按位异或是最公平的,是最可能使其分散开来插入哈希表中的。
链表与红黑树
我们知道我们的 HashMap 有个哈希数组,我们并不是一个位置只存在一个元素,我们可以只存在一个元素,但是要是有别的元素也想存在在数组的同一位置,我们就将其作为一个链表存在,这就是我们 Jdk1.7 的HashMap。
虽然我们前面也说了,我们尽量会使其均匀分布,但是还有有可能会有很多元素都想存在哈希数组的同一个地方,那我们我们的链表就很非常的长,其查找效率就会降低,这是我们 Jdk1.8 就进行了改进,当一个单链表的节点数大于 8 时,它就会自动将其单链表转换为红黑树进行存储。
当我们 put 元素时,我们首先会判断其元素插入的位置,是否没空,为空直接插入;不为空,那就有三种情况了,已有一个元素、已有一个链表、已有一颗红黑树。当该位置已有一个元素时,我们就判断是否相等,是不是需要替换掉。
然后我们在来看看其如果是链表的情况下,是如何的
我们就会发现,当其数量大于等于8时,我们就将其改造成红黑树。我们在其下面还看见另一个状态值为 6 ,这个其实就是当我们 remove 元素时,如果在红黑书中,会进行判断,如果元素个数小于等于6时,就会从红黑树变成链表结构。
另外我们的链表长度达到了 8 之后,一定会转化为红黑树么,我们可以进入其链表转红黑树的方法中查看一下
我们会发现就算链表的长度大于等于 8 后,我们在准备转换成红黑树之前,我们会进行判断,查看下当前的哈希表的大小是不是大于 64,否则的话就不会转化为红黑树,而是进行扩容,这样可以避免在哈希表建立初期(默认为 16),多个键值对恰好被放入了同一个链表中而导致不必要的转化。
因为哈希表容量较小时,同一位置重复可能性较高,我们对数组进行扩容,同样可以使同一位置的元素进行分散开来。
数组的扩容
我们再试下如果我们数组容量一直为16,但是我们 put 的元素非常非常多的话,那么我们哈希数组中每个位置下都存储了非常非常多数量的元素,那么性能肯定就非常低了。
那么我们怎么解决呢?当然是想把数组进行扩容呀,我们发现在 put() 方法内,会有个变量 modCount 帮助我们计数的,当其大于 threshold 的值,就需要进行数组的扩容(threshold 的值,上面也说明了,就是默认的加载因子乘以我们的数组大小(默认16))
我们发现其哈希表的扩容,每次都是扩大一倍,然后我们的下次需要达到扩容数量的值,也会进行更新。
扩容完就结束了么?当然没有,我们既然进行扩容了,那么就需要进行利用呀,不然进行扩容干嘛呢?
上面我们在说对一个HashMap进行 put 操作时,想在一个位置插入一个元素,这个位置原来的情况一定会有这三种情况,要不为空,可以直接插入,要不就是该位置也存在了其他元素,所以要不就是以链表形式进行存储,要不就是以红黑树的形式进行存储。
那么我们在扩容后移动数据时,也是有着不同情况的数据,要进行移动,要不原来就是空,无需移动;如果只有一个元素存在着下次有元素存储在该处,那么我们直接以新的容量大小进行计算,我们先判断其 next 节点为空,保证其只有一个元素,然后直接以新的容量 newCap - 1 与 hash 值做按位与(&)运算,获取新的位置。
我们第一次初始化的时候 oldTab 为空,我们就不需要看了,这次进行扩容,肯定是有原始大小的,除了这种为空的情况,还有就是我们的红黑树的情况。
最下面的 else 就是我们的链表形式的移动了,这里就没有用我们的新的容量 -1 去重新计算新的位置了,而是采用了直接和旧的容量进行按位与(&)运算,然后要不就是还是原来的位置,要不就是原来的位置直接加上我们原容量的大小。这是为什么呢?
其实,这是一个非常巧妙的方式,和我们之前按位与(&)现有容量 - 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 进哈希表时,如果该位置已经有元素存储了,我们就查到链表的尾部,即尾插法。
我们一直取链表的下一个,直到最后一个,及它的 next 为空,我们就插入新元素,而在我们 Jdk1.7 中的HashMap 不是,Jdk1.7 中采用的是头插入,就是新的元素进来了,如果该位置已有元素存在,那么我们就直接插在链表的第一个。
另外我们在源码 put() 方法中,发现其是有返回值的,这个我们通常都是就直接 put 就完事了,还真没注意到其的返回值,我们来看一看
我们发现,就是 put() 方法要是会覆盖某个值,它就会把旧值返回出来,否则返回 null