缘起--HashMap系列之1.7源码篇(一)

上周五小宋在部门讲解红黑树的相关知识就顺便复习了一下hashmap,这周整理一下笔记,接着会写关于hashmap的系列博客。

HashMap基本概念

现在大家都已经在使用hashmap1.8了。1.8和之前1.7最大的区别就是在底层新增了红黑树。这里给不太了解hashmap的朋友普及一下基本概念。

HashMap 1.7是由数组+链表组成。HashMap1.8是由数组+链表+红黑树组成。

然后存储结构是<k,v> key,value的形式存储的。

默认的数组容量是16,默认的加载因子是0.75

HashMap1.7(数组+链表)

刚刚在上面简单讲了一下hashmap的基本点。这里从1.7开始进行讲解。

为了方便大家理解,数组+链表图化如下:
在这里插入图片描述

今天会分析在1.7底层代码中hashmap进行了哪些具体的操作,例如大家常用的put(“key”,value)。

我们先进行基本概念的讲解,在put源码中,会去拿key值进行hashcode计算然后进行右移+异或计算出一个hash值。这里有一个问题为什么用右移+异或而不用取余,这个后面会有解答。
然后拿到计算的hash值再去进行按位与操作计算出对应的数组下标位置。
这里根据下标进行存储的时候,可能会出现不同的put操作但是计算出相同的下标去存储到一个slot的情况,如果是相同的key的话可以直接覆盖,但是不同的key也可能计算出相同的下标所以这个时候就会出现一个现象叫hash冲突也叫hash碰撞。为了解决hash冲突这个问题就用到了链表

链表在java中的实现有一个next属性,这个next属性会决定node节点在链表中的位置。

get的话就是去拿key进行相同的hash计算然后按位与操作得到下标,再在相应的下标位置对该位置的链表向下遍历找到相应的key,这里提一句在1.7以前都是用的头插法,就是为了方便遍历查找数据,1.8以后就做出了改变用的是尾插法,主要还是因为引入了红黑树的原因,为了方便得出链表的长度节点数是否达到阈值去进行转化红黑树的判断就用了尾插法。

1.7 put源码解析(数组+链表+扩容)

初始化数组:
在这里插入图片描述

前面我说过默认的数组大小是16,但是我们也可以自定义大小。下面具体看一下1.7是怎么来操作初始化的。下面是put的源码。

在这里插入图片描述

在put的代码中会先进行一个empty table的判断。当为true的时候进入inflateTable

在这里插入图片描述

这里会引申出一个问题,在上面有重复的提到16,二次方。为什么hashmap里面的数组大小一定要是2的幂次方数呢??? 同学们这里记一下,这个问题我会在后面进行解答,我们继续往下讲。

刚刚我们进行了数组初始化,接着对key进行判断,然后根据key进行hash计算得到hash值。

在这里插入图片描述

