HashMap问题总结

hashmap的一些基础原理

关于浏览hashmap基础原理之后的一些汇总:
1、在JDK8及以后的版本中,HashMap引入了红黑树结构,其底层的数据结构变成了数组+链表或数组+红黑树。添加元素时,若桶中链表个数超过8,链表会转换成红黑树
在这里插入图片描述
具体原理为什么是8呢:泊松分布、概率学问题
2、红黑树,来源于二叉查找树,但是当二叉查找树最坏的情况会变成链表,所以引入红黑色的概念,而红黑树在插入跟删除时候主要数据的操作排列方式是左旋或者右旋,红黑树的查询时间复杂度是O(log N)
3、hashmap的初始容量是16,加载因子是0.75(泊松分布),当数据容量为16*0.75=12时,会进行数据的扩容,扩容是翻倍=32,但是还有一种扩容的情况,就是当数据大于8时候由链表转为树形存储时候,当检测到容量小于64时候,会进行扩容,因为数据过长原因还是由于空间不足。
4.当冲突发生的时候,通过查找数组的一个空位,将数据插入进去,而不再用hash函数计算获取数的下标,这个方法就叫做开放地址法;
通过对要插入的数值先进行hash编码,再对数值的长度进行取模i,这样得到的位置总能够落在数值的长度内,

里面有个地方可能不太好理解,就是在插入数据的时候,我们使用while循环进行插入,既然是开发地址,也就是说数组的每一个闲置的空间我们都能使用,前提是这个位置没有被其他的值占用,由于数组是连续的,所以我们需要循环的去寻找一个这样的位置,所以才有 ++hashVal这段代码,直到找到了一个空位,然后我们把数据插入进去,
我们考虑一下,数据的长度是有限的,但我们可能会往数组里面添加很多数据进去,数组总有被填满的时候,那样开发地址法也不管用了,当然,实际业务中,如果可以预料数据的大小,我们可以采用这样的方式解决部分问题,但问题是这样确实不是万无一失的解决办法,

更合适的方式是什么呢?其实就是hashMap中使用较多的链地址法,也就是一开始我们图中展示的,基本结构仍然是一个数组,但是数组的每个单元维护的不再是一个个数据,而是一个个链表,也就是类似于linkedList这样的结构,当新插入的多个数据通过计算hash函数得到的是相同的数组下标时候,我们只需要把值往这个索引位置维护的链表中插入即可

为什么是2的n次幂

1、hashmap的数据结构

要知道hashmap是什么,首先要搞清楚它的数据结构,在java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,hashmap也不例外。Hashmap实际上是一个数组和链表的结合体(在数据结构中,一般称之为“链表散列“),请看下图(横排表示数组,纵排表示数组元素【实际上是一个链表】)。
在这里插入图片描述

从图中我们可以看到一个hashmap就是一个数组结构,当新建一个hashmap的时候,就会初始化一个数组。我们来看看java代码:

	/** 
     * The table, resized as necessary. Length MUST Always be a power of two. 
     *  FIXME 这里需要注意这句话,至于原因后面会讲到 
     */  
    transient Entry[] table;  
static class Entry<K,V> implements Map.Entry<K,V> {  
        final K key;  
        V value;  
        final int hash;  
        Entry<K,V> next;  
..........  
}  

上面的Entry就是数组中的元素,它持有一个指向下一个元素的引用,这就构成了链表。

  当我们往hashmap中put元素的时候,先根据key的hash值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。从hashmap中get元素时,首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么hashmap的get效率将是最高的,但是理想总是美好的,现实总是有困难需要我们去克服,哈哈~

2、hash算法

我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。

所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式那?java中时这样做的,

static int indexFor(int h, int length) {  
       return h & (length-1);  
   }  

首先算得key得hashcode值,然后跟数组的长度-1做一次“与”运算(&)。看上去很简单,其实比较有玄机。比如数组的长度是2的4次方,那么hashcode就会和2的4次方-1做“与”运算。很多人都有这个疑问,为什么hashmap的数组初始化大小都是2的次方大小时,hashmap的效率最高,我以2的4次方举例,来解释一下为什么数组大小为2的幂时hashmap访问的性能最高。

  看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
在这里插入图片描述
  所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
  说到这里,我们再回头看一下hashmap中默认的数组大小是多少,查看源代码可以得知是16,为什么是16,而不是15,也不是20呢,看到上面annegu的解释之后我们就清楚了吧,显然是因为16是2的整数次幂的原因,在小数据量的情况下16比15和20更能减少key之间的碰撞,而加快查询的效率。

  所以,在存储大容量数据的时候,最好预先指定hashmap的size为2的整数次幂次方。就算不指定的话,也会以大于且最接近指定值大小的2次幂来初始化的,代码如下(HashMap的构造方法中):

