面试系列 | HashMap

自我复习, 自我总结    

HashMap是Java程序员使用频率最高的用于映射(键值对)处理的数据类型。随着JDK版本的更新,JDK1.8对HashMap底层的实现进行了优化,例如引入红黑树的数据结构和扩容的优化等。

Java为数据结构中的映射定义了一个接口java.util.Map,此接口主要有四个常用的实现类,分别是: HashMap 、 Hashtable 、 LinkedHashMap 、 TreeMap。

 

类继承关系如下图所示:

继承关系-图片引用自美团技术博客

 

关于HaspMap几点说明:

1、它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度。

2、因为HashMap是散列映射,也就是它不会记录数据存储时的顺序, 所以在取数的顺序是不确定的。

3、HashMap最多只允许一条记录的键为null,允许多条记录的值为null。(而Hashtable则不能)

4、HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。

5、如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。

 

从结构实现来讲,HashMap是数组+链表+红黑树JDK1.8增加了红黑树部分

数据结构-图片引用自美团技术博客

 

(1) 从源码可知,HashMap类中有一个非常重要的字段,就是 Node[] table,即哈希桶数组,明显它是一个Node的数组。

Node是HashMap的一个内部类,实现了Map.Entry接口,本质是就是一个映射(键值对)。上图(数据结构)中的每个黑色圆点就是一个Node对象(Java1.7使用Entry对象)。

 

(2) HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,Java中HashMap采用了链地址法。链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上。

 

Hash碰撞原因:

    当执行map.put("美团","小美")系统将调用”美团”这个key的hashCode()方法得到其hashCode 值(该方法适用于每个Java对象),然后再通过Hash算法的后两步运算(高位运算和取模运算,下文有介绍)来定位该键值对的存储位置,有时两个key会定位到相同的位置,表示发生了Hash碰撞。当然Hash算法计算结果越分散均匀,Hash碰撞的概率就越小,map的存取效率就会越高。

扩容机制的产生:

    如果哈希桶数组很大,即使较差的Hash算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。那么通过什么方式来控制map使得Hash碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少呢?答案就是好的Hash算法和扩容机制

 

JDK1.7用的是头插法, JDK1.8用的尾插法: 

    因为JDK1.7是用单链表进行的纵向延伸,新来的值会取代原有的值,原有的值就顺推到链表中去,当采用头插法就是能够提高插入的效率,但是也会容易出现逆序且环形链表死循环问题。

    在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

为什么使用尾插法呢?

    这就又要说到扩容, 扩容的两个因素:

    Capacity:HashMap当前长度。

    LoadFactor:负载因子,默认值0.75f。

    怎么理解呢,就比如当前的容量大小为100,当你存进第76个的时候,判断发现需要进行resize了,那就进行扩容,但是HashMap的扩容也不是简单的扩大点容量这么简单的。

    扩容:创建一个新的Entry空数组,长度是原数组的2倍。

    ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。

为什么要重新Hash呢,直接复制过去不就好了吗?

    是因为长度扩大以后,Hash的规则也随之改变。

    Hash的公式---> index = HashCode(Key) & (Length - 1)

    说完扩容机制我们言归正传,为啥之前用头插法,java8之后改成尾插了呢?

我先举个例子吧,我们现在往一个容量大小为2的put两个值,负载因子是0.75是不是我们在put第二个的时候就会进行resize?2 * 0.75 = 1 所以插入第二个就要resize了

我们分析下resize的源码: (鉴于JDK1.8融入了红黑树,较复杂,为了便于理解我们仍然使用JDK1.7的代码,好理解一些,本质上区别不大)

这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。

 

下面举个例子说明下扩容过程。假设了我们的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的过程。

Node重新rehash的过程 - 图片引用自美团技术博客

 

下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。

确定索引位置  - 图片引用自美团技术博客

 

元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

重新计算hash - 图片引用自美团技术博客

 

确定哈希桶数组索引的位置(以下是源码实现):

这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算

 

因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:

16k扩充为32的resize - 图片引用自美团技术博客

