关于HashMap底层的学习理解

一.底层原理的大致实现

基于hashing的原理,jdk8后采⽤数组+链表+红⿊树的数据结构。我们通过put和get存储和获取对象。当我们给put() ⽅法传递键和值时,先对键做⼀个hashCode()的计算来得到它在bucket数组中的位置来存储Entry对象。当获取对象 时,通过get获取到bucket的位置,再通过键对象的equals()⽅法找到正确的键值对,然后在返回值对象。

接下来描述一下put的实现:

1.计算关于key的hashcode值(与Key.hashCode的⾼16位做异或运算)

2.如果散列表为空时,调⽤resize()初始化散列表

3.如果没有发⽣碰撞,直接添加元素到散列表中去

4.如果发⽣了碰撞(hashCode值相同),进⾏三种判断

4.1:若key地址相同或者equals后内容相同,则替换旧值

4.2:如果是红⿊树结构,就调⽤树的插⼊⽅法

4.3:链表结构,循环遍历直到链表中某个节点为空,尾插法进⾏插⼊,插⼊之后判断链表个数是否到达变成红 ⿊树的阙值8;也可以遍历到有节点与插⼊元素的哈希值和内容相同,进⾏覆盖。

5.如果桶满了⼤于阀值,则resize进⾏扩容

上面的put过程中提到了红黑树,对红黑树不够了解可以看下其他相关文章。

二.常见相关问题

2.1 HashMap一般什么时候需要进行扩容,扩容resize()是如何实现的?

调⽤场景: 1.初始化数组table

2.当数组table的size达到阙值时即++size > load factor * capacity 时,也是在putVal函数中 实现过程:

1.通过判断旧数组的容量是否⼤于0来判断数组是否初始化过

否:进⾏初始化 判断是否调⽤⽆参构造器,

是:使⽤默认的⼤⼩(16)和阙值 否:使⽤构造函数中初始化的容量,当然这个容量是经过tableSizefor计算后的2的次幂数

是,进⾏扩容,扩容成两倍(⼩于最⼤值的情况下),之后在进⾏将元素重新进⾏与运算复制到新的散列表中 概括的讲:扩容需要重新分配⼀个新数组,新数组是⽼数组的2倍⻓,然后遍历整个⽼结构,把所有的元素挨个重 新hash分配到新结构中去。 可⻅底层数据结构⽤到了数组,到最后会因为容量问题都需要进⾏扩容操作

2.2 HashMap中get是如何实现的?

对key的hashCode进⾏hashing,与运算计算下标获取bucket位置,如果在桶的⾸位上就可以找到就直接返回,否则 在树中找或者链表中遍历找,如果有hash冲突,则利⽤equals⽅法去遍历链表查找节点。 

2.3 当两个对象的hashCode相等时会怎么样?

会产⽣哈希碰撞,若key值相同则替换旧值,不然链接到链表后⾯,链表⻓度超过阙值8就转为红⿊树存储

2.4 HashMap的参数loadFactor,它的作用是什么?

loadFactor表示HashMap的拥挤程度,影响hash操作到同⼀个数组位置的概率。默认loadFactor等于0.75,当 HashMap⾥⾯容纳的元素已经达到HashMap数组⻓度的75%时,表示HashMap太挤了,需要扩容,在HashMap的构造器中可以定制loadFactor。 如果HashMap的大小超过了负载因⼦(load factor)定义的容量,则会进⾏扩容操作(resize()),概括的讲就是扩容后的数组大小是原数组的2倍,将原来的元素重新hashing放⼊到新的散列表中去。

2.5 传统HashMap的缺点(为什么引入红黑树?)

JDK 1.8 以前 HashMap 的实现是 数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有⼤量的元素都存放到同⼀个桶中时,这个桶下有⼀条⻓⻓的链表,这个时候 HashMap 就相当于⼀个 单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。针对这种情况,JDK 1.8 中 引⼊了 红⿊树(查找时间复杂度为 O(logn))来优化这个问题。 

三. 阅读源码理解

我们可以通过源码来理解上面说到的思想

可以查看map的put方法

可以看到我们传入key,value进来后最终调用putVal方法来存储的键值对对象,并返回value的值,在这里注意到hash(key)这个方法,就是计算这个key的hash值,我们可以进去看看hash算法的实现。如下图:

可以看到的是我们首先计算出一个key的hashCode值再与这个key的hashCode高16位,即无符号右移16为进行异或运算,在二进制中,1^0=1,1^1=0,就是只要相等就为0,否则为1,上面也说过,右移16位是为了让hash值的散列度更高,尽可能减少hash表的hash冲突,从而提升数据查找的性能。在上面的putVal方法里面,是通过Key的hash值与数组的长度取模计算得到数组的位置。而在绝大部分的情况下,计算的hash的值一般都会小于2^16次方,也就是65536。所以也就意味着一般使用hash值的低16位与(n-1)进行取模运算,就是计算的hash值取模散列表的长度n,因为&运算有个特性,在n为偶数时, x%n=x&(n-1)。如果不与高16为做异或运算,会导致可能有较多的key取模后都会落在数组的同一个位置,故为了提升key的hash值的散列度,在hash方法里面,做了位移运算。

从上述源码也可以看出,想要将一个对象存储在hashMap的key中,必须重写hashCode方法,而一般来说重写hashCode肯定也要重写equas才能满足hashMap的put的插入条件,这里我们可以自己研究一下一个我们自定义对象hashCode又是怎么计算的。

四. 自定义对象hashCode的计算

如图,我们新建一个peopel类,重写hashCode和equals方法。可以看到hashCode里调用了Objects.hash(age,name),继续深入查看,

接着进入到Arrays.hashCode(values),

可以看到他接受的是一个对象数组,前面我们传的age,name参数作为对象数组传入到这里,根据这个代码逻辑当数组为空时,hash值为0,故我们可以知道hashMap的key也可以存null,一般存在散列表的第一个元素。若不为空则进行后面的hash计算操作,直接看代码也可以很清晰的看出hashCode是如何计算的了,但对于element.hashCode()的值,java对于像String,Integer这些java.lang包下或其他封装的类已经重写了hashCode的逻辑,比如Integer直接返回整数本身,long返回的是与自己本身无符号右移32位然后相异或的结果,这样是为了更好的将long也转化32位特征的数。对于Character的hashCode,就是返回这个字符的ASCII编码,超类Obejct的hashCode也是这样的。而对于String,我们可以再来查看JDK8的源码:

String本质上是字符数组组成的,在这里我们可以发现逻辑与我们自定义对象并重写hashCode方法的逻辑也基本一致,并且还发现都有31这个数组,那为什么要都要用到31呢,而不用其他的数字。这里在一些文章上也有体现,即:

因为他是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算(低位补0)。使用素数的好处并不很明显,但是习惯上使用素数来计算散列结果。 31 有个很好的性能,即用移位和减法来代替乘法,可以得到更好的性能: 31 * i == (i << 5)- i, 现代的 VM 可以自动完成这种优化。这个公式可以很简单的推导出来。即

31*i=(32-1)*i   -->   32*i-i  -->  2的5次方*i-i  -->  (i<<5)-i

五。总结

以上是小编对hashMap的理解和学习总结,这些分析结论是在网上查阅和实践深入源码的结果,可能有些地方带有自己的错误理解,欢迎大家指出补充。当然hashMap里面还有很多有趣深奥的东西值得挖掘,比如31这个数字为什么可以减少散列冲突,这可能就要问道数学家们了。

 

  • 19
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值