//Find a power of 2 >= initialCapacity  
  int capacity = 1;  
  while (capacity < initialCapacity)   
      capacity <<= 1;  

总结:
本文主要描述了HashMap的结构,和hashmap中hash函数的实现,以及该实现的特性,同时描述了hashmap中resize带来性能消耗的根本原因,以及将普通的域模型对象作为key的基本要求。尤其是hash函数的实现,可以说是整个HashMap的精髓所在,只有真正理解了这个hash函数,才可以说对HashMap有了一定的理解。

3、hashmap的resize

  当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

   那么hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面annegu已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。

4、key的hashcode与equals方法改写

  在第一部分hashmap的数据结构中,annegu就写了get方法的过程:首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。所以,hashcode与equals方法对于找到对应元素是两个关键方法。

  Hashmap的key可以是任何类型的对象,例如User这种对象,为了保证两个具有相同属性的user的hashcode相同,我们就需要改写hashcode方法,比方把hashcode值的计算与User对象的id关联起来,那么只要user对象拥有相同id,那么他们的hashcode也能保持一致了,这样就可以找到在hashmap数组中的位置了。如果这个位置上有多个元素,还需要用key的equals方法在对应位置的链表中找到需要的元素,所以只改写了hashcode方法是不够的,equals方法也是需要改写滴~当然啦,按正常思维逻辑,equals方法一般都会根据实际的业务内容来定义,例如根据user对象的id来判断两个user是否相等。
在改写equals方法的时候,需要满足以下三点:
(1) 自反性:就是说a.equals(a)必须为true。
(2) 对称性:就是说a.equals(b)=true的话,b.equals(a)也必须为true。
(3) 传递性:就是说a.equals(b)=true,并且b.equals©=true的话,a.equals©也必须为true。
通过改写key对象的equals和hashcode方法,我们可以将任意的业务对象作为map的key(前提是你确实有这样的需要)。

总结:
本文主要描述了HashMap的结构,和hashmap中hash函数的实现,以及该实现的特性,同时描述了hashmap中resize带来性能消耗的根本原因,以及将普通的域模型对象作为key的基本要求。尤其是hash函数的实现,可以说是整个HashMap的精髓所在,只有真正理解了这个hash函数,才可以说对HashMap有了一定的理解。

5 、HashMap 中为什么需要一个hashCode 值

  原因就是需要用它来对HashMap 数组的位置来定位,如果向HashMap 里存一个数,单纯的依次使用equals 方法比较key 是否相同来确定当前数据是否已存储过,那效率非常低,而通过比较hashCode 值,效率就会大大提高
  你可以随便变hashcode 值来测试,最终得到的数都会小于8 ,当然会像上边一样,出现相同的数据,那样的话,就会以链表的形式存在那个数组元素上了。保证定位数组索引是在数组最大长度范围!
  那它是如何定位数组位置呢?
  回过头来再设想一下,那我们就可以通过把key转为hashCode值,然后与数组长度进行与运算 ,来确定HahsMap 中当前key对应的数据在数组中的位置 。
  原本,需要查找数组下的每个元素,以及他们对应的链,疯狂的调用equals 方法,誓死遍历出数据的方式,变成了仅仅是查找数组下的一个元素,然后,只需要equals 比较这一条链上的数据就可以了,这样equals 的使用次数降低了很多。

6、当数组长度不为2的n次幂

会出现重复下标,hash碰撞高,还有就是有些位置的元素永远用不到!
当数组长度不为2的n次幂 的时候,hashCode 值与数组长度减一做与运算 的时候,会出现重复的数据,因为不为2的n次幂 的话,对应的二进制数肯定有一位为0 ,这样,不管你的hashCode 值对应的该位,是0 还是1 ,最终得到的该位上的数肯定是0 ,这带来的问题就是HashMap 上的数组元素分布不均匀,而数组上的某些位置,永远也用不到。如下图所示:
在这里插入图片描述
这将带来的问题就是你的HashMap 数组的利用率太低,并且链表可能因为上边的(n - 1) & hash 运算结果碰撞率过高,导致链表太深。(当然jdk 1.8已经在链表数据超过8个以后转换成了红黑树的操作,但那样也很容易造成它们之间的转换时机的提前到来)。

7.hash方法解析

这是1.8版本源码

static final int hash(Object key){
  int h;
  return (key==null)?0:(h=key.hashCode())^(h>>>16);  //进行了右移16位的操作(高16位与低16位互换)
}

在这里插入图片描述
HashMap中table[0]处存放的是key为null的键值对,除去这个特例,就剩下15个table位置。

