HashMap原理详解:探测技术、数据聚集、寻址方式、墓碑删除等技术的深度剖析

849 阅读4分钟

这是我参与8月更文挑战的第20天,活动详情查看:8月更文挑战

一、前言

看到标题大家都应该觉得奇怪,我们去面试被问到HashMap的实现,大家不都是说的基于数组+链表的方式么。为什么我们会说HashMap不是基于数组+链表的方式实现的呢?其实这是大家的狭义理解导致的。真正的HashMap是广义的概念,我们平常所说的HashMap都是只Java里面的HashMap实现。这只是所有HashMap实现方法中的一种。

广义的HashMap从寻址方式上分为Open Addressing HashMap和Closed Addressing HashMap。而Open Addressing又根据探测技术细分为:Linear Probing、Quadratic Probing和Double Hashing等。在Open Addressing中又有Primary Clustering和Secondary Clustering的概念。

看到这些概念想必很多同学都已经晕了,是不是发现很多概念都没有听过。接下来楼主就为大家详细讲解广义HashMap的概念和底层实现原理。

二、广义HashMap的原理

1、寻址方式

首先我们简单介绍下什么是寻址方式。对Java HashMap有了解的同学对寻址方式应该不陌生。在Java HashMap中,其主要结构是数组+链表。我们根据Hash查找一个key应该落在数组中的哪个位置的过程就叫做寻址。寻址即如何找到Key在数组中的位置。因为所有的HashMap能够快速找到数据基础都是数组的快速定位。

广义的HashMap基于寻址方式的不同,将HashMap分为两大类:Open Addressing(开放寻址)和Closed Addressing(闭合寻址)。下面我们将对其进行深入讲解。

A、Closed Addressing(闭合寻址)

这种寻址方式大家容易理解。因为Java HashMap中的寻址方式就是Closed Addressing。其特点为:无论什么元素(元素的key)只要其通过hash定位到数组的某个位置,那么它必须放在这个位置。当出现Hash冲突的时候,我们可以使用额外存储空间来解决,如Java HashMap中使用链表的方式。

B、Open Addressing(开放寻址)

开发寻址方式是相对闭合寻址方式而言。即当我们通过hash的方式将元素(元素的key)定位到数组的某个位置时,我们不必一定将该元素放在数组的这个位置。而是可以通过其他的寻址方式(后续会讲解)将其放到数组的其他位置。

通过下图我们就可以明显看出两种寻址方式的不同(其中数字0-9表示数组的位置,而圆形圈中的数组则表示对应的元素)。

C、Open Addressing vs Closed Addressing

通过上面的分析我们可以看到Open Addressing和Closed Addressing有比较明显的区别。下面做了基本的总结:

对比项Open AddressingClosed Addressing说明
元素容量最大为数组大小最大会比数组大小大很多不考虑扩容的场景
数据聚集存在数据聚集问题不存在数据聚集问题说明是数据聚集参考后面的章节
缓存行利用可以充分利用缓存行不能利用缓存行利用缓存行可以提高读取性能
空间占用仅为数组大小会占用比数组大小更多的空间不考虑元素自身大小
高load factor性能load factor=0.9时性能都极好load factor=0.75就需要扩容load factor:数组元素相对数组大小的占比

说明:

  • 因为Closed Addressing会通过链表等额外空间的方式存储元素,所以它的容量比较大。可以说上限
  • 数据聚集(后续会介绍)会导致读取数据慢
  • Open Addressing的数据都放在数组中,数组结构可以充分利用CPU能力被加载到同一个缓存行中,从而提高读取性能
  • Closed Adressing有链表等额外结构空间,会导致占用更多的内存。
  • Closed Addressing使用链表解决Hash冲突,导致高load factor下链表很长,且不能利用缓存行导致查询性能很差。而Open Addressing的数据都在一个数组中,且能够利用缓存行,同时好的Open Addressing实现能够很好的平衡数据聚集问题,所以其在load factor=0.9时都能够有很好的性能。

通过上面的对比我们可以看到Open Addressing还是有很多优势的。特别是在高load factor下的性能表现,再加上大家都Closed Addressing的代表实现Java HashMap应该是非常的熟悉了,且网上也有很多文章讲解Java HashMap,因此本文就不再对Closed Addressing做进一步讲解。接下来我们重点讲解大家比较陌生的Open Addressing HashMap。

2、Open Addressing的探测技术

因为Open Addressing没有额外空间来解决hash冲突的问题,因此当存在Hash冲突的时候,其需要在数组中为这个元素找到一个空位置。这个当遇到hash冲突去寻找空位置的过程就叫做探测。这里使用的技术就叫做寻址技术。目前比较常用的寻址技术有Linear Probing、Quadratic Probing和Double Hashing。接下来我们详解对齐进行介绍。

A、Linear Probing(线性探测)

线性探测的方式比较简单。当写入元素的时候出现hash冲突时,我们直接去查看该位置的下一个是否可用,如果可用则直接插入元素。否则继续查找该位置的下一个,一直这样循环处理,知道找到合适的位置,或者触发Rehash。

这种探测技术计算可用位置的公式如下(i为通过hash确定的初始位置):

  • i + 1
  • i + 2
  • i + 3

这种探测方式存在一个问题。它会导致大量的元素聚集在一块,形成一个连续的链(物理地址上连续,不是链表)。当我们查找的数据在这个链里面的时候,需要不断一个一个查找。如果链越长,则查询的效率则越低。这种数据聚集在一起的现象就叫做聚集(Clustering),也可以叫做Primary Clustering。

