解剖Go语言map底层实现

在Golang中,map的底层实现是一个散列表。实现map的过程实际上就是实现散列表的过程。在这个散列表中,主要涉及到两个结构体:一个是hmap(Go map的头部),另一个是bmap(Go map的桶,通常称为bucket)。这两个结构体的定义如下:

hmap结构

hmap结构体包含多个字段,但为了理解map的架构,最重要的字段是标红的buckets数组。Golang的map中用于存储的结构是bucket数组。
hmap结构图:

+------------------+
| hmap             |
|------------------|
| count            |
| flags            |
| hash0            |
| buckets          |--------------------+
| oldbuckets       |                    |
| nevacuate        |                    |
| extra            |                    |
+------------------+                    |
                                        v
                                +--------------------+
                                |    buckets数组     |
                                +--------------------+

bucket结构

相比于hmap,bucket的结构更简单。标红的字段依然是核心:我们使用的map中的key和value就存储在这里。高位哈希值数组记录的是当前bucket中key相关的索引,稍后会详细叙述。还有一个字段是一个指向扩容后bucket的指针,使得bucket形成一个链表结构。

bucket结构图:
+--------------------+
|       bucket       |
|--------------------|
| tophash [8]uint8   |
| keys    [8]keyType |
| values  [8]valType |
| overflow *bucket   |----+
+--------------------+    |
                          v
                +--------------------+
                |    overflow bucket |
                +--------------------+

hmap和bucket的关系

hmap包含一个指向bucket数组的指针,bucket数组存储实际的数据。当bucket需要扩容时,通过指针链表的形式指向下一个bucket。整体的结构如下图所示:
hmap中的buckets指向bucket数组,bucket之间通过overflow指针形成链表。

hmap和bucket的关系:
+------------------+        +--------------------+
|     hmap         |        |    buckets数组     |
|------------------|        +--------------------+
|   buckets  ------|------> | bucket 0           |
|   ...            |        | tophash [8]uint8   |
+------------------+        | keys    [8]keyType |
                            | values  [8]valType |
                            | overflow *bucket   |----+
                            +--------------------+    |
                                                      v
                                                +--------------------+
                                                | overflow bucket 0  |
                                                +--------------------+

哈希表的特点

哈希表的特点是通过哈希函数对传入的key进行哈希运算,得到唯一的值,通常是一个数值。Golang的map中也有这么一个哈希函数,它计算出唯一的值,并对其进行高位和低位的分割。

低位用于寻找当前key属于hmap中的哪个bucket,而高位用于寻找bucket中的哪个key。bucket中的高位哈希值数组存储了高位值,用于声明当前bucket中有哪些key,便于搜索查找。
哈希函数的高位和低位
哈希函数将key分为高位和低位,低位用于定位bucket,高位用于在bucket中定位具体的key。

哈希值分解图:
+----------------------------------------+
|                hash value              |
+----------------------------------------+
|  high bits  |           low bits       |
+----------------------------------------+
|  用于定位bucket中的key  |  用于定位bucket  |
+----------------------------------------+

map中的key/value存储

需要特别指出的是,map中的key和value值存储在同一个数组中,顺序是key0、key1、key2...value0、value1、value2...的形式。这种存储方式的好处是,在key和value长度不同时,可以消除padding带来的空间浪费。

Go语言map的结构图

结合以上描述,可以得到Go语言map的完整结构图:
map的key和value存储
map中的key和value存储在同一个数组中,顺序如下图所示:

key/value存储顺序图:
+--------+--------+--------+--------+--------+--------+
| key0   | key1   | key2   | val0   | val1   | val2   |
+--------+--------+--------+--------+--------+--------+

map的扩容

当哈希表增长时,Go语言会将bucket数组的数量扩充一倍,产生一个新的bucket数组,并将旧数组的数据迁移至新数组。

加载因子
扩容的条件是哈希表的加载因子(load factor)。加载因子是一个阈值,一般表示为:散列包含的元素数除以位置总数。它在“冲突机会”和“空间利用”之间做了平衡。加载因子越小,空间空置率高,利用率低;加载因子越大,空间利用率高,但冲突机会也高。

Golang的map的加载因子公式是:map长度 / 2^B,其中B表示已扩容的次数。阈值是6.5。当map长度超过加载因子阈值时,Go语言会产生一个新的bucket数组,并将旧bucket数组移至oldbucket字段中。注意,这并不是立即迁移旧数组中的所有元素,而是在访问具体bucket时才会迁移对应的数据。

扩容时的map结构如下图所示:

蓝色部分代表存有数据的bucket,橘黄色部分代表空的bucket。访问旧bucket数据时,旧数据才会迁移至新bucket,如下图:
当map需要扩容时,旧的bucket数据迁移到新的bucket数组中,但不是立即完成,而是逐步迁移。

复制代码
扩容前:
+------------------+
|     hmap         |
|------------------|
|   buckets        |-----> +--------------------+
|   oldbuckets     |      | bucket 0           |
|   ...            |      +--------------------+
+------------------+

扩容后:
+------------------+                      +--------------------+
|     hmap         |                      |    新buckets数组   |
|------------------|                      +--------------------+
|   buckets        |-----> +--------------------+  新bucket 0  |
|   oldbuckets     |-----> | 旧buckets数组     |---->          |
|   ...            |      +--------------------+               |
+------------------+                                           v
                                                         +--------------------+
                                                         | 旧bucket 0        |
                                                         +--------------------+

数据访问迁移
在访问数据时,旧数据才会被迁移到新的bucket。

访问时的数据迁移:

+------------------+                      +--------------------+
|     hmap         |                      |    新buckets数组   |
|------------------|                      +--------------------+
|   buckets        |-----> +--------------------+  新bucket 0  |
|   oldbuckets     |-----> | 旧buckets数组     |---->          |
|   ...            |      +--------------------+               |
+------------------+                                           v
                                                         +--------------------+
                                                         | 旧bucket 0        |
                                                         +--------------------+
                                                         数据访问时迁移数据

map中数据的删除

理解map的整体结构后,查找、更新、删除的步骤就显而易见了。找到了map中的数据后,对key和value分别做如下操作:

在删除数据时,需要将key和value分别处理:

数据删除步骤:
1. 如果key是指针类型,将其置为空,等待GC。
2. 如果key是值类型,清除相关内存。
3. 对value进行同样的处理。
4. 将高位哈希值数组对应的索引置为空。

+------------------+
|    bucket        |
|------------------|
| keys    [8]keyType|----> [ key0, key1, ... ]
| values  [8]valType|----> [ val0, val1, ... ]
| tophash [8]uint8 |----> [ 0, 1, ... ]
| overflow *bucket |----> 其他bucket
+------------------+
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容