面试必备:HashMap(JDK1.8)原理以及源码分析

对于HashMap想必大家都不陌生,无论是平时code还是面试都经常和它打交道。今天我们通过源码的层面来分析一下它的实现原理,注意本文基于的是JDK1.8。

问题是从哪边开始聊起呢?我觉得不妨先从一段熟悉的代码开始。

Map<Integer, String> map = new HashMap<Integer, String>();

然后我们会迫不及待点开HashMap这个类,发现里面有大量的属性和方法,一脸懵逼。那就直接点开put方法?点了之后发现下面这段代码。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,

依旧一脸懵逼,完全没有看下去的欲望,怎么办?为什么会这样?原因是我们不了解HashMap的数据结构,什么意思?也就是说,当把key,value存储到HashMap中之后,不知道它们是以何种数据排列的方式去存储的,这样根本就不明白源码写的思路是什么,所以我们先把目光转向到数据结构,更准备地说是HashMap的数据结构。

1 HashMap数据结构

从网上的很多资料我们知道,HashMap1.7的数据结构是数组+链表,HashMap1.8的数据结构是数组+链表+红黑树,下面这张图我画出了HashMap1.8的数据结构。

image

有些哥们可能会说,我在网上看到的不是这样。这时候你可以发挥空间想象能力逆时针旋转个90度,也就是下面这样的展示形式。

image.gif

不管如何,反正都能体现出是数组+链表+红黑树的数据结构的方式。虽然数据结构是知道了,但是关键是图解中的每个小格子表示的是什么呢?对于我们了解HashMap的原理和源码有什么作用吗?先别急,一个个来看,先看每个小格子表示什么。

2 每个小格子的含义

我们可以猜想一下,每个小格子表示里面至少包含了key,value,为什么这么说呢?因为hashmap.put(key,value)之后,就会形成上述的数组+链表+红黑树的结构,那这个结构中的每个小格子至少把key和value涵盖进去了,如果不是,那么key,value怎么存储呢?ok,假如这个猜想是对的,那Java中想要同时存储key,value两个值,该怎么表示呢?我觉得可以用XXX类,比如下面的伪代码。

