一、前言
看到标题大家都应该觉得奇怪,我们去面试被问到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 Addressing | Closed 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 |
| 很好的利用缓存行 | 存在primary clustering问题 存在secondary clustering问题 |
Quadratic Probing |
| 有间隙可以避免primary clustering | 存在secondary clustering问题 无法利用缓存行 |
Double Hashing | j=hash2(key)
| 避免了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共同交流探讨(添加公众号可以获得楼主最新博文推送以及”Java高级架构“上10G视频和图文资料哦)。