可以看看JDK1.7和JDK1.8 HaspMap中reszie()(自行查看源码)

    在旧数组中同一条Entry链上的元素(JDK1.7),通过重新计算索引位置后,有可能被放到了新数组的不同位置上。(用多个线程去操作)如果这个时候去取值,悲剧就出现了——Infinite Loop

    Java8之后链表有红黑树的部分,大家可以看到代码已经多了很多if else的逻辑判断了,红黑树的引入巧妙的将原本O(n)的时间复杂度降低到了O(logn)。

 

    Java1.7在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。

    Java1.8在同样的前提下并不会引起死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系。

但并代表在多线程状态下就安全, 通过源码看到put/get方法都没有加同步锁,多线程情况最容易出现的就是:无法保证上一秒put的值,下一秒get的时候还是原值,所以线程安全还是无法保证。

 

HashMap的put方法执行过程可以通过下图来理解: 

HashMap之put过程-图片引用自美团技术博客

 

①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;

②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空, 转向③;

③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;

④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;

⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;

⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

用容易表述的话来讲put过程, 就是(JDK1.8版):

1、对Key求hash值,然后再计算下标;

2、如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的Hash值相同,需要放到同一个bucket中);

3、如果碰撞了,以链表的方式链接到后面;

4、如果链表长度超过阀值( TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表;

5、如果节点已经存在就替换旧值;

6、如果桶满了(容量16*加载因子0.75),就需要 resize(扩容2倍后重排);

 

HashMap的默认初始化长度是多少?

(源码大概在230行左右)

 

为什么要用位运算1 << 4, 不直接写16?  为什么是16?

    因为(数组长度-1)正好相当于一个“低位掩码”, 与操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。

以初始长度16为例,16-1=15, 二进制表示是00000000 00000000 00001111

和某散列值做“与”操作如下,结果就是截取了最低的四位值

    但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重, 这时候“扰动函数”的价值就体现出来了

    右位移16位,正好是32位一半,自己的高半区和低半区做异或,就是为了混合原始hashCode的高位和低位,以此来加大低位的随机性, 而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。

    这样也是为了位运算的方便,位与运算比算数计算的效率高了很多,之所以选择16,是为了服务将Key映射到index的算法。在使用不是2的幂的数字的时候,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。

 

下面2张图体现hash均匀分布和极不均匀分布的花费情况:

Hash均匀分布的情况-图片引用自美团技术博客

Hash极不均匀的情况-图片引用自美团技术博客

测试环境:处理器为2.2 GHz Intel Core i7,内存为16 GB 1600 MHz DDR3,SSD硬盘,使用默认的JVM参数,运行在64位的OS X 10.10.1上。

从表中结果中可知,随着size的变大,JDK1.7的花费时间是增长的趋势,而JDK1.8是明显的降低趋势,并且呈现对数增长稳定。当一个链表太长的时候,HashMap会动态的将它替换成一个红黑树,这话的话会将时间复杂度从O(n)降为O(logn)。hash算法均匀和不均匀所花费的时间明显也不相同,这两种情况的相对比较,可以说明一个好的hash算法的重要性。

 

为什么在JDK1.7的时候是先进行扩容后进行插入,而在JDK1.8的时候则是先插入后进行扩容的呢?

(其实这个问题也是JDK8对HashMap中,主要是因为对链表转为红黑树进行的优化,因为你插入这个节点的时候有可能是普通链表节点,也有可能是红黑树节点,但是为什么1.8之后HashMap变为先插入后扩容的原因,我也有点不是很理解?欢迎来讨论这个问题?

但是在JDK1.7中的话,是先进行扩容后进行插入的,就是当你发现你插入的桶是不是为空,如果不为空说明存在值就发生了hash冲突,那么就必须得扩容,但是如果不发生Hash冲突的话,说明当前桶是空的(后面并没有挂有链表),那就等到下一次发生Hash冲突的时候在进行扩容,但是当如果以后都没有发生hash冲突产生,那么就不会进行扩容了,减少了一次无用扩容,也减少了内存的使用。)

 

为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是8,而不是7或者不是20呢?

(如果选择6和8(如果链表小于等于6树还原转为链表,大于等于8转为树),中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

还有一点重要的就是由于treenodes的大小大约是常规节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们,当它们变得太小(由于移除或调整大小)时,它们会被转换回普通的node节点,容器中节点分布在hash桶中的频率遵循泊松分布,桶的长度超过8的概率非常非常小。所以作者应该是根据概率统计而选择了8作为阀值。)

 

关于HashMap的几点建议:

(1) 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。

(2) 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。

(3) HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。

(4) JDK1.8引入红黑树大程度优化了HashMap的性能

 

可能在面试中问到的问题, 回顾一下吧

 

HashMap的工作原理是什么?

HashMap为什么是线程不安全的? (Java1.7在多线程操作HashMap时可能引起死循环; JDK1.8, 源码put/get方法都没有加同步锁, 无法保证刚put,马上就能get到)

HashMap和HashTable区别? (前者可以接受null键和值,后者不能(原因就是equlas()方法需要对象,因为HashMap是后出的API经过处理才可以);线程安全问题[后者简单粗暴,直接在方法上锁,并发度很低]等)

HashMap的底层数据结构?(数组+链表+红黑树[JDK1.8])

HashMap的存取原理?(put时hash冲突、扩容、红黑树等, gets时key去hash然后计算出index, index相同时equals判断)

HashMap检测到hash冲突后, 元素插入在链表的末尾还是开头? (JDK1.7用的是头插法, JDK1.8用的尾插法)

HashMap是怎么处理hash碰撞的?解决hash碰撞还有哪些办法?(HashMap采用了链地址法,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上; 开放地址法、链地址法(拉链法)、再哈希、建立公共溢出区)

拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树?(红黑树在添加、删除、查找方便表现相对较好, AVL树查找速度更快, 但代价是添加/删除慢, 当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,降低了时间复杂度,可以说说红黑树的特点;当然小于UNTREEIFY_THRESHOLD(默认为6)时,又会转回链表以达到性能均衡)

HashMap在Java7和Java8中的区别?(扩容方面, 数据结构方面等等)

有什么线程安全的类代替么? (HashTable或者ConcurrentHashMap, 这可能又是一扒拉一堆问)

默认初始化大小是多少?为啥是这么多?为啥大小都是2的幂?(默认16, 是为了服务将Key映射到index的算法, 位与运算的高效; 在使用不是2的幂的数字的时候,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。)

HashMap的扩容方式?负载因子是多少?为什是这么多?(Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75), threshold是HashMap所能容纳的最大数据量的Node(键值对)个数, threshold = length * Load factor, 超过这个数目就重新resize(扩容), 扩容后的HashMap容量是之前容量的两倍,;主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。)

