一、HashMap的底层数据结构是什么?1.7与1.8有什么不同?
1.7 数组 + 链表
1.8 数组 + (链表 | 红黑树)链表与红黑树是可以转换的,链表中的元素比较多的时候,就会转化为红黑树,红黑树的元素减少了,也会退化为链表
二、为什么使用红黑树?为什么先使用数组+链表再使用数组+红黑树,而不是一上来就使用数组+红黑树?树化阈值为什么是8?何时会树化?何时退化为链表?
回答这几个问题,需要一些铺垫
HashMap放入、查找元素的过程
HashMap放入元素时,对于key,会调用字符串的hashcode方法,得到原始hash值,对原始hash值再进行一次运算,得到二次hash值,二次hash值与当前hash表的数组容量,进行求模运算,运算的结果就是桶下标,即这个元素在hash表中的位置,那么,在查找时,也可以进行类似运算,快速知道元素在hash表中的位置,通过少量的比较(最优时仅需要一次比较),就可以找到这个元素
但是,假如有一组异常的数据,经过计算后,得到的桶下标都一样,比如说类似于这样
要查找其中某个元素,就需要一次一次的比较,最糟时需要比较8次,时间复杂度由理想的O(1)变成了O(n),性能低下,如何解决呢?
方法一、缩减链表长度
扩容就可以缩减链表长度
当元素个数到达超过hash表容量的3/4时,就会扩容,本例而言,16的3/4是12,即放入第13个元素的时候,就会扩容
扩容后,桶下标1的位置,链表的长度由8变为4
为什么缩短了?因为扩容后,桶下标要重新计算
但是,如果原始hsah值一样,那么计算出的桶下标也是相同的,怎么扩容,都不能够缩短链表长度,那么,只能使用方法二了
方法二、使用红黑树
树化需要满足两个条件,条件一,链表长度需要超过树化阈值(这个树化阈值是个固定的常量,为8),条件二,数组容量大于等于64(如果数组容量不够大,首先会尝试使用扩容的方式,缩短链表长度,只有万不得已的时候,才会进行树化)
红黑树的特性就是父节点左侧的元素都比它小,右侧元素都比它大。
问,在桶下标为1的位置上,再添加一个元素,会不会进行树化?
答:不会,因为数组长度还未达到64
问,链表长度有没有可能超过8?
答:有可能
问:为什么使用红黑树?
答:1.7的数组+链表,如果链表长度很长时,会影响HashMap的性能,1.8引入红黑树以后,即使链表长度比较长,也不会对性能有大的影响;
问:何时会树化?
答:满足两个条件,第一,链表长度超多树化阈值8,第二,数组容量大于等于64;
问:为什么先使用数组+链表再使用数组+红黑树,而不是一上来就使用数组+红黑树?
答:短链表时性能,是优于红黑树的,没有必要树化,只有当链表长度比较长时,性能才不如红黑树,所以不是一开始就使用红黑树,而是等到链表比较长的时候,才会树化,使用红黑树;变成红黑树后,占用内存也会变多(链表的底层数据结构是Node ,红黑树的底层数据结构是TreeNode,TreeNode的成员变量比node多很多,占用的内存也多很多),所以,如非不要,不会转化为红黑树
问:树化阈值为什么是8?而不是阈值更小一点,早一点树化?
答:首先要说明一点,红黑树,是在不正常的情况下使用的,绝对不是正常情况下出现的,正常情况下,链表的长度不会超过8
用代码,证明一个,一个单词表,有23万多个单词
把这些单词放到HashMap中,看结果
23万多个单词, 数组长度扩容到了52万多个,其中大部分桶下标没有放元素,有33万多个,一个元素的链表有15万多个,占了单词总数的一半以上,两个元素的链表,有3万多个,可以看到,链表长度为1、2、3的,已经占到了单词总数的80%,最长链表长度是6,只有两个
由此得出结论,正常业务情况下,没有刻意构建hash码,形成的链表的最大长度也就6个左右,并且占比非常少,不可能出现长度超过8的链表,什么时候会出现呢?
那就是有人恶意攻击的时候,构造一批hash值一样的对象(hash值一样,计算出的桶下标也一样),造成链表特别长,进而严重影响整个系统的性能
最后得出结论:
红黑树用来避免 DoS 攻击,防止链表超长时性能下降,树化应当是偶然情况,是保底策略
hash 表的查找、更新的理想时间复杂度是 O(1),而红黑树的查找、更新的时间复杂度是
O(log_2n ),TreeNode 占用空间也比普通 Node 的大,如非必要,尽量还是使用链表
hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链
表出现概率是 0.00000006,树化阈值选择 8 就是为了让树化几率足够小
问:何时退化为链表?
情况一,扩容时,红黑树会有一个拆分的动作,拆分后,如果树元素个数 <= 6 则会退化链表
情况二,remove 树节点前,会做一些检查,若 root、root.left、root.right、root.left.left 有一个为 null ,也会退化为链表
问:移除1,红黑树是否会退化成链表?
答:不会,因为检查是在移除前进行,移除前,根节点的左孙子还在,那么不会退化成链表
移除1以后,变成这样
如果再移除7, 移除前检查,发现根节点的左孙子没有了,就会退化成链表
问:移除6,会不会退化成链表?
答:不会,移除6以后,5填充上去了,变成
再移除5,会不会退化成链表?
不会,因为移除前做检查,root、root.left、root.right、root.left.left 都有,树会进行一个旋转,变成这样
三、索引如何计算?hashCode都有了,为什么还要提供hash()方法?数组容量为什么是2的n次幂?
问:索引如何计算?
答:首先,调用对象的 hashCode(),得到原始hash值,原始hash值再调用 HashMap 的 hash() 方法,进行二次 hash(),二次 hash() 结果,再与 hash 表中数组的容量做一个取模的运算,得到的余数就是索引,即桶下标;
问:hashCode都有了,为什么还要提供hash()方法?
答:二次 hash() 是为了综合高位数据,让哈希分布更为均匀,越均匀,就不会出现某个链表过长的情况;
问:数组容量为什么是 2 的 n 次幂?
看下取模运算,求模运算 97 % 16 = 1,求模运算可以优化,97 % 16 等价于位与运算 97 & (16 - 1),位与运算要比取模效率高,但是,除数必须是 2 的 n 次幂;
答:
一,计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模;
二,扩容时重新计算索引效率更高: 二次hash()值 & 原始容量 == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
问:数组容量为 2 的 n 次幂,有什么缺点吗?
数组容量为 2 的 n 次幂,虽然在计算索引时效率更高,但是,hash 的分布性不是很好
选择质数作为数组容量,得到的hash() 分布性会更好,并且不再需要二次 hash() 了
二次 hash 就是为了配合数组容量是 2 的 n 次幂这一设计的,如果 hash 表的容量不是 2 的 n 次
幂,则不必二次 hash
数组容量是 2 的 n 次幂这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable
由此可以看出,HashMap更注重效率
四、介绍一下 put 方法流程,1.7与1.8有何不同?
1. HashMap 是懒惰创建数组的,首次添加元素的时候才创建数组
2. 计算索引(桶下标)
3. 如果桶下标还没人占用,创建 Node 占位返回
4. 如果桶下标已经有人占用
1. 已经是 TreeNode 走红黑树的添加或更新逻辑
2. 是普通 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
5. 返回前检查容量是否超过扩容的阈值,一旦超过进行扩容,需要注意的是,添加完元素以后,再扩容
不同点:
1. 链表插入节点时,1.7 是头插法,1.8 是尾插法
2. 1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容
3. 1.8 在扩容计算 Node 索引时,会优化(二次hash()值 & 原始容量 == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap)
五、加载因子为何默认是0.75f?
加载因子即扩容因子
0.75f 即 3/4
在空间占用与查询时间之间取得较好的权衡
大于这个值,空间节省了,但链表就会比较长影响性能
小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多
六、多线程下会有什么问题?
1.7有可能出现扩容死链
1.7、1.8都可能出现数据错乱(线程不安全,多线程操作会有问题)
七、key 能否为 null ?作为key的对象,有什么要求?
HashMap 的 key 可以为 null,但 Map 的其他实现就不一定了
作为 key 的对象,必须实现 hashCode 和 equals,并且 key 的内容不能修改(不可变)
重写 hashCode 是为了key能够有更好的分布性
重写 equals ,如果两个 key 计算出的索引一样,那么需要使用 equals 进一步比较,看看对象是否相同(如果 hashCode 相同,equals 不一定相同,如果 equals 相同,hashCode 一定相同)
对象 key 如果发生改变,就获取不到相应的值了,代码:
public static void main(String[] args) {
HashMap<Student, Object> map = new HashMap<>();
Student stu = new Student("张三", 18);
map.put(stu, new Object());
System.out.println(map.get(stu));
// 修改 age的值
stu.age = 19;
System.out.println(map.get(stu));
}
static class Student {
String name;
int age;
public Student(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Student student = (Student) o;
return age == student.age && Objects.equals(name, student.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
控制台输出:
java.lang.Object@71e7a66b
null
八、String 对象的 hashCode() 如何设计的?为什么每次乘的是31?
不管是字符串对象还是其他对象,hashCode() 的设计目标就是达到较为均匀的散列效果,每个字符串的 hashCode 足够独特
字符串中的每个字符都可以表现为一个数字,称为$S_i$,其中 i 的范围是 0 ~ n - 1
将多个数字综合起来,形成一个独特的hashCode
散列公式为: $S_0∗31^{(n-1)}+ S_1∗31^{(n-2)}+ … S_i ∗ 31^{(n-1-i)}+ …S_{(n-1)}∗31^0$
为什么每次乘的是31?
31 代入公式有较好的散列特性,其实乘以41或者51等其他的奇数,也会有相同的效果,但是不能光看散列效果,并且 31 * h 可以被优化为
* 即 $32 ∗h -h $
* 即 $2^5 ∗h -h$
* 即 $h≪5 -h$
31 既有比较好的散列效果,又可以优化为位与运算,提高效率,所以,选择了数字31