2. go map源码-结构体

181 阅读2分钟

上集回顾:哈希表

1.概述

全观go源码,有一个核心原则,对于所有的数据结构都遵循内存空间对齐,在应用层可以随意应用,但在源码层面会针对对应数据结构进一步优化。

go map的设计核心是哈希表,其中针对桶数量2^n,针对哈希冲突,采用了除留余法,其中处理哈希冲突,使用链地址法,链条使用溢出桶

在确定好go map的设计方向后,我们需要针对方向的设计方案进一步确认。在确定方案前,我们不妨思考以下问题。

  • 初始化,桶的数量大小是如何分配
  • 添加元素,超出了map空间,应该怎样扩容
  • 哈希冲突,链地址的元素怎样处理
  • 删除元素,链地址的元素要不要重新排列
  • 删除元素,当桶的利用率过小,有没有做对应的缩容
  • 修改元素,里面涉及到哈希冲突,溢出桶的存放,如何确定key位置
  • 查询元素,里面涉及到哈希冲突,溢出桶的存放,如何确定key位置
  • 存储元素,对于key/value的大小,我们是以怎样的方式进行存储(值/指针)

2.数据结构图

map结构图:

image.png

map迭代结构图:

image.png

3.数据结构描述

hmap结构体

字段类型含义
countint元素数量
flagsuint8针对map操作,会有对应的标志位(如当前是否有别的线程正在写map,当前是否为相同大小的增长(扩容/缩容))
Buint8表示当前持有的对数值,即:当前持有backets数量=2^B
noverflowuint16溢出的桶的数量的近似值
hash0uint32用于散列函数计算的种子,它能为哈希函数的结果引入随机性
bucketsunsafe.Pointer哈希桶地址,是一段连续空间的地址
oldbacketsunsafe.Pointer旧哈希桶的地址
nevacuateuintptr用于当前扩容的搬迁进度 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
overflowuintptr虚拟overflow(下一个溢出桶的地址)

maptype结构体

map.go里很多函数的第一个入参是这个结构,从成员来看很明显,此结构标识了键值对和桶大小等必要信息

有了这个结构的信息,map.go的代码就可以与具体数据类型解耦,所以map.go用内存进行存取,而无需关心key或value的具体类型。

字段类型含义
typ_type
key*_type
elem*_type
bucket*_typehash桶的内部类型
keysize*_type单个key大小
valuesizeuint8单个value大小
bucketsizeuint8桶大小
flagsuint32
hasherfunc(unsage.Pointer,uintptr) uintptr针对键的hash函数(散列器)

iter结构体

字段类型含义
keyunsafe.Pointerkey(每次迭代的结果),必须位于第一
elemunsafe.Pointerelem(每次迭代的结果),必须位于第二
t*maptype创建map的前置定义
h*hmaphmap指针,指向迭代的map
bucketsunsafe.Pointer哈希表初始化的桶指针
bptr*bmap当前桶
overflow*[]*bmap保持hmap.buckets的溢出桶活动
oldoverflow*[]*bmap保持hmap.oldbuckets的溢出桶活动
startBucketuintptr最先开始迭代的位置
offsetuint8迭代期间开始的桶内偏移量
wrappedbool已从bucket数组的结尾到开头
Buint8hmap.B
iuint8bptr已经遍历的键值对数量,i初始为0,当i=8时表示这个桶遍历完了,将bptr移向下一个桶
bucketuintptr当前迭代的位置
checkBucketuintptr暂时没看出哪些特别,更像一个临时值

4.重要的标志位

map重要参考值

字段含义
bucketCntBits3一个桶中最多能装载的键值对(key-value)的个数为8
bucketCnt1 << bucketCntBits
loadFactorNum13触发扩容的装载因子为6.5= loadFactorNum/loadFactorDen
loadFactorDen2
maxKeySize128键和值超过128个字节,就会被转为指针
maxElemSize128
dataOffsetunsafe.Offsetof(struct{ b bmap v int64 }{}.v)数据偏移量应该是map结构体的大小,它需要正确的对齐,对于amd64p32而言,这意味着,即指针是32位,也是64位对齐
noCheck1<<(8*sys.PtrSize) -1用于迭代器检查的bucket ID