HashMap的主要参数都有哪些?(Node对象数组(JDK1.8), 数组初始化长度默认16, 负载因子0.75等等)

 

 

资料来源: 美团技术博客、github-JavaFamily

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
HashMapJava中的一个常用的数据结构,它是基于哈希表实现的。在存储过程中,首先会计算元素的hashcode,然后根据hashcode进行与操作,得到元素应该存放在哈希表中的桶的索引。如果桶中没有冲突,即没有其他元素存放在同一个桶中,那么元素就直接插入到该桶中。如果桶中有冲突,即已经有其他元素存放在同一个桶中,那么元素会以链表的形式插入到该桶中。当链表的长度达到一定阈(一般为8),并且当前HashMap的容量大于64时,链表会被转换为红黑树,以提高查找效率。如果当前容量小于64,则会进行扩容操作,以保证哈希表的负载因子不会过高。 在存储过程中,HashMap还会判断元素的hashcode是否相同,如果hashcode相同,则会使用equals方法判断元素是否重复。如果元素重复,那么新元素将会覆盖原有的元素。因为HashMap不允许存储重复元素。 总结来说,HashMap是通过计算元素的hashcode来确定元素在哈希表中的位置,并使用链表或红黑树解决哈希冲突问题。同时,在存储过程中还会判断元素是否重复,并进行相应的处理。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [java面试题:讲一讲hashMap](https://blog.csdn.net/weixin_44844089/article/details/117455417)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值