HashMap底层数据结构是什么?1.7和1.8有何不同
1.7是 数组 + 链表,1.8 是数组 + (链表 或者 红黑树)
当链表的元素比较多的时候,链表就会转换成红黑树,红黑树的元素减少了,红黑树也会转换成链表
为什么要优化?
Map这种集合容器,最主要的应用就是想通过一个key最快的时间找到对应的value,事实上这个时间复杂度接近为O(1),那么怎么样才能实现这么快的速度呢?于是就引入了数组,数组可以理解为内存中一块连续的内存空间,且每一小块空间都有自己的索引,通过这个索引就能直接找到对应空间的值。
这也是哈希表的优点:快速查找
可以实现集合中元素的快速查找,可以通过计算元素的哈希码,对其向桶的个数取模运算(源码不是取模,但是类似取模),得到它的桶下标,有了桶下标,就能定位它的位置,接下来只需要进行少量的比较,最优的情况下只需要进行1次比较,就可以找到这个元素。
发生了哈希冲突
比如某些人通过找到你的hash碰撞值,来让你的HashMap不断地产生碰撞,那么相同key位置的链表就会不断增长,当你需要对这个HashMap的相应位置进行查询的时候,就会去循环遍历这个超级大的链表,性能及其地下。java8使用红黑树来替代超过8个节点数的链表后,查询方式性能得到了很好的提升,从原来的是O(n)到O(logn)。
在最坏情况下,链表的长度是n,就是遍历n次才能找到元素,所以,链表的时间复杂度为O(n)。
当插入的元素哈希码对桶长度取模后都在一个链表时,查询的速度也会变慢很多,这时候就需要优化。
解决哈希冲突
如果冲突后是链表,判断该链表是否大于8,如果大于8并且数组容量小于64,就进行数组扩容;
如果链表节点大于8并且数组的容量大于64,则将这个结构转换为红黑树
转换成红黑树的两个条件(树化)
红黑树:节点左侧的比之小,右侧比之大。用红黑树查询就会快。
1. 链表的长度超过一个阈值,这个阈值固定是8,也就是只有链表的长度大于8才会优化成红黑树
2. 数组的长度必须 >= 64,如果数组的长度不够大,首先会考虑用数组扩容的方式来优化,也就是增加了桶的个数,数组一扩容,就会重新根据哈希码值来进行插入到不同的桶里面。
什么时候红黑树 退化 为链表
退化情况1:在扩容时如果拆分树时,树元素个数
退化情况2: remove树节点时,若root、root.left、root.right、root.left.left 有一个为null,也会退化为链表
为什么先用链表,再转红黑树?
在解决hash冲突的时候选择先用链表,再转红黑树
- 链表短的时候是性能比红黑树是高的,只有链表长的时候性能才不如红黑树
- 链表的底层是Node,红黑树是TreeNode,TreeNode里面的成员变量多,内存占用多
因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。当元素小于8个的时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于8个的时候,红黑树搜索时间复杂度是O(logn),而链表是O(n),此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。因此,如果-开始就用红黑树结构,元素太少,新增效率又比较慢,无疑这是浪费性能的。
索引怎么计算?
原始hash -- 》 二次hash --》索引
- 在java中所有的对象继承自Object,而Object类里面有一个方法:hashCode,因为任何一个对象都有一个hashcode, 这个就是原始hash值,调用HashMap中的hash() 哈希方法将原始哈希值 转换成 二次hash。
- 二次哈希的结果,再和数组容量“取模”运算,得到的就是索引,也就是桶下标
注意:源码中不是用取模hash % n ,而是用hash & (n - 1) ,计算结果一样,但是后者性能更高。
例如:97 % 16 = 1,97 & (16 - 1) = 1,结果是一样的。
为什么要二次哈希:hash()?
在计算索引的时候,不会直接用hashcode去计算,而是先进行 hash() 方法,根据hashcode 计算出 二次hash后再计算索引。
目的:二次hash()是为了综合高位数据,让哈希分布更为均匀,分布的越均匀,链表就不会过长
这个计算二次哈希 也就是 扰动
1.7里面的 hash()
1.8 里面的 hash()
解决hash冲突的办法有哪些
如果冲突后是链表,判断该链表是否大于8,如果大于8并且数组容量小于64,就进行扩容;
如果链表节点大于8并且数组的容量大于64,则将这个结构转换为红黑树;否则,链表插入键值对,若key存在,就覆盖掉value.
解决Hash冲突方法有:开放定址法、再哈希法、链地址法(拉链法)、建立公共溢出区。 HashMap中采 用的是链地址法。
1、开放定址法也称为再散列法,基本思想就是,如果p=H(key)出现冲突时,则以p为基础,再次hash,I p1=H(p) ,如果p1再次出现冲突,则以p1为基础,以此类推,直到找到一个不冲突的哈希地址pi。因此开放定址法所需要的hash表的长度要大于等于所需要存放的元素,而且因为存在再次 hash,所以只能在删除的节点上做标记,而不能真正删除节点。
2、再哈希法(双重散列,多重散列), 提供多个不同的hash函数, 当R1=H1(key1)发生冲突时,再计算R2=H2(key1),直到没有冲突为止。这样做虽然不易产生堆集,但增加了计算的时间。
3、链地址法(拉链法), 将哈希值相同的元素构成一个同义词的单链表并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。链表法适用于经常进行插入和删除 的情况。
4、建立公共溢出区,将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统-放到溢出
一般用什么作为HashMap的key?
一般用Integer、 String这种不可变类当HashMap当key,而且String最为常用。
●因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就是HashMap中的键往往都使用字符串的原因。
●因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的重写了hashCode(以及equals()方法。
put方法流程?1.7与1.8有何不同?
流程
- HashMap是懒惰创建数组的,首次使用才创建数组
- 计算索引(桶下标)
- 如果桶下标还没人占用,创建Node占位返回
- 如果桶下标已经有人占用
- 已经是TreeNode走红黑树的添加或更新逻辑
- 是普通Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
- 返回前检查容量是否超过阈值,一旦超过进行扩容
区别
- 链表插入节点时,1.7是头插法,1.8是尾插法
- 1.7是大于等于阈值且没有空位时才扩容,而1.8是大于阈值就扩容
- 1.8在扩容计算Node索引时,会优化
总结 1.7 和 1.8 的区别
底层:
- HashMap在1.7中是由 数组+链表 也可以说 哈希表+链表 实现的
- HashMap在1.8中是由 数组+链表 +红黑树 (链表长度大于8,并且数组长度大于了64之后,转换为红黑树,红黑树的查找效率更快, O(logn))
- 在JDK1.7中,HashMap存储的是Entry对象 在JDK1.8当中,HashMap存储的是实现Entry接口的Node对象
put方法:
- JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法
- 为什么从头插法改成尾插法? 在1.7中,是没有红黑树的 在并发的情况下单链表过长,会成环,发生死循环 在1.8中,尾插法就可以解决这个问题
扩容机制: 扩容因子0.75 扩容倍数2倍 初始容量16
- 在JDK1.7的时候是先扩容后插入的,扩容过程中会将原来的数据,放入到新的数组中,但是会重新计算hash值进行分配,这样就会导致无论这一次插入是不是发生hash冲突都需要进行扩容,如果这次插入的并没有发生Hash冲突的话,那么就会造成一次无效扩容, 但是在1.8的时候是先插入再扩容的,优点是可以减少1.7的一次无效的扩容,因为如果这次插入没有发生Hash冲突的话,那么其实就不会造成扩容
加载(负载)因子为何默认是0.75f
基础篇-51-HashMap_负载因子为何是0.75f_哔哩哔哩_bilibili
- 在空间占用与查询时间之间取得较好的权衡
- 大于这个值,空间节省了,但链表就会比较长影响性能
- 小于这个值,冲突减少了,但扩容就会更频繁,空间占用多
多线程下操作HashMap会有啥问题?
扩容死链(1.7)
基础篇-53-HashMap_并发扩容死链(1.7)_哔哩哔哩_bilibili
数据结乱(1.7,1.8)
key 能否为null,作为key的对象有什么要求?
HashMap的key可以为null,但 Map的其他实现则不然
作为key的对象,必须实现 hashCode和equals,并且key的内容不能修改(不可变)