go Map

137 阅读4分钟

Go 语言 Map(集合) | 菜鸟教程 (runoob.com)

Map · Go语言圣经 (studygolang.com)

go语言面试中面试官问我map底层应该怎么办?-地鼠文档 (topgoer.cn)

简述

Map 是一种无序的键值对的集合。

map类型的变量默认初始值为nil,需要使用make()函数来分配内存。语法为:

    make(map[KeyType]ValueType, [cap])

其中cap表示map的容量,该参数虽然不是必须的,但是我们应该在初始化map的时候就为其指定一个合适的容量。

Map 最重要的一点是通过 key 来快速检索数据,key 类似于索引,指向数据的值。

Map 是一种集合,所以我们可以像迭代数组和切片那样迭代它。不过,Map 是无序的,遍历 Map 时返回的键值对的顺序是不确定的。

在获取 Map 的值时,如果键不存在,返回该类型的零值,例如 int 类型的零值是 0,string 类型的零值是 ""。

Map 是引用类型,如果将一个 Map 传递给一个函数或赋值给另一个变量,它们都指向同一个底层数据结构,因此对 Map 的修改会影响到所有引用它的变量。

Map 类型可以写为map[K]V其中K对应的key必须是支持 == 比较运算符的数据类型,所以 Map 可以通过测试key是否相等来判断是否已经存在。

map类型的零值是nil,也就是没有引用任何哈希表。

和slice一样,map之间也不能进行相等比较;唯一的例外是和nil进行比较。要判断两个map是否包含相同的key和value,我们必须通过一个循环实现:

func equal(x, y map[string]int) bool {
    if len(x) != len(y) {
        return false
    }
    for k, xv := range x {
        if yv, ok := y[k]; !ok || yv != xv {
            return false
        }
    }
    return true
}

Map 的并发安全性

在多个 Goroutine 中使用 Map 时,我们需要注意 Map 的并发安全性。多个 Goroutine 对同一个 Map 进行读写操作时,可能会导致竞争条件和数据竞争等问题。为了解决这些问题,Go 语言提供了 sync 包中的 Map 类型。sync.Map 类型可以安全地在多个 Goroutine 中使用。

一般使用读写锁 sync.RWMutex 来保证安全。

Map 的底层原理

Map是一种通过key来获取value的一个数据结构,其底层存储方式为数组。

通过key进行hash运算(可以简单理解为把key转化为一个整形数字)然后对数组的长度取余,得到key存储在数组的哪个下标位置。

hash 冲突

开放定址法:在剩余未占用,挑选一个位置。

挑选位置的方法如下:

  1. 线性探测:插入时向后循环插入;查找时向后循环查找
  2. 拉链法:冲突位置上变为链表

开放定址(线性探测)和拉链的优缺点

  • 由上面可以看出拉链法比线性探测处理简单

  • 线性探测查找是会被拉链法会更消耗时间

  • 线性探测会更加容易导致扩容,而拉链不会

  • 拉链存储了指针,所以空间上会比线性探测占用多一点

  • 拉链是动态申请存储空间的,所以更适合链长不确定的

go 语言的Map原理

而在 Go 语言中的 Map 是使用拉链法实现的哈希表。

底层结构体是hmap,hmap里维护着若干个bucket数组 (即桶数组)。

  • tophash 用来快速查找key值是否在该 bucket 中
  • 内存对齐 k,v
  • 当一个bucket满时,通过overfolw指针链接到下一个bucket。

当往map中存储一个kv对时,通过k获取hash值,hash值的低八位和bucket数组长度取余,定位到在数组中的那个下标,hash值的高八位存储在bucket中的tophash中,用来快速判断key是否存在,key和value的具体值则通过指针运算存储。

扩容

  1. 装载因子已经超过 6.5;
  2. 哈希使用了太多溢出桶;

总结

通过对 Map 基本原理的了解,可以简单总结:

  1. Go 中的 Map 是使用拉链法实现的哈希表
  2. Map 对应 hmap 结构体,每个桶对应 bmap 结构体。 每个 bmap 结构体包含三个大小固定为 8 的数组,以及用于挂载溢出桶的指针。
  3. 核心操作是查找,首先生成键的哈希值
    • 如果 Map 处于扩容状态,使用旧桶计算索引,在旧桶中查找;找不到时,利用新桶数组计算索引,在新桶中查找。
    • 如果不处于扩容状态,直接利用桶数组计算索引,在桶数组中查找元素。
  4. 在添加元素之前,需要判断是否需要扩容。 当装载因子大于 6.5 时,需要执行翻倍扩容;当溢出桶数量过多时,只需等量扩容整理。
  5. 需要扩容时,采用渐进式驱逐的方式。 然后将元素放入适当的位置。
  6. 删除元素与查找元素非常相似,只是当找到对应键时,将相关信息删除而不是返回。