B、Quadratic Probing(二次方探测)

二次方探测也比较简单,就是每次计算可用位置的时候不是直接+1,而是加二次方。

这种探测技术计算可用位置的公式如下(i为通过hash确定的初始位置):

  • i + 1*1
  • i + 2*2
  • i + 3*3

这种方式通过这种次方跳跃的方式寻找可用位置,虽然不容易产生Primary Clustering。但是也会产生另外一种链,比如hash冲突很严重(大量元素的hash到同一个位置),那么这些元素也会构成一个链(物理上不连续),在查找的时候仍然会导致查询慢的问题。这种数据链也是一种数据聚集。而这种数据聚集就叫做Secondary Clustering。

C、Double Hashing(二次Hash探测)

二次Hash探测顾名思义,就是当出现hash冲突的时候通过另外一个hash来计算下一个可用位置。

这种探测技术计算可用位置的公式如下(i为通过hash确定的初始位置,j=另外一个hash(key)值):

  • i + 1 × j
  • i + 2 × j
  • i + 3 × j

这种探测技术,相对来说就不会出现Primary Clustering和Secondary Clustering了。具体原因大家可以思考一下。

D、Linear Probing vs Quadratic Probing vs Double Hashing

探测技术冲突slot计算方式优点缺点
Linear Probing- i + 1
  • i + 2
  • i + 3 | 很好的利用缓存行 | 存在primary clustering问题存在secondary clustering问题 | | Quadratic Probing | - i + 1*1
  • i + 2*2
  • i + 3*3 | 有间隙可以避免primary clustering | 存在secondary clustering问题无法利用缓存行 | | Double Hashing | j=hash2(key)- i + 1 × j
  • i + 2 × j
  • i + 3 × j | 避免了primary clustering问题避免了secondary clustering问题 | 无法利用缓存行性能差(多一次hash) |

3、Clustering元素聚集

元素聚集就是只元素在数组上形成一个链,可以是物理上连续的,也可以是物理上不连续的。这种链会导致数查询的时候性能降低。就像Java HashMap中一样,当链表变长了之后HashMap的查询效率就会降低。

A、Primary Clustering

就是说元素聚集发生在物理上的连续,即在数据上的元素相邻挨着一起,中间无间隔。

B、Secondary Clustering

表示数组上的元素根据探测技术的算法形成了一个物理上不连续,但是在探测算法上连续的链。即通过通过探测技术的探测可用空位时,发现多次计算的位置都是被占用的,这就形成了一个物理上不连续但是逻辑上连续的链。

三、Open Addressing的增删查

Open Addressing的增删查和咱们熟知的Closed Addressing还是有很多不同。接下来我们就以线性探测(Linear Probing)为基础对Open Addressing Hash Map的增删查分别多进一步的讲解。

1、删除元素

在Open Addressing中要删除元素主要涉及到三种场景,如下图:

场景1:元素独立存在(图中元素12)

由于该元素独立存在于数组中,我们只需要直接将其从数组中删除接口。

场景2:元素在Clustering末尾(图中元素9)

这种场景和场景1是一样的,我们可以直接将其从数组中删除即可。

场景3:元素在Clustering中(图中元素6)

这种场景和前面的场景则不一样了。如果我们直接将元素6从数组中删除。则会导致一个问题,即下次查询的时候会查询不到7和8。因为由于6被删除,查询的时候查询到空位置则终止了(参考后续查询逻辑)。

这个问题的解决办法有如下两种:

tombstones法: 即被删除的元素不直接从数组中删除,而是标记为不可用,查询的时候仍然可以继续向后查询到元素7和8。这样被标记删除的元素叫做tombstones,即墓碑。

backward shift deletion法: 元素6被删除之后,该链中后面的元素都向前移动一格。这样元素4、5、7、8就挨着一起了。也不会影响后续的查询。

这两种删除方式各有各优缺点。

删除方式执行速度查询性能影响性能测试
tombstones直接标记,速度快删除元素不删除,会导致链很长,影响查询性能罗宾汉墓碑法
backward shift deletion需要移动元素,速度慢删除元素被删除,不会影响查询性能罗宾汉backward shift法

PS:墓碑法的性能比较低,直接使用backward shift deletion法,其性能很高。截图可以参考中连接关于罗宾汉HashMap的两种不同删除元素的性能测试连接。

2、查找元素

查找元素就比较简单了,首先根据hash直接定位到数组的位置,然后对key进行equal比较。如果key不匹配则通过对应的探测技术继续探测下一个位置。如果在探测的过程中发现了墓碑(tombstones)元素,则跳过,即寻找下一个。一直循环要么找到对应的元素,要么找到一个空位置结束。

3、插入元素

首先根据hash定位到数组的位置,如果该位置为空或者为墓碑(tombstones),则将元素放在该位置,结束插入操作。如果不为空或者墓碑,则通过对应的探测技术继续寻找下一个位置,如此循环直到找到空位置或者墓碑位置,则插入元素。或则触发rehash,重新查找。

关于广义HashMap的技术与原理就介绍到这里,下期我们将介绍Open Addressing HashMap的具体实现例子:ThreadLocal和Robin Hood HashMap。

四、惯例

如果你对本文有任何疑问或者高见,欢迎添加公众号lifeofcoder共同交流探讨。