class XXX{

我觉得靠谱,如果真的是这样,那么要想形成上述的数据结构的图解,只需要创建一个个XXX类的对象,然后排列好它们的方式不就ok了吗?没错,关键这个排列要形式数组+链表+红黑树的数据结构。我们暂且给XXX一个名称叫”Node”,于是就是这样了。

class Node{

这时候有些哥们想,上面都是你主观的一个猜想,源码中真的是这样做的吗?我们不妨在HashMap类中搜索一下”Node”,发现有这样一个内部类。

static class Node<K,V> implements Map.Entry<K,V> {

ok,至此每个小格子表示的含义猜想和验证已经完成,发现源码中真的也有这样一个Node类,并且里面维护了key和value属性,至于其他属性是什么含义,我们后面再聊。

3 Node的排列方式/数据结构

上面既然已经验证了小格子对应的就是Node类,或者可以称为是Node节点。接下来我们的任务就是将这些节点来排列成数组+链表+红黑树的形式。

3.1 数组

想要将Node节点形式数组,按照以往的经验只需要在类中维护一个Node[]的属性即可,那么源码中是否有这样做呢?

/**

我们会发现源码中维护了这样一个成员变量,Node<K,V>[] table,这样数组的排列方式就解决了。

3.2 链表

链表无非就是Node节点和Node节点的关系的维护,这个关系可以分为单向链表或者双向链表,在前面的图解中我们发现这个链表是单向的,但是如果要想在源码中验证这个单向链表,只需要在原来的Node类中维护一个Node属性,如下所示。

class Node{

那源码中是否是这样做的呢?通过下面代码中的Node<K,V> next属性可以发现的确是单向链表的方式

static class Node<K,V> implements Map.Entry<K,V> {

3.3 红黑树

红黑树是一种特殊的二叉树,对于二叉树我们比较熟悉,会有父节点,左子树,右子树等。

这时候我们会想,源码中是否有这样来做呢?搜索”TreeNode”,发现会有这样一段代码。

/**

跟我们的猜想是一样的,有表示父节点的parent属性,有表示左子树的left属性,还有表示右子树的right属性。

3.4 总结

通过上面3点源码的分析,可以感受到数组,链表和红黑树的代码,也就是能够验证HashMap1.8的数据结构是数组+链表+红黑树的实现方式。

4 再看HashMap1.8源码

经过前面的分析,已经能够得到结论HashMap1.8果然是基于数组+链表+红黑树的方式实现的。

这时候再看HashMap1.8源码的实现就会很清晰。

当put(key,value)的时候,肯定先要创建数组,然后基于数组的下标索引创建出链表或者红黑树。这里有一点要注意,上述HashMap的数据结构图解是最终的效果图,但是在没有put任何数据之前,这个数据结构是什么都没有的,也就是一片空白,是需要经过一个个Node节点创建然后形成起来的。

所以先要创建出数组。

4.1 数组的创建

在putVal方法中开始有这段代码。判断table是否为null,也就是Node[]数组是否为null,如果是,则需要先初始化数组的大小。初始化数组的大小是通过resize()方法,我们定位到resize()方法。

if ((tab = table) == null || (n = tab.length) == 0)
  • resize()

注意下面的代码截取的是resize()方法的间断性部分。

//再次判断数组是否为null,如果为null,则oldCap赋值为0

接下来就是根据这个默认数组的大小16初始化数组。

@SuppressWarnings({"rawtypes","unchecked"})

到这里,在上述的数据结构图解中,数组的部分就已经形成。但是里面还没有存元素,这个元素的类型前面我们已经分析过,应该是Node节点,换句话说也就是有key,value等这些组成的Node的对象。

4.2 put之前Node节点位置的确定

Node节点要想存储到上面初始化好的数组中,关键是到底存到哪个位置呢?上述数组初始化的大小为16,也就是Node节点到底是落在0-15索引的哪个位置。有哥们可能会想要不就从下标0开始存储?虽然是可行,但是问题是什么时候存储到1的位置呢?什么时候存储到2的位置呢?似乎这个界限不明确,还是放弃这样的想法。那怎么确定?要不这样,使用Random对象随机出一个0-15之间的数值?好像可以,如果是这样,万一不小心随机的数值一直是1和2,那最终可能只有1和2位置以及下面有Node节点,其他位置没有得到充分的利用,如下图所示。

这时候,1和2所在位置索引下面的节点就会很多。一方面看起来不美观其他位置没有得到充分的利用,另外一方面每次想要查询尾端的节点的值时,要经历过的过程必须知道前一个节点是什么,此时时间和空间复杂度比较高,所以Random这种产生Node节点位置的方式不合理。

image.gif

  • 得到Node节点在数组中的位置

经过上述尝试后,不妨这样,具体的位置由Node节点中的key本身来决定,也就是根据key来得到这个落点值。

我们可以将这个过程分为两步,第一:根据key得到一个整型数;第二:控制这个整形数在0-15之间。

(1)根据key得到整型数hash

key.hashCode():因为在Object类中有一个hashCode()方法,是一个native的方法,可以得到一个int类型的整型数,正好符合我们的想法。

我们暂且用一个int hash=key.hashCode()记录这个整型数的值,后面会调整。

(2)控制整型数hash的范围在0-15之间

int index=hash%16,此时index的结果就是0-15之间,后面这块也会调整。

至少目前为止这个落点我们能够计算出来了,虽然后面还会优化这块内容,但是思路是没错的。

  • 优化控制整型数hash的范围

原来我们是通过hash%16这种方式,但是效率不够高,不妨一起来看下源码中是怎么做的。

if ((p = tab[i = (n - 1) & hash]) == null)

可以发现,源码中是通过hash%(n-1)这种方式,而不是hash%n,注意这里的n是数组的大小,比如默认大小16。

先不管这样做的优势如何,也就是也得到0-15的数值,我们就将这个&计算通过二进制来折腾一下。

hash: 010100101010101010101010101 32位

n-1: 01111 15的二进制表示


index

这个index最终结果最小值为00000,最大值为01111,换算成10进制,也就是0-15,即和hash%n的结果是一样的。那为什么作者使用的是&运算而不是%运算呢?很简单,&这样的效率更高,速度更快,这也是面试中很重要的一个点,大家一定要注意。

  • 优化hash值的计算方式

原来hash的计算方式直接是key.hashCode(),得出的结果直接和n-1进行&运算,得到index之后,就可以确定Node节点的位置了,但是这样真的好吗?其实index的结果真正取决于hash值,因为n-1是01111。

所以hash的值,或者说是hash二进制表示最后的几位决定了index的值,我们希望的是index的值尽可能不一样,这样数组每个索引位置能尽肯能得到充分的利用,雨露均沾嘛,不然index值重复的可能性太高的话,就会形成像原来Random设想的那种方案,一方面不美观,一方面影响时间和空间复杂度。

那么hash值的最后几位能否尽可能不一样呢?或者说源码中对hash的计算方式和我们原来认为的key.hashCode()是否一样呢?不妨一起来看下put方法调用的时候,有一个hash(key)函数,点开该方法代码如下。

static final int hash(Object key) {

我们会发现,它采用的是使用key.hashCode()的高16位和低16位异或的方式

0101010101001100100101010010101010原本是这样

01010101010011001

00101010010101010 ^


hash 这个hash结果采用的是key.hashCode()高低16位进行异或运算后的结果

也就是说这里让key.hashCode()的高16位和低16位都参与了运算,得到的hash值最后几位重复的可能性会大大降低,也就是hash(key)算法的设计。所以平时面试中问HashMap中hash算法的设计是怎样的,就是上面的这个过程。

同时到这里也解决了源码中Node类里面为什么有一个int hash的属性,其实这个属性就是保存的hash算法计算的结果值,这个值确定了,Node节点落点的位置就确定了,也就是按照面向对象的思想,Node节点最终落到哪个数组的位置它自己得知道。

static class Node<K,V> implements Map.Entry<K,V> {

4.3 数组大小为什么是2的N次幂

这时候我们先不着急看put下面的代码,不妨来看一下对于数组默认大小属性的定义。

不难发现,这个DEFAULT_INITIAL_CAPACITY采用的是位移运算,也就是1向左位移4位,也就是1后面加上4个0,也就是10000,换算成10进制,就是2的4次方,即16。

有哥们可能会想为什么采用位移运算?因为速度快。对于DEFAULT_INITIAL_CAPACITY上面有注释,意思是必须是2的N次幂,也就是数组的大小必须是2的N次幂。

/**

这时候来想想为什么呢?不妨再回到计算Node落点的(n-1)&hash。

hash: 010100101010101010101010101 32位

n-1: 01111 15的二进制表示


index

我们上面说过,index的值尽可能的不要重复,不然最终Node节点都集中在一两个索引位置之下了。为了尽可能不重复,hash算法进行了高低16位的异或计算。n-1的值是01111所以index的结果实际上取决于hash的值,试想一下如果n-1不是01111,如果是01110会怎样?这时候hash的二进制结果最后一位无论是1还是0,index重复的可能性就会增加,所以必须保证n-1的结果是01111,换句话说必须保证n是10000这样的形式,也就是n[数组的大小]必须是2的N次幂。

  • 如果在初始化HashMap的时候传入的大小不是2的N次幂呢?

总有人会不按照规则出牌,这时候就需要看HashMap的构造函数。

public HashMap(int initialCapacity, float loadFactor) {

继而来看tableSizeFor(initialCapacity)到底做了什么,我们会发现,这个方法会根据传入的cap得到一个2的N次幂的值作为数组的大小。

/**

4.4 继续源码的put过程

上面已经对数组进行了初始化,也得到了每个Node节点应该在的位置。

这时候比如真的来一个key和value,得到该Node节点应该在的位置,接下来的流程该是如何呢?肯定要判断原来数组该索引位置中是否有Node节点。若没有,则直接将该节点放到该位置;若有,有的话就再看咯。

我们先来看没有的时候,源码是怎么做的。

if ((p = tab[i = (n - 1) & hash]) == null)

如果有的话,则不能直接放到该位置,如下图所示

image.gif

这个时候可以分为三种情况:

如果key值相同,只需要将原来下标位置的value值替换掉即可;

如果key值不同,则将新的节点放到原来索引节点的后面形成单向链表;

如果key值不同,原来索引下面已经是红黑树的数据结构了,则按照红黑树的数据结构将新的节点存储。

4.4.1 仅仅替换value值

image.gif

代码实现

Node<K,V> e; K k;

如果最终e的值不为空,则使用新value替换老的value

if (e != null) { // existing mapping for key

4.4.2 按照红黑树方式存储Node

4.4.3 按链表方式存储Node

else {

4.5 链表转红黑树

通过上述代码可以发现在链表put的过程中,如果链表太长会将其转成红黑树。我们先想想为什么要转?之前说如果index的结果一样,key值不同,会慢慢往Node节点往下延长形成链表的数据结构,但是对于链表而言,长度太长的话,存取效率会低,因为链表要想找到某个节点必须要知道它的上一个节点。

但是即使采用了hash算法,保证了数组的大小是2的N次幂,还是避免不了链表长度慢慢变长,这时候查询或者插入效率降低,怎么办呢?不妨这样将链表的结构变形成树形结构,如下图所示。

image.gif

那转换的条件是什么呢?也就是链表到底多长才需要转呢?在源码中是怎样定义的?

也就是说链表节点长度超过8就需要转红黑树,如果红黑树中节点数目小于6就再转成链表。

而且之前在链表节点不断增加时候代码也是这样判断的。

//如果链表的长度超过某个值,就将链表转红黑树,这块后面会说
/**

4.6 数组扩容的探讨

到这边想必大家都有点累了,此时把自己的脑袋给放空,只回想一个图,就是一开始HashMap的数据结构图,一张由数组+链表+红黑树的图。通过上述的分析,我们发现数组的索引位置会被Node节点占用,而且index相同的情况下还会形成链表或者红黑树的结构。试想有没有这样一种情况,就是数组的索引位置不够用了,或者说虽然可以不断往下形成链表或者红黑树,但是数组的大小难道就一直保持在16不变吗?比如Node节点已经像下面这样的分布了呢?

大家应该能明白我想表达的意思,这时候你会发现数据结构比较复杂,也不利于我们的存取节点了。所以得要指定一个标准,比如整个数据结构中节点的数量超过某个值之后就把数组的大小扩大一下,这样可以有效减轻节点的一个分布压力。就像是链表太长要转红黑树一样。那这个数组扩大的临界值怎么确定呢?

image

4.6.1 扩容/加载因子

在成员变量中有一个这样的值。

/**

4.6.2 扩容标准

这个临界值的确定可以用数组大小*扩容因子,其实在数组初始化方法resize中我们见过这个公式。

newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

这个newThr=16*0.75=12就是扩容的标准,在resize()方法中最终将这个赋值给了一个成员变量,赋值的过程如下所示,也就是说这个threshold等于12。

threshold = newThr;

4.6.3 扩容过程

每当put一个Node节点成功,最后会有这段代码的判断。

也就是说会通过一个成员变量size,默认值为0,记录每次put的次数,如果这个++size>threshold[12],之后就进行resize()操作,也就是进行扩容。

if (++size > threshold)
  • 数组的2倍扩容

到这里,我们能够知道resize()除了有初始化数组的功能,还会有扩容的功能,而且这个扩容会2倍扩容,原因是要保证数组的大小必须是2的N次幂,原因前面已经说过咯。

判断数组的大小,此时数组大小是16。

int oldCap = (oldTab == null) ? 0 : oldTab.length;

此时oldCap的大小则大于0。

if (oldCap > 0) {

当新数组的大小变成32之后,就将新数组的大小创建成功。

Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];

此时会面临一个问题,新创建数组中没有任何Node节点,我们需要将原来数组中的Node节点”搬运“到新的数组中,那怎么搬运呢?

  • 老数组中Node节点搬运到新数组中

搬运要按照节点的类型来区分,我们可以采取这样的方式,先循环遍历原来数组的索引位置,要确保原来数组下表位置不为空才有必要进行搬运。

if (oldTab != null) {

这时候可以分为3种情况。

(1)数组索引下标下面没有元素

这时候只需要使用hash值重新&上新数组n-1的值,计算节点在新数组中的位置即可。

image.gif

if (e.next == null)

(2)数组索引下标下面有元素,且元素类型为红黑树

如果索引下面的节点类型是红黑树,则按照红黑树的方式将Node节点切分,然后移动到新的数组中。

image.gif

else if (e instanceof TreeNode)

(3)数组索引下标下面有所愿,且元素类型为链表

image.gif

这种情况表示原数组索引下面的节点类型为链表,此时要循环遍历链表,使用的是do-while循环。

下面这段代码最重要的其实是这句话

if ((e.hash & oldCap) == 0),也就是这块会计算链表中每个节点的hash&oldCap的值,最终结果和0比较,根据结果进行不同的处理,那么这种结果什么时候为0,什么时候不为0呢?

else { // preserve order

我们把if ((e.hash & oldCap) == 0)这个计算公式写出来

hash: 010010101001100101010101101010

oldCap: 10000 &


result

上述result到底何时才为0?细心的哥们会发现只有hash的倒数第5位位0的时候,结果才会0,否则结果不为0,而且为0的时候result和不为0的result相差是10000,也就是16,也就是oldCap的大小。

本来要移动老数组中链表的Node节点要重新计算hash&(n-1)的值,但是此时只要知道hash的倒数第5位是否为0,就能知道本来应该计算的结果。

如果hash二进制表示倒数第5位为0,即使采用hash&(n-1)的结果还是和原来index一样。

if ((e.hash & oldCap) == 0) {

如果hash二进制表示倒数第5位为1,那么采用hash&(n-1)的结果就会比原来index大oldCap的大小。

else {

其实上述两段代码形成的最终结果是,也就是节点在新的数组中的位置要么是在原来位置,要么是在原来位置+oldCap的位置。

if (loTail != null) {

5 总结

通过本文分析,我们了解了HashMap1.8的数据结构以及源码原理源码实现,包括hash算法,put过程,加载因子,扩容等,希望对大家有所帮助。

猜你喜欢

转载自blog.csdn.net/weixin_47067712/article/details/108294325
今日推荐