1.概述
全观go源码,有一个核心原则,对于所有的数据结构都遵循内存空间对齐,在应用层可以随意应用,但在源码层面会针对对应数据结构进一步优化。
go map的设计核心是哈希表,其中针对桶数量2^n,针对哈希冲突,采用了除留余法,其中处理哈希冲突,使用链地址法,链条使用溢出桶
在确定好go map的设计方向后,我们需要针对方向的设计方案进一步确认。在确定方案前,我们不妨思考以下问题。
- 初始化,桶的数量大小是如何分配
- 添加元素,超出了map空间,应该怎样扩容
- 哈希冲突,链地址的元素怎样处理
- 删除元素,链地址的元素要不要重新排列
- 删除元素,当桶的利用率过小,有没有做对应的缩容
- 修改元素,里面涉及到哈希冲突,溢出桶的存放,如何确定key位置
- 查询元素,里面涉及到哈希冲突,溢出桶的存放,如何确定key位置
- 存储元素,对于key/value的大小,我们是以怎样的方式进行存储(值/指针)
2.数据结构图
map结构图:
map迭代结构图:
3.数据结构描述
hmap结构体
| 字段 | 类型 | 含义 |
|---|---|---|
| count | int | 元素数量 |
| flags | uint8 | 针对map操作,会有对应的标志位(如当前是否有别的线程正在写map,当前是否为相同大小的增长(扩容/缩容)) |
| B | uint8 | 表示当前持有的对数值,即:当前持有backets数量=2^B |
| noverflow | uint16 | 溢出的桶的数量的近似值 |
| hash0 | uint32 | 用于散列函数计算的种子,它能为哈希函数的结果引入随机性 |
| buckets | unsafe.Pointer | 哈希桶地址,是一段连续空间的地址 |
| oldbackets | unsafe.Pointer | 旧哈希桶的地址 |
| nevacuate | uintptr | 用于当前扩容的搬迁进度 0<nevacuate <= buckets |
| extra | *mapextra | 额外字段,存在意义(当map的key/value都不是指针,Go为了避免GC扫描整个hmap,会将bmap的overflow字段移动到extra) |
mapextra结构体
如果key和elem都不包含指针并且是内联的,那么我们标记bucket类型为不包含指针。这避免了扫描此map, 但是,bmap.overflow是一个指针。为了保持溢出桶的存活,我们在 hmap.extra.overflow和hmap.exra.odoverflow中存储指向所有溢出桶的指针。
间接寻址允许在hiter中存储指向切片的指针。
| 字段 | 类型 | 含义 |
|---|---|---|
| overflow | *[]*bmap | 包含hmap.bucks的溢出桶(仅在key和elem不包含指针时使用) |
| oldoverflow | *[]*bmap | 包含hmap.oldbuckets的溢出桶(仅在key和elem不包含指针时使用) |
| nextOverflow | *bmap | 指向的是预分配的overflow bucket,预分配的用完了那么值就变成nil |
bmap结构体
结构体中除了tophash,都没有实际定义出来,只是单独分配一段连续的地址空间,实现程序自分配
| 字段 | 类型 | 含义 |
|---|---|---|
| tophash | [bucketCnt]uint8 | 存储每个hash(key)的高8位(用于插入时候的快速比较场景) |
| key(8个) | uintptr (keytype) | 虚拟key |
| elem(8个) | uintptr (elemtype) | 虚拟elem |
| overflow | uintptr | 虚拟overflow(下一个溢出桶的地址) |
maptype结构体
map.go里很多函数的第一个入参是这个结构,从成员来看很明显,此结构标识了键值对和桶大小等必要信息
有了这个结构的信息,map.go的代码就可以与具体数据类型解耦,所以map.go用内存进行存取,而无需关心key或value的具体类型。
| 字段 | 类型 | 含义 |
|---|---|---|
| typ | _type | |
| key | *_type | |
| elem | *_type | |
| bucket | *_type | hash桶的内部类型 |
| keysize | *_type | 单个key大小 |
| valuesize | uint8 | 单个value大小 |
| bucketsize | uint8 | 桶大小 |
| flags | uint32 | |
| hasher | func(unsage.Pointer,uintptr) uintptr | 针对键的hash函数(散列器) |
iter结构体
| 字段 | 类型 | 含义 |
|---|---|---|
| key | unsafe.Pointer | key(每次迭代的结果),必须位于第一 |
| elem | unsafe.Pointer | elem(每次迭代的结果),必须位于第二 |
| t | *maptype | 创建map的前置定义 |
| h | *hmap | hmap指针,指向迭代的map |
| buckets | unsafe.Pointer | 哈希表初始化的桶指针 |
| bptr | *bmap | 当前桶 |
| overflow | *[]*bmap | 保持hmap.buckets的溢出桶活动 |
| oldoverflow | *[]*bmap | 保持hmap.oldbuckets的溢出桶活动 |
| startBucket | uintptr | 最先开始迭代的位置 |
| offset | uint8 | 迭代期间开始的桶内偏移量 |
| wrapped | bool | 已从bucket数组的结尾到开头 |
| B | uint8 | hmap.B |
| i | uint8 | bptr已经遍历的键值对数量,i初始为0,当i=8时表示这个桶遍历完了,将bptr移向下一个桶 |
| bucket | uintptr | 当前迭代的位置 |
| checkBucket | uintptr | 暂时没看出哪些特别,更像一个临时值 |
4.重要的标志位
map重要参考值
| 字段 | 值 | 含义 |
|---|---|---|
| bucketCntBits | 3 | 一个桶中最多能装载的键值对(key-value)的个数为8 |
| bucketCnt | 1 << bucketCntBits | |
| loadFactorNum | 13 | 触发扩容的装载因子为6.5= loadFactorNum/loadFactorDen |
| loadFactorDen | 2 | |
| maxKeySize | 128 | 键和值超过128个字节,就会被转为指针 |
| maxElemSize | 128 | |
| dataOffset | unsafe.Offsetof(struct{ b bmap v int64 }{}.v) | 数据偏移量应该是map结构体的大小,它需要正确的对齐,对于amd64p32而言,这意味着,即指针是32位,也是64位对齐 |
| noCheck | 1<<(8*sys.PtrSize) -1 | 用于迭代器检查的bucket ID |
flags
| 字段 | 值 | 含义 |
|---|---|---|
| iterator | 1 | 可能有迭代器在使用buckets |
| oldIterator | 2 | 可能有迭代器在使用oldbuckets |
| hashWriting | 4 | 有协程正在向map写入key |
| samSizeGrow | 8 | 等量扩容 |
tophash
每个桶(如果有溢出,则包含它的overflow的链桶)在搬迁完成状态(evacuated* states)下,要么会包含它所有的键值对,要么一个都不包含(但不包括调用evacuate()方法搬迁阶段 ,该方法调用只会在对map发起write时发生,在该阶段其他goroutine是无法查看该map的)。简单的说,桶里的数据要么一起搬走,要么一个都还没搬。
tophash除了放置正常的高8位hash值,还会存储一些特殊状态值(标志该cell的搬迁进度)。正常的tophash值,最小应该是5,以下列出就是一些特殊状态值。
| 字段 | 值 | 含义 |
|---|---|---|
| emptyRest | 0 | 表示cell为空,并且比它高索引位的cell或者overflows中的cell都是空(初始化状态) |
| emptyOne | 1 | 空的cell(曾经插入过元素) |
| evacuatedX | 2 | 键值对已经搬迁完毕,key在新buckets数组的前半部分 |
| evacuatedY | 3 | 键值对已经搬迁完毕,key在新buckets数组的后半部分 |
| evacuatedEmpty | 4 | cell为空,整个bucket已经搬迁完毕 |
| minTopHash | 5 | tophash的最小正常值(小于这个值可以认为正在搬迁或搬迁结束) |
emptyRest/emptyOne标志位
1.emptyRest/emptyOne标志位方案
假设在map操作一段时间后,链桶内的数据如下图所示:
- 现在需要插入一个 hash(key) = x9,存在则更新,我们需要做的是遍历链桶,检查到桶1.2空位(先占用,由于不知道插入的key在哈希表是否存在),需要继续遍历直到遍历到桶3.8才发现是属于插入元素,将数据插入桶1.2。
- 在上述的操作中,我们能看到其实桶3.1是最后一个元素,往后的不需要遍历,节省了7次循环。
引入标志位后,会有哪些不同,同样假设在map操作一段时间后,链桶内的数据如图所示
- 现在需要插入一个 hash(key) = x9,存在则更新,我们需要做的是遍历链桶,检查到桶1.2空位(先占用,由于不知道插入的key在哈希表是否存在),需要继续遍历直到遍历到桶3.2,发现 3.2==ER,已经遍历到最后一个了,发现是属于插入元素,将数据插入桶1.2。
2.tophash标志位重置
假设在map操作一段时间后,链桶内的数据如下图所示:
- 现在需要删除一个 hash(key)=x8,key=key1,遍历链桶,检查到桶3.1的hophash相等和key相等,将对应的数据删掉并将桶3.1标志位设为emptyOne,如图所示:
1.从图中可以看到桶3.2并不是最后一个元素的后一位,这时候需要往前更新标志位,如图所示:
迁移标志位
因为迭代器的存在,迁移完所有数据的溢出桶,并不是直接删除的,go map的做法是保留tophash,其他的进行内存清除。 回到标志位重置,目前go map的扩容是等量或2倍扩容,当2倍扩容时,就有了新桶的前半段和后桶的后半段。
假设有一个map容量为8,存储的情况如下:
我们能看到在hash数组索引为3的溢出桶存储元素(假设:hash(key)=key)
现在map的容量扩容为16,需要将旧数据进行迁移,最终结果如下(需要关注的新旧桶的tophash标志位):
5.实现细节
5.1 扩容
在go map的实现中,有两种扩容方式,一是等量扩容(实际上容量不变,只对数据整理),二是2倍扩容;我们这一次讨论的主题是2倍扩容情况。
我们思考一个点,当原hash数组扩容到2倍的hash数组,我们怎样保证迁移后的数据同样符合取模规则:
直接结论,在2的倍数中,新旧的取模位置符合公式(B为旧对数):
- 当 时,
- 当 时,
除了正常的key符合上述的情况以外,还有一种特殊的key。
func main() {
n1 := math.NaN()
println(n1 == n1) // false
}
对于这种key的存储是没有意义的,我们可以任意方式去发送这些key。我们可以用tophash的最低位来推动迁移决定,所以我们要重新计算一个随机的tophash,让这些无意义的key均衡分配在所有桶中。
- 决定key存储在新桶的前半段或者后半段:
- 重新定义top:
5.2 迭代
迭代器有一个存疑点:是怎样配合编译器,将每次的结果进行返回,就是如何进行出栈出栈操作,在哪出栈入栈。
迭代这部分看起来并没有想象中的那么简单,需要考虑如下几点。
- 迭代选择 hash数组对应的桶,是新桶还是旧桶。
- 迭代器在扩容期间启动,但扩容尚未完成,扩容期间做的迁移逻辑,在迭代的过程是怎样处理,才能保证元素的准确。
- 迭代器启动以来,哈希表一直在扩容(迁移),可用的数据对应的key已经发生了变化(删/更新/删并重新插入)的情况。
带着问题思考
问题一是属于常规问题,根据扩容中的状态去界定新桶还是旧桶。
问题二就比较绕了,需要去了解扩容做了哪些事,迭代时做对应的处理。重新回顾一下扩容做了哪些事。
map扩容,如下图所示:
一、原key的归属问题,在扩容后部分key的hash数组桶不变,同样有部分key迁移到了其他的hash数组桶。至于为什么需要考虑这个问题,我们做一下迭代模拟。
参考上图,假设旧桶(3)还没有进行数据迁移。
- 当我们迭代新桶(3)时,由于数据还没有进行迁移,需要找到旧桶(3)的数据进行迭代(这时是将旧桶(3)的元素进行全部迭代?)
- 当我们迭代新桶(11)时,由于数据还没有进行迁移,需要找到旧桶(3)的数据进行迭代(这时是将旧桶(3)的元素进行全部迭代?)
- 上述的2/3都涉及一个问题,在新桶(3/6)的元素都全部在旧桶(3)里面。迭代新桶的时候针对旧桶的数据如何选择
- 对应上边存疑:直接对元素取模,确认是否为当前桶就行:,不是就跳过
二、特殊key的归属问题,特殊key(NaN),扩容时的处理是通过tophash的最低位确定key的归属: ,;所以同样判断该key可以通过:,不是则跳过。
三、key已经被删除/更新/删除并更新,这种情况会在:迭代器启动以来,哈希表一直在扩容(迁移)。这个可用的数据对应的key现在已经发生了变化。对于这种情况的处理只能是重新查找该 key-value(找不到就证明被删除)
你能看到在迭代的过程中,map对应元素在发生变化,如下边所示:
package main
func main() {
dict := map[int]int{1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6}
for key, val := range dict {
println("key: ", key, " value: ", val)
dict[3] = 5
}
}
6.设计特点
-
hmap中是bucket的数组,而不是bucket指针的数组。好的方面可以一次分配较大的内存,减少了分配次数,避免多次调用mallocgc。但相对应的缺点
- 可扩展哈希的算法并没有发生作用,扩容时会造成对整个数组的值拷贝(如果实现上用Bucket指针的数组就是指针拷贝了,代价小很多)
- 首个bucket与后面产生了不一致性。这个会使删除逻辑变得复杂一点。比如删除后面得溢出链可以直接删除,而对于首个bucket,要等到
evalucated完毕后,整个oldbucket删除时进行。
当然对于缺点二,做了一点优化,只保留tophash,剩余key/value/溢出桶指针进行内存删除。
- 散列数组符合2的对数,有利于读取或计算 即位运算。
- 扩容都是2倍扩容,好处就是数据迁移的旧数据->新数据迁移定位容易
if v&b ≠0{ x=1 }else{ x=0 } - bucket的key/value优化,小于128字节直接使用值存储,否则指向实际的指针
- hash低8位确定桶位置,hash的高8位(tophash)快速定位,然后再进行key比较,由于bucket只有8个,顺序比较,代价还是相对少。