Go从入门到放弃7--Map实现原理

140 阅读3分钟

Go运行时使用一张哈希表来实现抽象的map类型。运行时实现了map操作的所有功能,包括查找、插入、删除、遍历等。在编译阶段,Go编译器会将语法层面的map操作重写成运行时对应的函数调用。

数据结构

type hmap struct {
	count     int //当前map中的元素个数;对map类型变量运用len内置函数时,len函数返回的就是count这个值。
	flags     uint8 //当前map的状态
	B         uint8 //B的值是bucket数量的以2为底的对数,即2^B = bucket数量
	noverflow uint16 //map中溢出桶的数
	hash0     uint32 //是哈希的种子,它能为哈希函数的结果引入随机性,这个值在创建哈希表时确定,并在调用哈希函数时作为参数传入

	buckets    unsafe.Pointer //指向当前map对应的桶的指针
	oldbuckets unsafe.Pointer //在map扩容阶段指向前一个bucket数组的指针。
	nevacuate  uintptr //在map扩容阶段充当扩容进度计数器。所有下标号小于nevacuate的bucket都已经完成了数据排空和迁移操作。

	extra *mapextra //存储map中的溢出桶
}

type mapextra struct {
	overflow    *[]*bmap
	oldoverflow *[]*bmap
	nextOverflow *bmap
}

hmap-and-buckets.png 哈希表 runtime.hmap 的桶是 runtime.bmap。每一个 runtime.bmap 都能存储 8 个键值对,当哈希表中存储的数据过多,单个桶已经装满时就会使用 extra.nextOverflow 中桶存储溢出的数据。

桶的结构体 runtime.bmap 在 Go 语言源代码中的定义只包含一个简单的 tophash 字段,tophash 存储了键的哈希的高 8 位,通过比较不同键的哈希的高 8 位可以减少访问键值对次数以提高性能:

type bmap struct {
	tophash [bucketCnt]uint8
}

在运行期间,runtime.bmap结构体其实不止包含 tophash 字段,因为哈希表中可能存储不同类型的键值对,所以键值对占据的内存空间大小只能在编译时进行推导。runtime.bmap 中的其他字段在运行时也都是通过计算内存地址的方式访问的,所以它的定义中就不包含这些字段

查找原理

Go语言选择将key与value分开存储而不是以key/value/key/value的形式存储,是为了在字节对齐时压缩空间。 在进行hash[key]的map访问操作时,会首先找到桶的位置,如下为伪代码:

hash = hashfunc(key)
index = hash % array_size

找到桶的位置后遍历tophash数组,如图所示,如果在数组中找到了相同的hash,那么可以接着通过指针的寻址操作找到对应的key与value。在map执行查找操作时,如果key的hash在指定桶的tophash数组中不存在,那么需要遍历溢出桶中的数据。

得到App_2022-06-29_23-21-11.png

扩容原理

随着哈希表中元素的逐渐增加,哈希的性能会逐渐恶化,所以我们需要更多的桶和更大的内存保证哈希的读写性能。Go 运行时的 map 实现中引入了一个 LoadFactor(负载因子),当 count > LoadFactor * 2^B 或 overflow bucket 过多时,运行时会自动对 map 进行扩容。目前 Go LoadFactor 设置为 6.5。

如果是因为overflow bucket过多导致的“扩容”,实际上运行时会新建一个和现有规模一样的bucket数组,然后在进行assign和delete操作时进行排空和迁移;如果是因为当前数据数量超出LoadFactor指定的水位的情况,那么运行时会建立一个两倍于现有规模的bucket数组 ,但真正的排空和迁移工作也是在进行assign和delete操作时逐步进行的。原bucket数组会挂在hmap的oldbuckets指针下面,直到原buckets数组中所有数据都迁移到新数组,原buckets数组才会被释放。

得到App_2022-06-30_00-01-44.png

参考资料