HashMap面试灵魂几问

hashMap 面试遇到的一些总结,欢迎大家来交流。

注:
看hashMap源码需要了解:二进制运算符号

1、位异或运算(^):二进制运算,如果相同则为0,不相同则为1
2、 位与运算符(&):二进制运算,如果两个数都为1则为1,否则为0
3、位或运算符(|):二进制运算,如果两个数有一个为1则为1,否则为0
4、位非运算符(~):如果位为0,结果是1,如果位为1,结果是0
5、 << 、>> 带符号移动
6、>>> 无符号右移:注意 没有无符号左移!

1、HashMap的默认容量?

阿里规约要求,创建Hashmap,需要指定默认容量。
在这里插入图片描述

2、如何计算hash值

获得Hash算法本质上大致分为三步:

1、获得key的hashcode
2、高位运算
3、取模运算

注:hashmap 的put和get 过程,都是计算得到hash值,然后确定hash桶的坐标。

2.1、获得key的hashcode,并高位计算

通过hashcode()方法,使其无符号右移 16 位,并且与自身 位异域运算
在这里插入图片描述
问题来了,为什么要先无符号右移 16位呢。**

" >>> ":不管正负标志位为0还是1,将该数的二进制码整体右移,左边部分总是以0填充,右边部分舍弃。

1111 1111 1111 1111 1011 1011 1110 1100  (key的hashcode)
                                 >>> 16  (无符号右移16位)
———————————————————————————————————————
0000 0000 0000 0000 1111 1111 1111 1111                                 

1.1、因为我们常使用的hashmap的容量不会大于 65636(2^16),所以 65636 用二进制表示 就是16位的二进制。
1.2、最后计算 hash桶的下标需要跟 hash 低位 ,做位与(&)运算。在做位与运算之前,要拿到均匀分布的hash值。根据key取hashcode,右移十六位,而且与自己做位异或(^)运算,就是尽量让自己变得更加散列。

2.2、为什么是位运算不是取模运算

(n-1) &h 运算不同于对length取模,但结果是等价的,但都是让数据均匀的分配,也就是h%length,但是&比%具有更高的效率。
于是乎用该hash与map(总容量-1)做 位与运算, 得到一个 小于haspMap总容量的一个整数。
在这里插入图片描述

为什么X % 2n = X & (2n - 1)

 public static void main(String[] args) {
        // (n-1) & hash 不等于 hash % n
        System.out.println("i = (n - 1) & hash");
        System.out.println(7 & 6);  // 6
        System.out.println(8 % 6);  // 2
        System.out.println(15 & 6); // 6
        System.out.println(16 % 6); // 4
        System.out.println("++++++++++++++++++++");
        // hash & (n-1) 等于 n % hash
        System.out.println(6 & 7);  // 6
        System.out.println(6 % 8);  // 6
        System.out.println(6 & 15); // 6
        System.out.println(6 % 16); // 6
        System.out.println("++++++++++++++++++++");

        /*
         * 抽象成计算式:X % 2n = X & (2n - 1)  todo 注意不是 2n % X  =   (2n - 1) & X
         * 1、名词 解释
         * 【取模运算 X % 2n】:
         *   X % 2^n:这个操作是找出 X 除以 2^n 之后的余数。
         *   比如说,如果我们有 X = 10 和 n = 3,那么 10 % 8(因为 2^3 = 8)的结果就是 2,因为 10 除以 8 的余数是 2
         * 【位与运算 X & (2^n - 1)】
         *   这个操作是将 X 的二进制表示与 2^n - 1 的二进制表示进行逐位比较,只有当两个相应的位都是 1 时,结果的相应位才是 1
         *   比如,2^3 - 1 等于 7,其二进制是 0111。
         *   如果我们与 10(二进制为 1010)进行按位与运算,结果是 0010,即 2
         *   1010
         *   0111 &
         *   0010 => 2 (本质上也是取小的余数)
         *
         * 2、为什么这两个操作在某些情况下会给出相同的结果?(from 文心一言)
         *  当我们对一个数 X 进行 X % 2^n 操作时,我们实际上是在找出 X 除以 2^n 的余数。
         *  这个余数只与 X 的最低n位有关,因为 2^n 的二进制表示中只有最低n位是1。
         *
         *  类似地,当我们对 X 进行 X & (2^n - 1) 操作时,我们实际上是在保留 X 的最低n位,并将其余位设置为0。
         *  这是因为 2^n - 1 的二进制表示中只有最低n位是1。
         *
         *  因此,无论是进行取模运算还是按位与运算,我们都是在处理 X 的最低n位。
         *  这就是为什么这两个操作在某些情况下会给出相同的结果
         *
         * @param args
         */
        System.out.println(17 % 8);
        System.out.println(17 & 7);

        System.out.println(32 % 33);
        System.out.println(1667 & 15);
    }

