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()方法判断是否有下一个,是允许集合中数据增加和减少。