HashMap原理(二)——jdk1.8中HashMap扩容底层代码和算法分析

放肆的年华 提交于 2019-12-22 16:24:16

记得曾经有个著名大师谁谁谁曾经说过:你有一个思想,我有一个思想,我们交换一下,一人就有两个思想;你有一个苹果,我有一个苹果,我们交换一下,一人还是一个苹果。那既然这样的话,用我的iPhone 7 换你的 iPhone 11 如何?

这次给大家带来的是HashMap原理第二篇之——HashMap扩容的底层代码和算法分析。需要说明的是本文是基于jdk1.8来进行展开的,今后有机会会和大家分享在jdk1.7中HashMap的实现方式和1.8有哪些区别(扩容方式是其中的区别之一)。有朋友会说,既然HashMap是基于数组+单向链表+红黑树的底层数据结构,链表可以无限地延伸啊,红黑树也可以不停滴往里面放东西啊,还扩容干什么?这样的说法既对也不对,说对是因为HashMap确实是基于单向链表和红黑树的,但是有没有想过不断地往链表上添加元素或者不断地往树里面加东西会怎么样?是不是会导致链表过长以及树的深度增大?是不是进而会提高遍历链表或者红黑树的时间复杂度?最终导致从表现上来看插入和查找操作会越来越慢?所以,到了一定程度对数组扩容还是很有必要的。那又有朋友会问:扩容很简单啊,需要的时候从数组两端往外延伸一下内存空间不就可以了吗?……想啥呢?数组不是拉面,需要的时候从两边往外抻一下,不存在的!数组扩容只能开辟出一个更大的内存空间出来,将原来的内容迁移到新的内存空间里面。OK,对HashMap的底层数据结构有一个清晰的认识后,我们就开始今天的扩容之旅。

HashMap的扩容其实就看一下resize()方法就可以了,直接上源码:

首先我要说一下的是,触发HashMap扩容的时机有两个:一个是第一次调用put()方法的时候,一个是当size > threshold的时候。前面的文章也分析过,HashMap最重要的数据结构——数组是延迟初始化的,也就是在第一次调用put()方法往里面去存放数据的时候初始化的。我们结合源码来看一下在第一次调用put时扩容是怎么实现的以及何时初始化的table数组。

第一次调用put方法还要分两种情况,根据什么来分呢?就根据这个HashMap是怎么来的来划分,也就是在new一个HashMap的时候你调用的是哪个构造方法。

Case 1:调用默认无参构造函数新建HashMap

首先678行将table数组赋给一个局部变量oldTab,此时为null,因为还没有初始化,所以显而易见679行的oldCap是0,注意这里的oldCap是数组的长度,不是size,这两个东东在上一篇文章提到过是不一样的。680行的threshold也是0,因为默认构造器里面也没有赋值。所以682行和691行的if判断条件就为false了,直接进入else语句块。到else里面大家可能会眼前一亮,不仅新容量被设置成默认值16(至于为什么是16请参考上一篇文章)而且也见到了扩容阈值是怎么算的了:数组长度 * 加载因子,当然由于是第一次扩容这里面的两个值都是默认值(16 * 0.75 = 12),然后赋值给newThr变量。接下来697行的newThr不会为0了因为刚赋过值,直接到702行将刚才计算的新扩容阈值12赋值给成员变量threshold,那么下次再扩容时HashMap元素的个数(注意这里是size)必须大于12才能进行。好了,到这里我们可以看到刚才是各种赋值,但此时主角table数组并没有被初始化,仍然为null。再往下看第704行初始化了一个具有newCap容量的数组,而newCap就是刚刚694行的DEFAULT_INITIAL_CAPACITY(即16),705行将数组引用赋值给了table变量,至此一个具有默认初始容量的全新table数组诞生了。由于oldTab在刚进入resize方法的第一行就已经赋值为null了,并且中间没有被修改过,所以706行的if判断不会通过,resize()方法完成!整个扩容过程完成!table数组初始化完成!Yeah~~可以很愉快地买瓶可乐去庆祝一下~~但!是!这才是第一次扩容的第一种情况,没看到上面的标题是Case 1吗?有Case 1就有Case 2啊~好吧,回家一边喝可乐一边看Case 2...

Case 2:调用有参构造函数新建HashMap

