HashMap白话总结

HashMap
提到HashMap,他在1.7和1.8之间,有着重大的升级。总得来说,在1.7之前,HashMap是由数组和链表构成的,节点叫做Entry,插入方法是头插,因为作者认为后插入的数据更容易被访问到。而在1.8之后,HashMap是有数组加链表加红黑树构成的,节点叫做Node,采用的是尾插。其中最基础的数据结构是数组,之所以优先用数组存而非链表,是因为数组的存取在底层的效率比链表高,因为对于数组而言,只需要下标,就可以直接查询到元素,时间复杂度为O1,此时就能发现,数组的下标索引非常重要,但是当我们把String类型或者Object类型存到数组时,当我们再查询时,此时手里只有String类型的具体值,但String他不是数值类型,所以无法作为索引的,而此时,我们就需要利用到对象天生就有的一个hashCode。

这个hashCode只有在散列表中才有应用,其他情况下,hashCode没有应用场景。所谓的散列表就是HashMap,HashTable,HashSet。

之所以要用尾插法,是因为头插法会在多线程触发扩容rehash情况下出现环形链表。如C,B,A按顺序进行插入,BC已经插好,指针是B->C,而当A来时,先触发头插,再触发rehash,也就是A->B,但rehash时C走了,此时重排,B又是后加入的,B触发头插,B->A,所以AB环形链表。使用尾插法,扩容时会保证链表原本的顺序,就不会出现环形链表的情况了。不会在多线程时死循环不代表多线程安全。也就是无法保证上一秒线程put的值,一下秒能被其他线程get出来。

当我们想用HashCode做索引时,显然HashCode很大,而我们数组的长度是有限且偏小的,所以我们可以用类似取模的方式,来将HashCode和数组的索引进行映射,然而很显然,这个映射不是一对一的,而是多对一的,即多个HashCode类似取模操作后,会映射到一个数组下标索引上,这种情况就叫做Hash碰撞。

且还有问题,当用Hash取模之前,我们的HashCode很可能分布不够均匀,所以我们需要对HashCode再进行扰动函数,计算hash值,将其尽可能的均匀,避免哈希碰撞。

为了解决Hash碰撞,HashMap对于每个发生碰撞的数据,将其用链表连接起来,这是万不得已的方法,因为被链表连接起来之后,在最坏的情况下他的查找效率就变成了O(n)。而在为了提高效率,Java在1.8中为HashMap进行了大的升级,引入了红黑树。所谓的红黑树是类似于平衡二叉搜索树,他有着很高的查找,插入,删除的效率。而然红黑树虽然这么好,这么强大,却不能在数据量小时就是用,这也是为什么Java1.8采用的是链表升级为红黑树,而非用红黑树完全取代链表。原因就在于,当数据量小的时候,对于红黑树而言,他的结构会很容易发生变化,而为了维持住红黑树的结构,他就需要进行很多的自旋操作,这很大的性能开销,得不偿失,所以红黑树只适合于大数据时使用。

而所谓的大数据时使用,在Java中也用参数控制了怎样的数据叫做大数据,也就是什么时候进行链表升级为红黑树,对于这个情况,HashMap底层是有很多规定的。HashMap中有一个变量叫树形阈值,他的值是8,从名字上看,似乎是当链表的长度达到8时,他就会升级,但是看源码时会发现,这个8,他是在循环中使用到的,而在循环的上一行代码中,有一个p指针,他不是空,也就是说,在8个节点的前面,还有一个头节点,所以其实是当链表的节点数到达9是会进行升级为红黑树。而HashMap之所以设置这个值阈值是8,他在jdk源码中也有注释,说到是根据泊松分布计算出来的,里面列出了一个表,列出了阈值是0~8的概率,其中,当阈值是8时,他的概率是亿分之6,也就是说,虽然jdk8中对HashMap引入了红黑树,但很小的概率会用到。当真的用到时,那个数据肯定是链表的效率的数量级的数据,所以才有必要升级为红黑树。