flags

字段含义
iterator1可能有迭代器在使用buckets
oldIterator2可能有迭代器在使用oldbuckets
hashWriting4有协程正在向map写入key
samSizeGrow8等量扩容

tophash

每个桶(如果有溢出,则包含它的overflow的链桶)在搬迁完成状态(evacuated* states)下,要么会包含它所有的键值对,要么一个都不包含(但不包括调用evacuate()方法搬迁阶段 ,该方法调用只会在对map发起write时发生,在该阶段其他goroutine是无法查看该map的)。简单的说,桶里的数据要么一起搬走,要么一个都还没搬。

tophash除了放置正常的高8位hash值,还会存储一些特殊状态值(标志该cell的搬迁进度)。正常的tophash值,最小应该是5,以下列出就是一些特殊状态值。

字段含义
emptyRest0表示cell为空,并且比它高索引位的cell或者overflows中的cell都是空(初始化状态)
emptyOne1空的cell(曾经插入过元素)
evacuatedX2键值对已经搬迁完毕,key在新buckets数组的前半部分
evacuatedY3键值对已经搬迁完毕,key在新buckets数组的后半部分
evacuatedEmpty4cell为空,整个bucket已经搬迁完毕
minTopHash5tophash的最小正常值(小于这个值可以认为正在搬迁或搬迁结束)

emptyRest/emptyOne标志位

1.emptyRest/emptyOne标志位方案

假设在map操作一段时间后,链桶内的数据如下图所示:

image.png

  1. 现在需要插入一个 hash(key) = x9,存在则更新,我们需要做的是遍历链桶,检查到桶1.2空位(先占用,由于不知道插入的key在哈希表是否存在),需要继续遍历直到遍历到桶3.8才发现是属于插入元素,将数据插入桶1.2。
  2. 在上述的操作中,我们能看到其实桶3.1是最后一个元素,往后的不需要遍历,节省了7次循环。

引入标志位后,会有哪些不同,同样假设在map操作一段时间后,链桶内的数据如图所示

image.png

  1. 现在需要插入一个 hash(key) = x9,存在则更新,我们需要做的是遍历链桶,检查到桶1.2空位(先占用,由于不知道插入的key在哈希表是否存在),需要继续遍历直到遍历到桶3.2,发现 3.2==ER,已经遍历到最后一个了,发现是属于插入元素,将数据插入桶1.2。

2.tophash标志位重置

假设在map操作一段时间后,链桶内的数据如下图所示:

image.png

  1. 现在需要删除一个 hash(key)=x8,key=key1,遍历链桶,检查到桶3.1的hophash相等和key相等,将对应的数据删掉并将桶3.1标志位设为emptyOne,如图所示:

image.png

1.从图中可以看到桶3.2并不是最后一个元素的后一位,这时候需要往前更新标志位,如图所示:

image.png

迁移标志位

因为迭代器的存在,迁移完所有数据的溢出桶,并不是直接删除的,go map的做法是保留tophash,其他的进行内存清除。 回到标志位重置,目前go map的扩容是等量或2倍扩容,当2倍扩容时,就有了新桶的前半段和后桶的后半段。

假设有一个map容量为8,存储的情况如下:

我们能看到在hash数组索引为3的溢出桶存储元素(假设:hash(key)=key)

image.png

现在map的容量扩容为16,需要将旧数据进行迁移,最终结果如下(需要关注的新旧桶的tophash标志位):

image.png

5.实现细节

5.1 扩容

在go map的实现中,有两种扩容方式,一是等量扩容(实际上容量不变,只对数据整理),二是2倍扩容;我们这一次讨论的主题是2倍扩容情况。

我们思考一个点,当原hash数组扩容到2倍的hash数组,我们怎样保证迁移后的数据同样符合取模规则: b=hash(key)&2B1b = hash(key) \& 2^{B-1}

直接结论,在2的倍数中,新旧的取模位置符合公式(B为旧对数):

  1. hash(key)&2B==0hash(key) \& 2^B == 0 时,b()=b() b(新) = b(旧)
  2. hash(key)&2B!=0hash(key) \& 2^B !=0 时,b()=b()+2B2b(新) = b(旧) + \frac{2^B}{2}