为什么说取模没有位运算快
在这里插入图片描述

文字叙述:
如果 put一个 (“key”,“value”),首先根据 key 调用 hashcode()方法生成 hash值,然后无符号右移 16 位(jdk7 没有这一部,jdk8为了是hash 低16位分布更加均匀,因为一般的长度不会超过 2的16次方)。
然后进行 位异域运算 ,之后再与 容器长度(length -1)进行 位与运算,得到一个hash桶下标。

3、如何解决hash冲突

hash冲突定义:

不同的key,通过一系列hash计算可能会得到,相同的hash桶下标。
hashmap采用链地址法解决hash冲突

1、如果初次插入,判断 tab数组节点 是否为空,如果为空 会 new一个node节点
在这里插入图片描述
2、如果通过一系列二进制运算得到 的hash值,在原本的数组节点已经存在。
这里分为俩种情况。
一种是 map.put(“a”,“旧值”) ,再次 map.put(“a”,"新值 ") ,相同key 覆盖的情况。
会比较俩者hash 值,并且比较二者的key 是不是相同,
如果二者经过一系列二进制运算得到的hash值相同,
并且key(这里的key指的是这里的 “a”)也相同,就会重写写入该值。
另外一种就是 key 不同,就会发生冲突。
在这里插入图片描述
3、发生hash冲突,写入链表。
基于步骤2,满足hash值相同,并且 key值不相同。
会使用指针 p.next 指向并新创建一个 node节点保存hash冲突的key和value。
在这里插入图片描述
4、如果发生冲突的时候,会检测我们桶节点,链表的长度。如果链表的长度大于8,会转成红黑树。

在这里插入图片描述

为什么链表长度等于 8 转成红黑树。

其实转成红黑树需要俩个条件:

1、先满足链表的长>8
在这里插入图片描述
binCount 默认是0,当 binCount =7的时候,链表已经有8个元素了,
但是树化之前,已经执行了 p.next = newNode(hash, key, value, null);
所以此时有8+1=9个元素

2、再满足hash桶数组的长度 >=64,如果不满足会去扩容
在这里插入图片描述

可以回答
正常来说,链表长度等于 6 的时候,使用红黑树已经比链表效率高了。

#红黑树查询复杂度为:log(2n),也简称为log(n)
log 2n =6  =>  n = 2.580...
#单链表查询复杂度为: O(n)
2n =6  =>  n = 3 
#由此可见。链表等于6时,红黑树已经高于链表查询复杂度。

但是根据泊松分布(源码注释)表示,若当节点数量等于6的概率还是很大的,而当大于8的概率就是百万分之一,已经很小了,hash冲突时为了避免在红黑树和链表之前频繁转换,所以定为8。

4、为什么hashMap 的数组大小为什么一定是 2 的幂?

1、HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;

2、只有它的长度是2的N次方,对它进行减一操作,才能拿到所有是 1 的值
,这样对它进行 按位与运算时,才能快速的用位运算的方式,拿到数组的下标,并且 保证下标在容量之下,并且分配均匀

eg:
假设我们 创建一个默认容量为32的map ,如果经过 map.put(“aaa”,“我是value”) 的操作,
首先得到一个key=“aaa” 的hash值 (11100001 … 11011),去跟 (32-1)做 位于运算。

11100001 .... 11011 (key的hashcode)
            & 11111 (31的二进制)
——————————————————— 
            = 11011 (32与31 进行位与运算(&)二进制)
            = 27    (十进制)
#能保证最后的结果在 (0-32之中)            

5、为什么hashmap负载因子是0.75

在这里插入图片描述

源码上面注释大致意思就是说负载因子是0.75的时候,空间利用率比较高,
而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。
如果太高会导致查询复杂度增加,如果太低会增加存储空间

根据统计学来说。使用随机哈希码,节点出现的频率在hash桶遵循泊松分布。
在负载因子0.75下,每个碰撞位置的链表长度超过8个概率很低,而出现 6或者 7个还挺大的,这里直接降低了,每个hash桶底层的查询复杂度

6、JAVA7 HashMap的问题