在HashMap中,有参构造函数一共有三个,这里只拿public HashMap(int initialCapacity) {}来举例。在上一篇文章中已经分析过,当用户输入一个非2的N次幂的容量时,HashMap会将该容量修改为比输入值大的最小的2的N次幂的值作为哈希表的容量。我们再从头走一遍resize的过程。由于HashMap的设计理念是懒初始化的思想,所以HashMap的所有构造方法都没有去初始化table数组,所以678行oldTab仍然为null,下面的oldCap仍然为0,再下面的oldThr仍然为0,所以682行和691行的判断都不能通过,直接进入到694行else{}代码块里面。等等,好像哪里不对,这样和Case 1的情况不一样吗?问题出在哪里呢?问题就出在第680行。在默认构造器里面成员变量threshold确实是没有赋过值的,但是在构造器public HashMap(int initialCapacity) {}里面情况就不一样了,我们来看一下该构造器的代码。

刚才说的HashMap会把用户输入的任意容量转换成2的N次幂,这个神奇的事情就发生在tableSizeFor()方法里面。通过这个方法的变量名称initialCapacity和这个方法的名字tableSizeFor可以知道它是来进行容量转换的,是将用户输入的容量值转换成另一个符合约定的容量值。但是更神奇的是这个方法的返回值是转变后的容量,竟然赋值给了threshold变量!!!threshold变量是干嘛的?是用来表示扩容阈值的,而不是表示数组大小的,这两个变量代表两个完全不同的概念!可能由于本人水平所限,到现在也没弄明白jdk的作者这么赋值的真正意图,只能心里默念一句“呵呵”......但人家这么写咱也只能这么看,如果在看这篇文章的朋友知道为什么这么做欢迎在下方评论区留言,在下将不胜感激!

经过上面的分析我们知道了resize方法的第680行oldThr变量的值不是0,所以它可以通过691行的判断,然后在692行将局部变量oldThr的值也就是成员变量threshold的值赋值给了一个叫newCap的局部变量里面。写到这里还想吐槽一下:这不多此一举吗?首先在构造方法里将计算后的数组容量赋值给一个代表扩容阈值的变量threshold(全局变量),然后又将threshold的值赋给代表容量的变量newCap(局部变量),然后还在691行加了一句注释:initial capacity was placed in threshold,意思就是初始容量放置在了threshold变量里面……其实全局变量threshold起了一个二传手的作用:就是在构造方法里面计算出一个值先赋给一个全局变量保存一下,目的就是要将这个值从构造方法里面传出去,在需要的时候这个全局变量再赋给某一个局部变量,这种方式很好啊,但就不能定义一个具有容量语义的全局变量(比如叫什么什么capacity)去保存计算后的容量值吗?因为threshold = capacity * load_factor,而load_factor默认是0.75当然用户也可以自定义,即使是自定义也很少有人将加载因子赋值为1,所以绝大多数情况下threshold不等于capacity。退一步说,即使是将加载因子赋值为1,那么在数值上threshold确实等于capacity,但是它俩表示的概念不一样啊,概念不同的两个变量怎么能互相赋值呢???在上面的截图的红框里面将计算出的容量值直接赋给threshold变量,会让读代码的人误以为capacity就是threshold,这哥俩是一回事,但实际上这是两个不同的概念。好吧,回到主题上来,692行赋完值之后到了697行,newThr为0所以可以通过判断,重新计算newThr的值。后面的内容就和Case 1一样了,注意706行至747行的逻辑也不会进去,所以再Case 2里面也不用关心。

上面的Case 1和Case 2都是首次调用put方法时的扩容,文章开始时也说过引起扩容的时机除了第一次调用put方法之外还有就是当size > threshold的时候。下面就要分析当size > threshold时扩容是怎么实现的。

当size > threshold时前面的内容就不分析了,也就是resize方法的678行到第705行和上面分析的过程差不多,读者朋友可以自己先分析一下。这里重点要说的是里面687-689行,非首次扩容那么oldTab肯定不是空,说先判断oldCap是不是大于等于MAXIMUM_CAPACITY,也就是1 << 30,和十进制的1073741824,如果是的话就将老数组长度置为Integer的最大值。然后重点是687-689行,这里我们可以看到,HashMap扩容使容量一次扩两倍,也就是代码里的无符号左移1位(<< 1 相当于乘以2),同时老的扩容阈值也扩为原来的两倍。在705行之前和上面的Case 1以及Case 2的情况差不多,重点是剩下的代码。其实剩下的代码就只有一个if语句块了,也就是706到747行,但是这个if语句块占了resize方法篇幅的半壁江山,可见其重要性!那它是干嘛的呢?其实我们大概扫一眼可以看出来是做数据迁移用的——将老数组的数据迁移到新数组上来。我们来一起看一下if里面到底做了什么。