这里是hash计算的底层代码,就是我在上面基本概念的时候提到过的先进行hashcode计算得到hash值再去进行右移
和异或^操作。
final int hash(Object k) {
	int h = hashSeed;
	if (0 != h && k instanceof String) {
		return sun.misc.Hashing.stringHash32((String) k);
	}
 
	h ^= k.hashCode();
 
	// This function ensures that hashCodes that differ only by
	// constant multiples at each bit position have a bounded
	// number of collisions (approximately 8 at default load factor).
	h ^= (h >>> 20) ^ (h >>> 12);
	return h ^ (h >>> 7) ^ (h >>> 4);

这里也有一个问题我在上面提到过为什么hashmap里面根据key进行hashcode计算的时候要进行右移和异或运算??? 但是我在这先不解释,先从上到下整体讲完再去解疑,同学们也可以自己先思考一下。 异或操作是同0异1原则,相同的是0,不同是1。

上面我们通过hash计算得到了hash值,继续我们要去获取下标位置,通过hash值和数组长度在indexFor中计算得到。

在这里插入图片描述

这里我们继续的是按位与操作&

在这里插入图片描述

indexFor中进行&操作的时候对length进行了-1操作。
为什么减一,这里我们就得还原一下数组长度的二进制了。
在这里插入图片描述
数组16长度的二进制是0001 0000,15是0000 1111,&操作进行比较的时候和1不同的取的0,只有都是1的时候才取1。

好了数组这一块的我们就讲完了,我们来解答一下之前提到过的两个问题。
1:为什么hashmap里面的数组大小一定要是2的幂次方数???

两个原因:
1,提升计算效率:因为2的指数倍的二进制都是只有一个1,而2的指数倍-1的二进制例如16-1就都是左全0右全1,15:0000 1111。那么跟(2^n - 1)做按位与运算的话,得到的值就一定在【0,(2^n - 1)】区间内,这样的数就刚合适可以用来作为哈希表的容量大小,因为往哈希表里插入数据,就是要对其容量大小取余,从而得到下标。所以用2^n做为容量大小的话,就可以用按位与操作&替代取余操作,提升计算效率。jdk1.4后取余比较慢,&操作针对快。

2.便于动态扩容后的重新计算哈希位置时能均匀分布元素:因为动态扩容仍然是按照2的指数倍,所以按位与操作的值的变化就是二进制高位+1,比如16扩容到32,二进制变化就是从0000 1111(即15)到0001 1111(即31),那么这种变化就会使得需要扩容的元素的哈希值重新按位与操作之后所得的下标值要么不变,要么+16(即挪动扩容后容量的一半的位置),这样就能使得原本在同一个链表上的元素均匀(相隔扩容后的容量的一半)分布到新的哈希表中。(注意:原因2(也可以理解成优点2),在jdk1.8之后才被发现并使用)

2.为什么hashmap里面根据key进行hashcode计算的时候要进行右移和异或运算???

一些对象hash出来的结果都是高位变化,低位不变,导致分布不均匀,哈希冲突就容易变多。基于HashMap的indexFor底层设计,假设容量为16,那么就要对二进制0000 1111(即15)进行按位与操作,那么hash值的二进制的高28位无论是多少,都没意义,因为都会被0&,变成0。所以哈希冲突容易变多。那么hash(Obeject k)方法中在调用 k.hashCode()方法获得hash值后,进行的一步运算:h ^ =(h>>>20)^(h>>>12);有什么用呢?首先,h>>>20和h>>>12是将h的二进制中高位右移变成低位。其次异或运算是利用了特性:同0异1原则,尽可能的使得h>>>20和h>>>12在将来做取余(按位与操作方式)时都参与到运算中去。综上,简单来说,通过h ^ =(h>>>20)^(h>>>12);运算,可以使k.hashCode()方法获得的hash值的二进制中高位尽可能多地参与按位与操作,从而减少哈希冲突

前面我们讲了hashmap底部的数组结构的相关点,接着继续聊我们一开始提过的hash冲突时的链表

在这里插入图片描述
put中的这个for循环是在循环数组相应位置slot的链表节点,在1.7中节点对象是Entry,1.8中是Node。
下面的if判断是在判断hash值和key值是否相等,一致的话新put进来的value覆盖hash和key一致节点的value。

往下走我们该讲一下扩容机制了。是在addEntry方法去实现的。

在这里插入图片描述

addEntry中resize方法针对数组进行扩容。在1.7中是先扩容再添加元素,1.8中是先添加元素再扩容的。

在这里插入图片描述

扩容在1.7中是根据上一次table.length *2得到(2的次方)。eg.16 * 2 == 32。

而在1.8中则是位移运算得到,左移一位。eg.当前table Size是16   16<<1 == 32

在这里插入图片描述

为什么采用位移运算而不乘以2: 主要原因是性能问题,cpu不支持乘法运算,最终都是在指令层面转化为了加法实现效率低。使用位移运算对cpu来说就非常简洁高效,效率高。

继续回到1.7的扩容resize,1.8我会在后面的章节进行讲解。

在这里插入图片描述

在resize方法中,我们还要对扩容前的数组中的数据转换到扩容后的数组中。这里面的方法是transfer。

transfer中会进行遍历去存储,然后这里其实还是要说一下我上文中有提到过,为什么用二次方作为数组长度,
也是方便扩容时候的数据转化存储。但其实,看下图的代码会发现并没有用到二次方的这个优点,还是在1.8在发现引用的。

在这里插入图片描述

然后addEntry中的createEntry方法

在这里插入图片描述
补充一下HashMap为什么用扩容?
当hashmap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过160.75=12的时候,就把数组的大小扩展为216=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面annegu已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.751000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。

最后在这里整体讲解一下扩容机制的数据迁移问题。这个我们还是说完整一点,
因为1.8多了一个红黑树,所以扩容后的数据迁移有四种情况。

扩容后的数据迁移

1.slot是null的时候不用迁移

2.slot没有链化存储的node节点的next是null的时候直接迁移根据新表的tableSize计算出它在新表的位置,然后存放过去。

3.slot发生过hash冲突已经链化有链表结构,这个时候需要把slot当中保存的链表根据每个节点hash值的高低位拆分成两个链表分别是高位链表低位链表。同一个链表中(同一个数组下标的slot中)所有的node#hash字段转化成二进制以后低位都是相同的,低位就是老表的tableSize-1转化出来的二进制有效位 例如16-1的转化出来的二进制的低四位就是1111。node#hash字段转化成二进制高位可能就不一致了1/0这样不同排列。
例如现在扩容到32 32-1:二进制就是 00011111 然后链表中一个node的hash是00011111 一个node的是00001111 第一个node就从老表的位置(15)+老表的数组长度size(16)=31 这个第一个node就定位到了下标31的slot,然后第二个node就定位到还是15下标的slot。

4.slot红黑树扩容情况 红黑树的TreeNode对象依然保留了next字段还维护着链表,这个链表方便resize扩容使用split拆分红黑树的时候用的,然后拆分就和第三种一样拆分成高位链表和地位链表再去找到相应的slot存储,不同的点就是拆分出来的链表需要看一下它的长度如果长度<=6就把TreeNode转化成普通的Node链表 如果>6还是要保持红黑树TreeNode结构

关于红黑树的相关知识,我会在下一个章节继续描述。谢谢大家的观看,希望能给各位同学带来帮助。如果觉得博主写的还可以的,可以点赞关注。

posted @ 2020-11-23 14:57  奋斗的小宋  阅读(22)  评论(0)    收藏  举报