1、并发环境容易死锁
2、可以通过精心构造的恶意请求引发Dos

7、java7到java8 做了哪些改进?为什么?

1.7和1.8主要在处理哈希冲突和扩容问题上区别比较大。

1、底层设计改变

JDK1.8 (数组+ 单链表 + 红黑树 )解决了1.7的大数据 查询效率问题
JDK1.7的时候使用的是(数组+ 单链表的数据结构)。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率)出现哈希冲突时,1.7把数据存放在链表,1.8是先放在链表,链表长度超过8就转成红黑树

2、扩容设计改变

扩容时插入顺序的改变,解决了1.7的扩容时发生死锁的问题
区别:
JDK1.7用的是头插法,有可能在扩容时,出现回环,造成死锁
而JDK1.8及之后使用的都是尾插法,但是仍有线程安全问题
总结:
HashMap之所以在并发下的扩容造成死循环,是因为,多个线程并发进行时,因为一个线程先期完成了扩容,将原的链表重新散列到自己的表中,并且链表变成了倒序,后一个线程再扩容时,又进行自己的散列,再次将倒序链表变为正序链表。于是形成了一个环形链表,当表中不存在的元素时,造成死循环。

虽然在JDK1.8中,Java的开发小组修正了这个问题,但这个问题并不是bug,只能说开发者使用不当造成的,但是HashMap始终存在着其他的线程安全问题。所以在并发情况下,我们应该使用HastTable或者ConcurrentHashMap来代替HashMap。

8、为什么说,hashmap 不是线程安全的

1、HashMap 在插入的时候
  现在假如 A 线程和 B 线程同时进行插入操作,然后计算出了相同的哈希值对应了相同的数组位置,因为此时该位置还没数据,然后对同一个数组位置,两个线程会同时得到现在的头结点,然后 A 写入新的头结点之后,B 也写入新的头结点,那B的写入操作就会覆盖 A 的写入操作造成 A 的写入操作丢失。
2、HashMap 在扩容的时候
  HashMap 有个扩容的操作,这个操作会新生成一个新的容量的数组,然后对原数组的所有键值对重新进行计算和写入新的数组,之后指向新生成的数组。
  那么问题来了,当多个线程同时进来,检测到总数量超过门限值的时候就会同时调用 resize 操作,各自生成新的数组并 rehash 后赋给该 map 底层的数组,结果最终只有最后一个线程生成的新数组被赋给该 map 底层,其他线程的均会丢失。
3、HashMap 在删除数据的时候
  删除这一块可能会出现两种线程安全问题,第一种是一个线程判断得到了指定的数组位置i并进入了循环,此时,另一个线程也在同样的位置已经删掉了i位置的那个数据了,然后第一个线程那边就没了。但是删除的话,没了倒问题不大。
  其他地方还有很多可能会出现线程安全问题,我就不一一列举了,总之 HashMap 是非线程安全的,有并发问题时,建议使用 ConcrrentHashMap。

9、haspMap 为什么使用红黑树

hashMap 的场景要求,查询快,插入快

1、红黑树(不完美平衡红黑树)
特点: 不追求完全平衡
插入比较快,因为不需要过多的自旋操作来维持,节点的绝对平衡。

2、avl树 (完美平衡树)
特点:
查询比较快,底层数据插入比较慢,为了维持高度的平衡,就要付出更多代价。

区别
查询复杂度:
        红黑树和avl树 查找的话都是logn
插入、删除,复杂度:
       平衡树一般是 logn,可能需要通过一次或多次树旋转来重新平衡这个树红黑树一般是 也是logn。但不需要额外的自旋。
此外由于它的设计,任何不平衡都会在三次旋转之内解决。

10、HashMap中的modcount表示什么什么意思?

modcount:修改次数

在集合【ArrayList,LinkedList,HashMap】等的内部实现增,删,改中,都有涉及到 modcount。
为什么要有修改 modcount?
其实这些涉及到modcount的集合都有共同的特点就是,都不是线程安全的。
HashMap也不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。这一策略在源码中的实现是通过 modCount 实现的。

11、HashMap为什么会出现ConcurrentModificationException?

【ArrayList,LinkedList,HashMap】等使用forEach删除时,会报错ConcurrentModificationException,因为在forEach遍历时,是不允许map元素进行删除和增加。
使用iterator迭代删除时没有问题的,在每一次迭代时都会调用hasNext()方法判断是否有下一个,是允许集合中数据增加和减少。

评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值