美团一面:JDK 1.8 中的 HashMap 如何应对 hash 冲突?我懵逼了。。

简介: 美团一面:JDK 1.8 中的 HashMap 如何应对 hash 冲突?我懵逼了。。

1 什么是hash冲突


我们知道HashMap底层是由数组+链表/红黑树构成的,当我们通过put(key, value)向hashmap中添加元素时,需要通过散列函数确定元素究竟应该放置在数组中的哪个位置,当不同的元素被放置在了数据的同一个位置时,后放入的元素会以链表的形式,插在前一个元素的尾部,这个时候我们称发生了hash冲突。


2 如何解决hash冲突


事实上,想让hash冲突完全不发生,是不太可能的,我们能做的只是尽可能的降低hash冲突发生的概率:下面介绍在HashMap中是如何应对hash冲突的?


当我们向hashmap中put元素(key, value)时,最终会执行putVal()方法,而在putVal()方法中,又执行了hash(key)这个操作,并将执行结果作为参数传递给了putVal方法。那么我们先来看hash(key)方法干了什么。


public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
    int h;
   // 判断key是否为null, 如果为null,则直接返回0;
   // 如果不为null,则返回(h = key.hashCode()) ^ (h >>> 16)的执行结果
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}


(h = key.hashCode()) ^ (h >>> 16)执行了三步操作 :我们一步一步来分析:


第1步:h = key.hashCode()

这一步会根据key值计算出一个int类型的h值也就是hashcode值,例如


"helloWorld".hashCode() --> -1554135584
"123456".hashCode() --> 1450575459
"我爱java".hashCode() --> -1588929438


至于hashCode()是如何根据key计算出hashcode值的,要分几种情况进行分析:


如果我们使用的自己创建的对象,在我们没有重写hashCode()方法的情况下,会调用Object类的hashCode()方法,而此时返回就是对象的内存地址值,所以如果对象不同,那么通过hashcode()计算出的hashcode就是不同的。

如果是使用java中定义的引用类型例如String,Integer等作为key,这些类一般都会重写hashCode()方法,有兴趣可以翻看一下对应的源码。简单来说,Integer类的hashCode()返回的就是Integer值,而String类型的hashCode()方法稍稍复杂一点,这里不做展开。总的来说,hashCode()方法的作用就是要根据不同的key得到不同的hashCode值。

JDK 8 系列教程:


https://www.javastack.cn/categories/Java/Java8/


第2步:h >>> 16


这一步将第1步计算出的h值无符号右移16位。


为什么要右移16位,当然是位了第三步的操作。


第3步:h ^ (h >>> 16)


将hashcode值的高低16位进行异或操作(同0得0、同1得0、不同得1)得到hash值,举例说明:


假设h值为:1290846991

它的二进制数为:01001100 11110000 11000011 00001111

右移十六位之后:00000000 00000000 01001100 11110000

进行异或操作后:01001100 11110000 10001100 11110000

最终得到的hash值:1290833136

那么问题来了: 明明通过第一步得到的hashcode值就可以作为hash返回,为什么还要要进行第二步和第三步的操作呢?答案是为了减少hash冲突!


元素在数组中存放的位置是由下面这行代码决定的:


// 将(数组的长度-1)和hash值进行按位与操作:
i = (n - 1) & hash  // i为数组对应位置的索引  n为当前数组的大小


我们将上面这步操作作为第4步操作,来对比一下执行1、2、3、4四个步骤和只执行第1、4两个步骤所产生的不同效果。


我们向hashmap中put两个元素node1(key1, value1)、node2(key2, value2),hashmap的数组长度n=16。


执行1、2、3、4 四个步骤:


1.h = key.hashCode()


假设计算的结果为:h = 3654061296

对应的二进制数为:    01101100 11100110 10001100 11110000

2.h >>> 16


h无符号右移16位得到:00000000 00000000 01101100 11100110

3.hash = h ^ (h >>> 16)


异或操作后得到hash:  01101100 11110000 11100000 00000110

4.i = (n-1) & hash


n-1=15 对应二进制数 :    00000000 00000000 00000000 00001111

hash :   01101100 11110000 11100000 00000110

hash & 15 :    00000000 00000000 00000000 00000110

转化为10进制 :&ensp 5

最终得到i的值为5,也就是说node1存放在数组索引为5的位置。


同理我们对(key2, value2) 进行上述同样的操作过程:


1.h = key.hashCode()


假设计算的结果为:h = 3652881648

对应的二进制数为:    01101100 11011101 10001100 11110000


2.h >>> 16


h无符号右移16位得到:00000000 00000000 01101100 11011101


3.hash = h ^ (h >>> 16)


异或操作后得到hash:  01101100 11110000 11100000 00101101


4.i = (n-1) & hash


n-1=15 对应二进制数 :    00000000 00000000 00000000 00001111

hash :   01101100 11110000 11100000 00101101

hash & 15 :   00000000 00000000 00000000 00001101

转化为10进制 :&ensp 13

最终得到i的值为13,也就是说node2存放在数组索引为13的位置


node1和node2存储的位置如下图所示:


image.png


执行1、4两个步骤:


1.h = key.hashCode()


计算的结果同样为:h = 3654061296

对应的二进制数为:    01101100 11100110 10001100 11110000


4.i = (n-1) & hash


n-1=15 对应二进制数 :    00000000 00000000 00000000 00001111

hash(h) :   01101100 11100110 10001100 11110000

hash & 15 :   00000000 00000000 00000000 00000000

转化为10进制 :  0

最终得到i的值为0,也就是说node1存放在数组索引为0的位置