为了方便描述,把这个占据“半壁江山”的if语句块拿出来单独分析(可能显示的图片不全,需要左右滑动看):

我们来看707行,这里遍历的是老数组,j每增加1就会读取老数组中后一桶的数据,但是在++j之前会将老数组在位置位j的数据迁移到新数组当中。具体是怎么迁移的呢?首先将老数组位置位j的数据取出来赋值给一个局部变量e,然后将该桶位的节点置为null,我在截图中第710行也提出了一个问题,为什么要这样做?其实这么做的原因就是有助于更快地GC。首先将老数组中指向该Node节点(假如是A节点)的引用给断掉,因为上面已经赋值给了局部变量e,也就是变量e持有了Node节点的引用,所以老数组已经没有必要再持有了,在下一次循环后e会被赋为另一个值,持有另一个Node(B节点)的引用,那么e也会断掉上一个Node(A节点)的引用,这样就没有任何一个变量持有A节点的引用,这样GC可以快速地将A节点回收掉。相反,如果在710行没有将oldTab[j]置为null,那么下一次循环e持有B节点的引用时,GC会判断仍然有老数组持有A节点的引用(但此时A节点对老数组来说已经没有任何作用了),这就给内存泄漏的发生埋下了隐患。(好吧,穿插了一点JVM的知识,哈哈...)好,我们继续回到正题。我把整个if语句块分成了三部分,我们来分别看一下:

Section 1:

判断数组桶中第一个节点是不是有下一个节点,如果没有就讲该节点Node放到新数组newTab的相应位置上。这里就有一个问题:什么是相应位置?我们在712行代码可以看到用原有key的hash值与新数组长度(扩容后位原数组的2倍)- 1 进行&运算,从而得到一个新数组下标,并将e放到新数组下标上。数组下标的计算方法在上一篇博客中我已经详细地给大家分析过了,这里就不再赘述。这里重点想说的是:用原有key的hash值和扩为两倍之后新数组长度 - 1进行&运算的结果有什么特点?我们知道,一个数在变成它自己的两倍之后转换成二进制表现为有效位向左移动一位,说明什么?说明在用新长度计算索引下标(e.hash & (newCap - 1))时得出来的下标值要么是原下标,要么是原下标+老数组长度。我们来具体看一下这个结论是怎么得出来的。

我们假设原数组长度为16,扩容后长度为原来的两倍即32,为了方便描述我们把key的hash值表示为H(k)。原来是H(k)与15进行&运算来计算下标,15的二进制的有效位只有后四位,也就是无论H(k)的值是什么样的,最终参与运算的是H(k)的后四位。由于16变32的二进制有效位向左移动1位:

16二进制表示:0001 0000

32二进制表示:0010 0000

也就是有效位多了一位,由原来的5位变成了6位,同理32 - 1的有效位也会比15 - 1的有效位多一位:

15二进制表示:0000 1111

31二进制表示:0001 1111

在计算索引下标时,原来H(k)只有后4位参与运算,现在在计算新数组的索引下标时H(k)就有后5位参与运算了。(敲黑板,划重点!!!)这一点很关键!在jdk1.7中,扩容时原数组中每个节点都要在新数组中重新计算一遍索引下标,在jdk1.8中作者就变聪明了,基于上面的分析大家可以想一想在新数组中还有没有必要重新计算一下新数组的下标?是不是没有必要了!确实是没有必要了!因为在扩容之后数组的长度(包括长度 - 1)向左多出了一位,但后面的值和原来的可是一模一样,也就是说在进行&运算的时候我们只需要和多出来的那一位进行计算就可以了。用上面举的例子(长度由16扩为32)来说,只要拿出H(k)的倒数第5位进行&运算就可以了,后4位不需关心,也无需计算,因为算出来也还是原来的值。那既然只算倒数第5位,后4位是什么已经不重要了,干脆置成0得了。所以,H(k)和0001 1111进行&运算与H(k)和0001 0000进行&运算的结果是一样的,这就是为什么第721行算索引下标的时候oldCap没有减1的原因。

