golang系列之-swiss map
map/哈希表,是golang常用的数据结构之一,也充当set数据结构的存在,相对slice要复杂很多。从1.24开始,swiss table替代noswiss成为默认实现,swiss与noswiss区别在于,swiss使用开放地址法,noswiss使用拉链法
当前go版本:1.24
swiss map的开关放在文件
src/internal/buildcfg/exp.go
的函数ParseGOEXPERIMENT
中
数据结构
todo:文章图片待补充
1 | // table-groups是二维数组,算上slot的话是三维 |
核心数据结构包括Map、table、groupsReference、groupReference,具体如下
1 | // src/internal/runtime/maps/map.go |
初始化
字面量
字面量初始化调用的是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/internal/runtime/maps/map.go |
相对旧版,新版的逻辑看起来会复杂一些,大概的逻辑如下
- 如果hint的数量不超过8,生成map header返回
- 如果hint的数量超过8
- 新版map只能容纳7/8的数据量,因此需要根据公式
hint*8/7
重新计算容量 - 根据容量计算table数量、group数量,基本都是2的乘方向上取整
- 生成table数组、group数组
- map header、table、group等参数初始化
- 新版map只能容纳7/8的数据量,因此需要根据公式
globalDepth、globalShift一部份数据示例如下
dirSize(10) | dirSize(2) | globalDepth | globalShift |
---|---|---|---|
1 | 0001 | 0 | 64 |
2 | 0010 | 1 | 63 |
4 | 0100 | 2 | 62 |
8 | 1000 | 3 | 61 |
其他:
- table数量、group数量固定为2的乘方
- table最大容量=1024个slot=128个group
- group最大容量=8个slot
读写操作
swiss table使用二次探测法法用于定位group,而终止扫描依赖group的ctrls字段,只要该字段中有一个处于empty状态,立即终止扫描,具体通过probeSeq实现
使用的二次探测法公式为:p(i) := (i^2 + i)/2 + hash (mod mask+1)
1 | // quadratic probing - 二次探测法 |
probeSeq的使用示例如下,其中offset是group的定位索引,看起来还是比较均匀的
1 | seq := makeProbeSeq(12345678, 7) |
索引访问
使用索引获取map的数值有两种
- 仅接受一个参数
- 接受两个参数
1 | // 仅接受一个参数 |
当仅接受一个参数时,底层使用runtime_mapaccess1,当接受两个参数时,底层使用runtime_mapaccess2,其与runtime_mapaccess1只有返回值的不同
这里的runtime_mapaccess1逻辑与Get方法相似,因此只介绍maps包的接口方法
1 | // src/internal/runtime/maps/runtime_swiss.go |
遍历访问
1 | var v1 = map[int]int{1: 1, 2: 2, 3: 3} |
当使用for range遍历整个map时,依赖Iter数据结构实现该操作,大概过程如下
- 创建Iter,纪录map的快照信息、生成随机数作为table、slot的索引偏移
- 调用Next方法,获取并存储第一个key/value地址
- 上层函数继续调用Next方法,获取并存储下一个key/value地址,或者主动退出循环
注意:如果发生了扩容,iter.tab的index显示是已过时,需要从旧的table获取key,从新的table获取新的key/value
1 | type Iter struct { |
写入
1 | var v1 = make(map[string]string) |
系统调用runtime_mapassign实现map的赋值操作,其代码逻辑与Put方法相似,大概逻辑如下
- 如果是小map,扫描group,找到空位置返回地址
- 如果是常规大小的map,根据哈希值找到table,用二次探测法找到合适的group,最后找到空位置返回地址
1 | func runtime_mapassign(typ *abi.SwissMapType, m *Map, key unsafe.Pointer) unsafe.Pointer { |
扩容
map使用growthLeft作为可用的slot大小,该值一般为capacity*7/8,最大值为896。每次写入都会消耗growthLeft,当growthLeft减少到0时,触发扩容,扩容函数为rehash
大概逻辑如下
- 如果扩容后到容量没有超过单个table的容量限制
- 创建双倍容量的新table,将旧table的数据拷贝到新的table
- dirPtr按指定索引替换掉table指针
- 如果扩容后容量超过单个table的容量限制
- 创建两个容量为1024的table_a和table_b
- 按哈希值高B+1位疏散slot数据到新的table
- 疏散完毕后,双倍扩容dirPtr数组
- 重新调整dirPtr数组指针
1 | // table扩容,如果超过限制的1024个slot,分裂并更新map |
删除
1 | delete(v1, "hello") |
当使用关键字delete删除指定key时,调用mapdelete,大概逻辑如下
- 如果map的slot数量小于等于8,small map,直接扫描dirPtr指向的group,删除key/value并将对应的ctrl改为empty
- 如果map的slot数量大于8,根据hash使用二次探测查找法定位table、group,找到目标slot
- 如果该group是满数据的,将ctrl改为deleted
- 如果该group是有空位的,将ctrl改为empty
1 | // src/runtime/map_swiss.go |
清空
1 | var v1 = map[int]int{1: 1, 2: 2, 3: 3} |
使用clear清理map的所有元素时,系统调用mapclear进行处理,大概逻辑如下
- 如果是small map,直接清理掉group数据
- 如果是常规map,遍历table、group清理
1 | // src/runtime/map_swiss.go |
克隆
1 | // 以下非克隆,只是复制了header |
当使用maps.Clone复制数据时,系统使用mapclone2实现该操作,大概逻辑如下大概逻辑如下
- 创建一个Iter
- 不断调用Next获取数据,写入新的map
1 | // src/runtime/map_swiss.go |