除了对链表升级红黑树之外,我再说说HashMap中用参数来数组容量吧。第一个就是,当我们在new HashMap时,可以不设置容量,如果不设置容量的话,默认容量是16。当然也可以提供一个初始化容量,但Java规定这个初始化容量必须是2的指数次幂,即便我们设定的不是2的指数次幂,Java也会在HashMap的putVal方法中,找到一个最接近我们设定值的,且大于的,2的指数次幂,这是HashMap的使用过程中,一个规定,而这么做的原因也很有意思,因为涉及到运算符的效率问题,我们之前所说的hashCode取余操作,他是一个效率很低的操作,几乎是加减乘除中效率最低的,所以HashMap并没有真的用取余符号,而是在计算indexFor方法中用位运算来达到取余的效果,但这个效果成立的条件就是,数组的length必须是2的指数次幂,所以不得不将其扩容到2的指数次幂。这就是Java的HashMap第一个规定的地方。而且当我翻1.7的源码时,看到当HashMap在扩容时,当旧的数组中元素在移到新的扩容后的数组中时,还会再计算一遍indexFor方法,导致indexFor大量使用,所以更说明indexFor的执行效率很重要,因为他执行的次数很多,所以很有必要优化,所以相当于妥协了,为了使用位运算,加上了这样一个规定。之所以要是16而不是其他的2的幂,是因为该length-1是所有二进制全1此时再&结果等同于HashCode的后几位,只要HashCode均匀,那index就均匀,保证了前期的均匀分布。

还有就是关于数组的控制的另一个方面,就是数组什么时候扩容,有两个因素,分别是是HashMap当前长度和负载因子。HashMap中有一个负载因子,他在源码中写死为0.75,表示当数组中四分之三被使用时,数组就会扩容。0.75的来历也和之前说的16一样,是涉及到概率论的,对一个Hash碰撞而言,因为我们之前对HashCode又做了一次Hash散列,使得对于每一个数组元素而言,发生Hash碰撞的概率是相等的,或者说是互不干扰的,所以符合概率论中的伯努利实验,然后通过数学推导出,当负载因子在log2附近时,效率最高,而Java中取0.75,而.Net中取0.72。关于这个负载因子,之所以需要这么严谨的算,是因为他的影响还是挺大的,当他太高的时候,也就是数组大部分空间被利用到,而随着插入操作的进行,越往后就越容易出现hash碰撞,虽然节约了空间,但碰撞地方的链表就会很长,而且在数据量没那么大的情况下,容易升级到红黑树。而负载因子太小的话,会浪费很多空间,所以需要严格的推导。且之前说的树形阈值,在jdk源码注释中的亿分之6就是在0.75的条件下算出来的。扩容的具体步骤分为两步,有点像文件系统的cow,先是创建一个新的Entry数组,长度是原数组的两倍,再遍历原数组,reHash后再重新加入,之所以要reHash是因为Hash的计算公式中index=HashCode(key)&(Length-1)涉及到长度,所以长度改变需要重新计算index。

在数组转红黑树的过程中,也不是链表长度一到9,进升级红黑树,而是还有一个条件,在putVal方法中会有一个if判断,就是当总的数组长度不超过64时,无论链表长度是多少,都不回升级红黑树,而是先去扩容,也是体现了作者的严谨。

未重写equals时,equals比较的是两个对象的内存地址。==比较的是对象的值。重写equals时为啥要重写hashCode。equals相同,则两对象一定相等,但hashCode无法决定。🤔️所以当我们通过key拿到index时,一定是通过equals去看查链表中的哪个key是能和我们匹配的key,也就是equals和hashCode是很有关系的,先hashCode找index,在index中通过equals匹配key。所以如果我们重写了equals,那么一定要对HashCode重写,以保证相同的对象返回相同的hash值,不同的对象返回不同的hash值。🤔️

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值