论HashMap的前世今生

深入解析HashMap的工作原理,包括其数据结构、链表转红黑树的条件与原因,以及负载因子的选择。探讨多线程环境下HashMap的使用及潜在问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

HashMap的数据结构:

哈希表>数组+链表(jdk1.8之前)数组的默认长度为16,jdk1.7之前需全部rehashing,jdk1.8通过 hashCode() 的高 16 位异或低 16 位实现:(h = k.hashCode()) ^ (h >>> 16),

主要从速度,功效和质量来考虑的,减少系统的开销,也不会造成因为高位没有参与下标的计算,从而引起碰撞。

链表转换为红黑树-作用:解决了二叉查找树的线性问题;进行平衡性-变色-左旋-右旋(指针的变化)。

思考1:若是线性查找,链表的时间复杂度为O(n)。转化为红黑树时间复杂度为O(lgn),又为何不直接使用红黑树?

从源码中注释可知,在节点较多,红黑树占空间这一劣势不太明显时舍弃链表,使用红黑树。

思考2:jdk1.8引入红黑树-当链表长度超过 8 时,开始由链表转化为红黑树,那为什么是8?6 or 7?负载因子0.75?

从源码中注释可知,为了配合使用分布良好的hashcode,受随机分布的影响,链表中节点遵循泊松分布,根据统计链表中节点数为8的概率接近千分之一,此时链表性能很差,而链表转化为红黑树也消耗性能,

为了挽回性能使用红黑树。理想的均匀分布,节点数不到8就自动扩容。

负载因子过大,空间利用率提高,时间效率降低。负载因子较小,填充的元素少,hash冲突也会减少,底层的链表长度或红黑树的高度会降低,查询效率提高,时间效率提高,空间利用率降低。

从源码中注释可知, 负载因子为0.75的时候,即当前容量为16,则16*0.75=12,当容量达到12进行扩容,避免较多的hash冲突,使底层的链表长度或红黑树的高度会降低,提升了空间效率,空间利用率比较高。

数组对于数据的访问如查找和读取十分便捷,而链表对于数据插入非常方便,链表可以解决hash值冲突(不同的key值可能会得到相同的hashcode值)。

其中数组里每个元素存储的是一个链表的头结点。而组成链表的结点其实就是hashmap内部定义的一个类:Entity,如图-Entity包含三个元素:key,value和指向下一个Entity的next。 即数组中的元素,它持有一个指向下一个元素的引用,构成链表

数组里面的元素都是Entry,Entry这个类有个成员变量就是Entry本身的引用,引用中又有自身的引用,构成链。

上述图示底层实现程序在此不作详细赘述,有兴趣的童鞋可以艾特博主?yxd179哟!

重点切入:

i.当put元素时>>>
              a.首先根据put元素的key获取hashcode值,然后根据hashcode算出数组的下标位置,若下标位置没有元素,直接放入元素即可。
              b.若该下标位置有元素(即根据put元素的key算出的hashcode值一样即重复),则需要已有元素和put元素的key对象比较equals方法,如果equals不一样,则说明可以放入进map中。由于hashcode一样,故得出的数组下标位置相同,会在该数组位置创建一个链表,后put进入的元素到放链表头,而原来的元素向后移动。       
ii.当get元素时>>>
             a.根据元素的key获取hashcode值,然后根据hashcode值获取数组下标位置,若只有一个元素则直接取出。若该位置一个链表,则需调用equals方法遍历链表中的所有元素与当前的元素比较equals,得到真正需要的对象。

综:HashMap在hashing的基础上,分别通过put()和get()方法储存和获取对象。当将键值对传递给put()方法时,它会调用键对象的hash()方法来计算hash值并储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回所获取的值对象。遇到碰撞问题如何解,当发生碰撞了怎么办,链表大大这时候就要来插足HashMap的前后今生啦,所找的对象将会储存在链表的下一个节点中,当声明作final(使用不可变的)的对象,并采用合适的equals()和hash()方法,将会大大减少碰撞的发生,从而提高效率(使用String,Interger这样的包装类作为键)。当然,若是根据hashcdoe算出的数组位置尽量的均匀分布,则可以避免遍历链表的情况,提高性能

若HashMap的大小超过了0.75(默认的负载因子),这时候就需要扩容啦,当重新调整HashMap的大小时,若俩个线程都发现需要rehashing,当通过get()方法获取值对象的时候,部分会出现null,So线程安全问题也就成了HashMap前后今生的第三者。

当put的时候导致的多线程数据不一致:
举例:有线程A和B,A插入一个key-value键值对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来桶的索引一致,则当线程B成功插入之后,线程A再次被调度运行时,依然持有过期的链表头,覆盖线程B插入的记录,这样线程B插入的记录就凭空消失-数据不一致。

当在扩容的时候:jdk1.8之前是采用头插法,当两个线程同时检测到hashmap需要扩容,在进行同时扩容的时候有可能会造成链表的循环,采用头插法新链表与旧链表的顺序是反的,在1.8后采用尾插法就不会出现这种问题,同时1.8的链表长度如果大于8就会转变成红黑树。

那为什么要在多线程的环境下使用HashMap,又如何尽量避免or解决,更多?可以一起探讨哟!(collection框架提供方法能保证HashMap synchronized).

综上:童鞋们,JDK1.8对HashMap的优化想必早已get到啦!

附Aop:引出下篇博文论题:Spring的代理Jdk VS Cglib,默认选择的是Jdk动态代理还是Cglib代理?它们分别是如何实现的,反射or字节码ASM?Jdk动态代理又Why只能是接口??

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值