面试:HashMap

一、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_2⁡n ),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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值