除了正常的key符合上述的情况以外,还有一种特殊的key。

func main() {
    n1 := math.NaN()
    println(n1 == n1) // false
}

对于这种key的存储是没有意义的,我们可以任意方式去发送这些key。我们可以用tophash的最低位来推动迁移决定,所以我们要重新计算一个随机的tophash,让这些无意义的key均衡分配在所有桶中。

  1. 决定key存储在新桶的前半段或者后半段top()&1top(旧) \& 1
  2. 重新定义top:top()=tophash(hash(key))top(新) = tophash(hash(key))

5.2 迭代

迭代器有一个存疑点:是怎样配合编译器,将每次的结果进行返回,就是如何进行出栈出栈操作,在哪出栈入栈。

迭代这部分看起来并没有想象中的那么简单,需要考虑如下几点。

  1. 迭代选择 hash数组对应的桶,是新桶还是旧桶。
  2. 迭代器在扩容期间启动,但扩容尚未完成,扩容期间做的迁移逻辑,在迭代的过程是怎样处理,才能保证元素的准确。
  3. 迭代器启动以来,哈希表一直在扩容(迁移),可用的数据对应的key已经发生了变化(删/更新/删并重新插入)的情况。

带着问题思考

问题一是属于常规问题,根据扩容中的状态去界定新桶还是旧桶。

问题二就比较绕了,需要去了解扩容做了哪些事,迭代时做对应的处理。重新回顾一下扩容做了哪些事。

map扩容,如下图所示:

image.png

一、原key的归属问题,在扩容后部分key的hash数组桶不变,同样有部分key迁移到了其他的hash数组桶。至于为什么需要考虑这个问题,我们做一下迭代模拟。

参考上图,假设旧桶(3)还没有进行数据迁移。

  1. 当我们迭代新桶(3)时,由于数据还没有进行迁移,需要找到旧桶(3)的数据进行迭代(这时是将旧桶(3)的元素进行全部迭代?)
  2. 当我们迭代新桶(11)时,由于数据还没有进行迁移,需要找到旧桶(3)的数据进行迭代(这时是将旧桶(3)的元素进行全部迭代?)
  3. 上述的2/3都涉及一个问题,在新桶(3/6)的元素都全部在旧桶(3)里面。迭代新桶的时候针对旧桶的数据如何选择
  4. 对应上边存疑:直接对元素取模,确认是否为当前桶就行:b()==hash(key)&2Bb(新) == hash(key) \& 2^B,不是就跳过

二、特殊key的归属问题,特殊key(NaN),扩容时的处理是通过tophash的最低位确定key的归属: tophash(key)&1==1tophash(key) \& 1 == 1ture(桶后半段)false(桶前半段)ture(桶后半段),false(桶前半段);所以同样判断该key可以通过:b>>(B1)==tophash(key)&1b >>(B -1) == tophash(key)\&1,不是则跳过。

三、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.设计特点

  1. hmap中是bucket的数组,而不是bucket指针的数组。好的方面可以一次分配较大的内存,减少了分配次数,避免多次调用mallocgc。但相对应的缺点

    • 可扩展哈希的算法并没有发生作用,扩容时会造成对整个数组的值拷贝(如果实现上用Bucket指针的数组就是指针拷贝了,代价小很多)
    • 首个bucket与后面产生了不一致性。这个会使删除逻辑变得复杂一点。比如删除后面得溢出链可以直接删除,而对于首个bucket,要等到evalucated完毕后,整个oldbucket删除时进行。

当然对于缺点二,做了一点优化,只保留tophash,剩余key/value/溢出桶指针进行内存删除。

  1. 散列数组符合2的对数,有利于读取或计算 即位运算。
  2. 扩容都是2倍扩容,好处就是数据迁移的旧数据->新数据迁移定位容易 if v&b ≠0{ x=1 }else{ x=0 }
  3. bucket的key/value优化,小于128字节直接使用值存储,否则指向实际的指针
  4. hash低8位确定桶位置,hash的高8位(tophash)快速定位,然后再进行key比较,由于bucket只有8个,顺序比较,代价还是相对少。