同理我们对(key2, value2) 进行上述同样的操作过程:


1.h = key.hashCode()


计算的结果同样为:h = 3652881648

对应的二进制数为:    01101100 11011101 10001100 11110000

4.i = (n-1) & hash


n-1=15 对应二进制数 :    00000000 00000000 00000000 00001111

hash(h) :   01101100 11110000 11100000 11110000

hash & 15 :   00000000 00000000 00000000 00000000

转化为10进制 :  0

最终得到i的值为0,也就是说node2同样存放在数组索引为0的位置


node1和node2存储的位置如下图所示:


1.png


相信大家已经看出区别了:


当数组长度n较小时,n-1的二进制数高16位全部位0,这个时候如果直接和h值进行&(按位与)操作,那么只能利用到h值的低16位数据,这个时候会大大增加hash冲突发生的可能性,因为不同的h值转化为2进制后低16位是有可能相同的,如上面所举例子中:key1.hashCode() 和key2.hashCode() 得到的h值不同,一个h1 = 3654061296 ,另一个h2 = 3652881648,但是不幸的是这h1、h2两个数转化为2进制后低16位是完全相同的,所以h1 & (n-1)和 h2 & (n-1) 会计算出相同的结果,这也导致了node1和node2 存储在了数组索引相同的位置,发生了hash冲突。


当我们使用进行 h ^ (h >>> 16) 操作时,会将h的高16位数据和低16位数据进行异或操作,最终得出的hash值的高16位保留了h值的高16位数据,而hash值的低16数据则是h值的高低16位数据共同作用的结果。所以即使h1和h2的低16位相同,最终计算出的hash值低16位也大概率是不同的,降低了hash冲突发生的概率。


ps:这里面还有一个值的注意的点: 为什么是(n-1)?


我们知道n是hashmap中数组的长度,那么为要进行n-1的操作?答案同样是为了降低hash冲突发生的概率!


要理解这一点,我们首先要知道HashMap规定了数组的长度n必须为2的整数次幂,至于为什么是2的整数次幂,会在HashMap的扩容方法resize()里详细讲。


既然n为2的整数次幂,那么n一定是一个偶数。那么我们来比较i = hash & n和 i = hash & (n-1)有什么异同。


n为偶数,那么n转化为2进制后最低位一定为0,与hash进行按位与操作后最低位仍一定为0,这就导致i值只能为偶数,这样就浪费了数组中索引为奇数的空间,同时也增加了hash冲突发生的概率。


所以我们要执行n-1,得到一个奇数,这样n-1转化为二进制后低位一定为1,与hash进行按位与操作后最低位即可能位0也可能位1,这就是使得i值即可能为偶数,也可能为奇数,充分利用了数组的空间,降低hash冲突发生的概率。


至此, JDK1.8中 HashMap 是如何在存储元素时减少hash发生就讲解完毕了!


相关文章
|
4月前
|
Java
【JDK 源码分析】HashMap 操作方法
【1月更文挑战第27天】【JDK 源码分析】HashMap Put 元素 初始化
|
7月前
|
设计模式 算法 Java
面试官:JDK1.8 HashMap扩容rehash算法是如何优化的?
本文跟大家聊一聊一个常见的面试题,那就是JDK1.8 HashMap扩容rehash算法是如何优化的?
|
4月前
|
安全 Java
【JDK 源码分析】HashMap 线程安全问题分析
【1月更文挑战第27天】【JDK 源码分析】HashMap 线程安全问题分析
|
4月前
|
存储 Java
【JDK 源码分析】HashMap 底层结构
【1月更文挑战第27天】【JDK 源码分析】HashMap 底层结构
|
4月前
|
存储 安全 Java
Hashtable和HashMap:差异,数据结构概述,以及JDK的影响
Hashtable和HashMap:差异,数据结构概述,以及JDK的影响
24 0
|
10月前
|
存储 算法 安全
高频面试题-JDK源码篇(HashMap)
我觉得HashMap是一个高频面试题,甚至被问烂了,如果你还不懂HashMap原理,你很幸运,看了这边文章之后你将不存在这个问题!这里整理了一下HahsMap可能会被问到的知识点,从源码的角度去做了一些分析,当然你可以试着自己先回答一下: HashMap底层用到了那些数据结构? 为什么要用到链表结构? 为什么要用到红黑树? HashMap如何扩容的? HashMap是如何Put一个元素的? HashMap是如何Get一个元素的? 什么是Hash冲突 还有哪些解决Hash冲突的方式?
49 0
|
10月前
|
安全 算法 Java
JDK 7 HashMap 并发死链
JDK 7 HashMap 并发死链
|
10月前
|
SQL 算法 Java
直击灵魂!美团大牛手撸并发原理笔记,由浅入深剖析JDK源码
并发编程这四个字想必大家最近都在网上看到过有很多的帖子在讨论。我们都知道并发编程可选择的方式有多进程、多线程和多协程。在Java中,并发就是多线程模式。而多线程编程也一直是一个被广泛而深入讨论的领域。如果遇到复杂的多线程编程场景,大多数情况下我们就需要站在巨人的肩膀上利用并发编程框架——JDK Concurrent包来解决相关线程问题。
|
11月前
|
存储 安全 算法
HashMap为什么在多线程操作下不安全(jdk1.7和jdk1.8原因不同)
HashMap为什么在多线程操作下不安全(jdk1.7和jdk1.8原因不同)
72 0
|
11月前
|
安全 Java 索引
JDK常用的数据类型【1】 ——HashMap(分享篇)
JDK常用的数据类型【1】 ——HashMap(分享篇)
76 0