golang系列之-map
map/哈希表,是golang常用的数据结构之一,也充当set数据结构的存在,相对slice要复杂很多。从1.24开始,swiss table替代noswiss成为默认实现,swiss与noswiss区别在于,swiss使用开放地址法,noswiss使用拉链法
当前go版本:1.23
数据结构
todo:文章图片待补充
1 | // |
map的数据结构如下所示
1 | // src/runtime/map.go |
上面数据结构中中比较关键的是
hmap
- header部份,var变量存储的也是这部份buckets
- 指向一片连续内存区域,bucket数组bmap
- bucket的具体实现,可以存储8个key/value对,尾部overflow是溢出bucket的指针
初始化
map的初始化方式有两种
- 使用字面量创建map
- 使用关键字make创建
1 | // 字面量 |
字面量
字面量初始化调用的是maplit,具体看代码
1 | // src/cmd/compile/internal/walk/complit.go |
其中,如果小于等于25个元素,直接赋值
1 | hash := make(map[string]int, 3) |
如果,大于25个元素,分key/value两组,使用for循环进行赋值
1 | for i = 0; i < len(vstatk); i++ { |
关键字
当使用make关键字初始化map时,调用的是makemap,如下:
1 | // src/runtime/map.go |
大概的逻辑如下
- 新生成hmap
- 初始状态修改
- hash种子初始化
- 计算B,如果hint<=8则B为0
- 如果B!=0,初始化buckets数组
注意
- hint超过BUCKETSIZE则放置在heap,否则放在stack
- key与value最大为128个字节,超过这个值则会转成指针
B的计算示例如下
1 | for i := range 50 { |
输出结果如下
hint | B | bucket count | capacity |
---|---|---|---|
[0,8] | 0 | 1 | 8 |
[9,13] | 1 | 2 | 16 |
[14,26] | 2 | 4 | 32 |
[27,52] | 3 | 8 | 64 |
… | … | … | … |
loadFactorNum=13是一个关键数据,右边界=13*2^(B-1)
读写操作
索引访问
使用索引获取map的数值有两种
- 仅接受一个参数
- 接受两个参数
1 | // 仅接受一个参数 |
当仅接受一个参数时,底层使用mapaccess1
1 | func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer { |
当接受两个参数时,底层使用mapaccess2,其与mapaccess1只有返回值的不同
1 | func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool) { |
这里补充两个用到的函数注释
1 | // 是否已疏散 |
遍历访问
1 | var v1 = map[int]int{1: 1, 2: 2, 3: 3} |
当使用for range遍历整个map时,依赖hiter、mapiterinit、mapiternext实现该操作,大概过程如下
- 调用mapiterinit创建hiter
- 纪录指定map的状态信息
- 复制指定map的状态属性
- 用随机数计算出一个bucket的索引和offset,用来无序化输出
- 返回第一个key/value对
- 调用mapiternext,获取并存储第一个key/value地址
- 上层函数继续调用mapiternext,获取并存储下一个key/value地址,或者主动终止循环
1 | // src/runtime/map.go |
写入
1 | var v1 = make(map[string]string) |
系统调用mapassign实现map的赋值操作,大概逻辑如下
- 确保map已经初始化
- 计算key的hash值
- 判断是否需要扩容?是,则先扩容
- 算出目标bucket的位置
- 判断是否在扩容中?是,则疏散旧bucket
- 扫描整个目标bucket,包括overflow,找到可写入的位置
- 返回value的指针,由上层更新数值
1 | // 根据key查找value并获得指向value的指针 |
扩容
当map的元素数量超过8且达到80%的饱和度时,会触发扩容,扩容函数为hashGrow。
注意:hashGrow只负责扩容,不负责疏散oldbucket,只有每次调用mapassign时才会去疏散旧的bucket
大概逻辑如下
- 判断是双倍扩容还是同等大小扩容
- 将buckets数组搬到oldbuckets
- 更新hmap状态
- 有overflow?搬到oldoverflow并更新extra状态
1 | // 只管扩容,下一次写的时候才疏散对应的bucket |
删除
1 | delete(v1, "hello") |
当使用关键字delete删除指定key时,调用mapdelete,大概逻辑如下
- 计算hash值,找到目标bucket
- 是否在扩容中?是,则疏散旧bucket
- 对比tophash和key,找到value的地址,清空tophash、key、value
- idx+1位置的tophash都是0-默认值?从idx开始往前扫描,将所有tophash=emptyOne改成0
- 更新map状态
1 | func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) { |
清空
1 | var v1 = map[int]int{1: 1, 2: 2, 3: 3} |
使用clear清理map的所有元素时,系统调用mapclear进行处理,大概逻辑如下
- 循环将每一个bucket的tophash都设置为0,包括oldbuckets
- 重置map的所有状态、seed等
- 调用makeBucketArray,清理所有key/value数据
1 | func mapclear(t *maptype, h *hmap) { |
克隆
1 | // 以下非克隆,只是复制了header |
go 1.21增加了maps.Clone用于克隆整个map数据,系统调用mapclone2实现该操作,大概逻辑如下
- 创建目标哈希表dst,复制源哈希表src的状态等相关数据
- 复制src的数据到dst
- 如果B=0且key/value非指针,直接赋值一个bucket即可,复制完毕直接退出
- 如果dst.B=src.B,1:1复制bucket数组
- 如果dst.B<=src.B,直接看代码吧
- src是否在扩容中?是,则复制src的oldbucket数据
1 | func mapclone2(t *maptype, src *hmap) *hmap { |
参考文档
3.3 哈希表
Go Maps Explained: How Key-Value Pairs Are Actually Stored
GopherCon 2016: Keith Randall - Inside the Map Implementation
Inside the Map Implementation - Gophercon
Arrays, Slices and Maps in Go
Maps and memory leaks