这是我参与「第五届青训营 」伴学笔记创作活动的第 15 天
go map 原理一:内存模型
Created: February 13, 2023 10:51 PM Tags: golang note
map 的实现
map 是 Go 语言的核心数据结构之一,map 描述了一种 key/value 的映射关系,开发者通常会通过键来查询其对应的值。map 最常见的底层实现有两种:基于 hash 散列和基于平衡树,两者的存取复杂度不同,Go 语言的 map 利用的是哈希表的方式,其核心原因在于哈希表增删查改的时间复杂度都是O(1),而基于平衡树的 map (例如:C++ STL中的 map)增删查改的时间复杂度是O(logn)。
| Algorithm | Action | Average Time Complexity | Worst Time Complexity |
|---|---|---|---|
| Hash | Insert | O(1) | O(N) |
| Hash | Delete | O(1) | O(N) |
| Hash | Update | O(1) | O(N) |
| Hash | Get | O(1) | O(N) |
| Balanced Binary Tree | Insert | O(logN) | O(logN) |
| Balanced Binary Tree | Delete | O(logN) | O(logN) |
| Balanced Binary Tree | Update | O(logN) | O(logN) |
| Balanced Binary Tree | Get | O(logN) | O(logN) |
哈希查找表用一个哈希函数将 key 分配到不同的桶(bucket,可以理解为数组的 index)。这样,开销主要在哈希函数的计算以及数组的常数访问时间上。哈希查找表一般会存在”哈希碰撞“的问题,就是说不同的 key 被哈希到了同一个 bucket。一般有两种应对方式:链表法和开放定址法。链表法将一个 bucket 实现成一个链表,落在同一个 bucket 中的 key 都会插入这个链表。开放定址法则是在碰撞发生后,通过一定的规律,在数组的后面挑选”空位“,用来存放新的 key。
自平衡搜索树法的最差搜索效率是 O(logN),而哈希查找表最差是 O(N)。当然,哈希查找表的平均查找效率是 O(1),如果哈希函数设计的很好,最坏的情况基本不会出现。还有一点,遍历自平衡搜索树,返回的 key 序列,一般会按照从小到大的顺序;而哈希查找表则是乱序的。
go map 内存模型
在 GO 源码中,表示 map 的结构体是 hmap,它是 hashmap 的缩写:
// A header for a Go map.
type hmap struct {
count int // 元素个数
flags uint8
B uint8 // 扩容常量相关字段B是buckets数组的长度的对数 2^B
noverflow uint16 // 溢出的bucket个数
hash0 uint32 // hash seed
buckets unsafe.Pointer // 指向 buckets 数组,大小为 2^B,如果元素个数为 0,就为 nil
oldbuckets unsafe.Pointer // 扩容的时候,buckets 长度会是 oldbuckets 的两倍
nevacuate uintptr // 迁移进度。小于此地址的 buckets 迁移完成
extra *mapextra // 用于扩容的指针
}
type mapextra struct {
overflow *[]*bmap
oldoverflow *[]*bmap
nextOverflow *bmap
}
在 hmap 结构体中, b 是 buckets 数组长度的对数,也就是说 buckets 数组的长度就是 2^B 。bucket 里存储了 key 和 value,具体是怎么存储的马上介绍。
上面的 buckets 是一个指针,最终指向的是一个结构体:
type bmap struct {
tophash [bucketCnt]uint8
}
//底层定义的常量
const (
// Maximum number of key/value pairs a bucket can hold.
bucketCntBits = 3
bucketCnt = 1 << bucketCntBits
)
但这只是表面 (src/runtime/hashmap.go) 的结构,编译期间会动态地创建一个新的结构:
type bmap struct {
topbits [8]uint8 // 存放哈希结果
keys [8]keytype
values [8]valuetype
pad uintptr // 填充空间
overflow uintptr // 链表指针, 指向溢出的 bmap
}
bmap 就是我们常说的“桶”,桶里面最多会装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算的结果相同。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有 8 个位置)。先看一个整体图:
如果再把视角再拉近,仔细看 bmap 的内部组成:
这就是 bucket 的内存模型, HOB Hash 指的就是 top hash。并且可以注意到 key 和 value 是各自放在一起的,并不是 key/value/key/value/... 这样的形式。这样的好处是在某些情况下可以省略掉 padding 字段,节省内存空间,原理与 C++ 结构体中的地址对齐是一样的。例如,如果有这样一个类型的 map:
map[int64] int8
如果按照key/value/key/value/... 这样的模式存储,那么在一个 key/value 对之后都要额外 padding 7 个字节,而将所有的 key,value 分别存放在一起,这种形式只需要在最后添加 padding。
每个 bucket 设计成最多只能放 8 个 key-value 对,如果有第 9 个 key-value 落入当前的 bucket,那就需要再构建一个 bucket ,通过 overflow 指针连接起来。
关于 golang 中 map 的内存模型就介绍的这里,后面还有更加重要的部分,关于 map 的创建,map 是如何扩容的,遍历 map 元素时发生了什么,这些关于 map 操作的部分将放在下一篇笔记中介绍。