其他

HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;
这个算法实际就是取模,hash%length,计算机中直接求余效率不如位移运算,源码中做了优化hash&(length-1),
hash%length==hash&(length-1)的前提是length是2的n次方;
11%16,得到了11,那么11就放在索引为11的位置。11&(1111),最后得到的结果也是11
为什么这样能均匀分布减少碰撞呢?2的n次方实际就是1后面n个0,2的n次方-1 实际就是n个1;

  • 例如:长度为9时候,3&(9-1)=0 2&(9-1)=0 ,都在0上,碰撞了;
  • 例如:长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞;

当然如果不考虑效率直接求余即可(就不需要要求长度必须是2的n次方了);

为什么加载因子是0.75

  1.加载因子需要在时间和空间成本上寻求一种折衷。加载因子过高,例如为1,虽然减少了空间开销,提高了空间利用率,但同时也增加了查询时间成本;加载因子过低,例如0.5,虽然可以减少查询时间成本,但是空间利用率很低,同时提高了rehash操作的次数。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少rehash操作次数,所以,一般在使用HashMap时建议根据预估值设置初始容量,减少扩容操作。
选择0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择

 2.加载因子是表示Hsah表中元素的填满的程度。
加载因子越大,填满的元素越多,空间利用率越高,但冲突的机会加大了。
反之,加载因子越小,填满的元素越少,冲突的机会减小,但空间浪费多了。
冲突的机会越大,则查找的成本越高。反之,查找的成本越小。
因此,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折衷。
 3.注释里面提到的:泊淞分布
简单翻译一下就是在理想情况下,使用随机哈希码,节点出现的频率在hash桶中遵循泊松分布,同时给出了桶中元素个数和概率的对照表。
从上面的表中可以看到当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。

为什么最大是容量是1<<30

static final int MAXIMUM_CAPACITY = 1 << 30;

  • int类型是32位整型,占4个字节。
  • Java的原始类型里没有无符号类型。 -->所以首位是符号位 正数为0,负数为1
  • java中存放的是补码,1左移31位的为 16进制的0x80000000代表的是-2147483648–>所以最大只能是30

 MAXIMUM_CAPACITY作为一个2的幂方中最大值,这个值的作用涉及的比较广。其中有一点比较重要的是在hashmap中容量会确保是 2的k次方,即使你传入的初始容量不是 2的k次方,tableSizeFor()方法也会将你的容量置为 2的k次方。这时候MAX_VALUE就代表了最大的容量值。

 另外还有一点就是threshold,如果对hashmap有一点了解的人都会知道threshold = 初始容量 * 加载因子。也就是扩容的 门槛。相当于实际使用的容量。而扩容都是翻倍的扩容。那么当容量到达MAXIMUM_CAPACITY,这时候再扩容就是 1 << 31 整型溢出。所以Integer.MAX_VALUE作为最终的容量,但是是一个threshold的身份。

resize扩容过程

这句话是重点----hash(){return key % table.length;}方法,就是翻译下面的一行解释:

假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。

其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。
在这里插入图片描述
下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,
经过rehash之后,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。对应的就是下方的resize的注释。

/** 
 * Initializes or doubles table size.  If null, allocates in 
 * accord with initial capacity target held in field threshold. 
 * Otherwise, because we are using power-of-two expansion, the 
 * elements from each bin must either stay at same index, or move 
 * with a power of two offset in the new table. 
 * 
 * @return the table 
 */  
final Node<K,V>[] resize() { }

看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希值(也就是根据key1算出来的hashcode值)与高位与运算的结果。

在这里插入图片描述
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
在这里插入图片描述
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:

这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。

为什么不直接使用hashcode,要采用hash方法

1.所有的hash值都与自身进行了异或并且自身异位
因为hash值是一个int类型的数据 大小在-2147483648到2147483648 看结果就知道map是无法存放这么大角标的数据
所以我们需要将hash值进行求余

2.关键点在于既然将hash值变小了 就很容易造成碰撞(求于之后就有两个hash值相等)这时候就需要扰动函数来发挥功能了
这样做的目的就在于你求于的时候包含了高16位和第16位的特性 也就是说你所计算出来的hash值包含从而使得你的hash值更加不确定 来降低碰撞的概率
混合高位和低位来加大随机性

参考文章:
https://www.iteye.com/topic/539465
https://blog.csdn.net/wohaqiyi/article/details/81161735
https://www.jianshu.com/p/dff8f4641814
https://blog.csdn.net/qq_27093465/article/details/52270519
https://blog.csdn.net/dazhu233/article/details/79678232

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Apple_Web

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值