Section 2:

如果原数组在位置位j的地方是一棵红黑树,那么就按照红黑树的插入方式进行迁移,这需要有红黑树的知识,后期有机会的话会和大家单独分享红黑树的插入方式,这里只是知道如果是红黑树就按照红黑树的插入方式进行迁移就可以了。如果不是红黑树,那么说明它是一个单向链表,就会进入else{}代码块,也就是代码区域3。

Section 3:

本区域是对链表进行迁移的。再讲迁移具体过程之前,我们先回过头看一下在Section 1中讲的——在计算索引时为什么oldCap不减1的原因。下面来说一下这么做要达到什么目的。前面分析了H(k)只和数组长度最左边的有效位进行&运算,那么运算的结果要么是0要么是1,转换成十进制有什么特点呢?我们来推到一下:

假设H(k)的二进制表示为(只取后8位):0001 0101

  • 与15(0000 1111)进行&运算,结果为:i1 = 0000 0101,转换成十进制为5

  • 与31(0001 1111)进行&运算,结果为:i2 = 0001 0101,转换成十进制为21

我们可以看到,计算结果后四位全一样,i2比i1多了一个有效位,我们知道在二进制中多了1个有效位且多出的有效位是第N位,就相当于在原来的基础上多了2^(N-1)。我们可以算一下:i2比i1多了1位,多出的这一位是第5位,转换成十进制后就相当于i1 + 2^(5 - 1),也就是5 + 2^4 = 21,正好和i2转换成十进制后的数值吻合,而2^4正好位原数组的长度。

假设H(k)的二进制表示为(只取后8位):0000 0101

  • 与15(0000 1111)进行&运算,结果为:i1 = 0000 0101,转换成十进制为5

  • 与31(0001 1111)进行&运算,结果为:i2 = 0000 0101,转换成十进制为5

这里i2=i1,说明什么问题?说明新数组两倍扩容之后,计算出的新索引值要么和原数组索引一样,要么是原数组索引 + 原数组长度。好了,有了这个结论我们就可以来看Section 3中else{}的代码块了。

首先,先定义了四个局部变量,lo开头的是用来保存计算出的新数组索引和老数组索引相同的Node,而hi开头的是用来保存计算出的新数组索引和老数组索引不同的Node。然后就是一个do{}while(){}循环,假设原数组在下标位j的位置上是一个这样的链表:A->B->C,我就不画图了,大家脑补一下:上面横着放一个数组,在某一个桶的位置上挂一个纵向的单向链表,节点的顺序是A->B->C。

首先拿出原链表第一个节点A的next节点B,然后赋值给next变量。721行刚才也分析过了,计算索引为0说明和原数组索引一样,一样的话就往lo开头的变量里面放;不一样说明这个节点要放在新数组的新索引的地方,往hi开头的变量放。代码区域3.1和3.2的过程类似且都比较简单就不在这里展开了,具体过程大家可以根据ABC这三个节点在头脑里跟着while循环走两遍。不过有四点需要注意的是:第一,每次插入都是给tail变量赋值,也就是说在链表迁移时jdk1.8的做法是往队列尾插入数据,也就是我们说的尾插法,为什么要强调是jdk1.8呢,因为在1.7中是从头部插入的,而且迁移完成之后链表的顺序被倒置了;第二,从while循环走出来之后,假如每一个节点的e.hash & oldCap全是0,那么最后的结果会是A->B->C->C,所以736~743行将尾节点的next节点置为null;第三,在第742行可以看到如果e.hash & oldCap不是0,那么他会将新链表的头节点放到新数组的新索引上,这个新索引就是j + oldCap,和上面分析的一致;第四,738行和742行是将新链表的头节点放到新数组相应的位置上,这个动作是在while循环之后进行的,这个主要是解决jdk1.7中链表迁移成环的问题,这个问题等有机会我会单独写一篇博客分析这个问题,这里大家知道一下就可以了。

好了,好了,好了...终于,终于,终于分析完了!为了考验一下大家是不是认真阅读了本篇博客,我要问一个问题:你的可乐喝完了吗?

 

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!