Golang集合源码-Map
22届211本科,在字节实习了一年多,正式工作了半年,工作主要是golang业务研发,go语言使用和生态比java简洁很多,也存在部分容易遇见的问题,常用结构需要对其底层实现略有了解才不容易写出有问题的代码。
本文学习主要参考《Go 语言设计与实现》一书,其对go语言部分底层实现解释较为清晰,但书上的都是别人的,故写一篇记录。
一、Map简介
哈希表是编程语言中必备的常见数据结构,也叫做映射、map、散列表。map实际上是一个非常简单的数据结构,其核心为hash函数、数组、拉链(或类似拉链)。
map使用数组存储元素,但是不像列表一样顺序存储,而是使用hash函数获得存储key的hash值,通常经过取模运算获得最终数组index然后存储数据,这样从map中查询元素时即可通过key计算数组index,快速定位已经存储的元素。hash冲突是不同的key通过hash函数可能得到的hash值去模后时一样的,例如数学中的抛物线函数,一定存在两个x对应的y值一样,此时数组存储在原位上就不够用了,通常使用开放寻址法或者拉链法处理,此处不展开。
map数据结构相比数组提高了集合元素的查询效率,最快有O(1)的时间复杂度,但是如果hash冲突严重,可能退化成O(n)。
不同语言中map实现主题一样,细节可能有些差别。Java中Map的实现主要是通过拉链法,如果拉链过长则会将链表转化成红黑树,在Java中数组的每个元素就是一个桶,即一个桶只有一个头元素,冲突后立即拉链。Golang中的数组每个元素是一个bmap,其中能够存储8个key-value对,并且bmap支持拉链,以bmap作为链表元素,链表元素的value是8个key-value对。Java中链表转化红黑树的条件为数组长度大于等于64且链表长度大于8,为什么都是8呢?据说按照正态分布,hash冲突为8的概率已经非常小,故Java设置8可以减少维持红黑树的性能成本,golang设置为8可以减少拉链的空间成本。
golang中bmap里可以存储8个key-value对,其实顺序存储就行了,类似java的拉链存储。而当bmap中存储的元素需要多余8个,即hash冲突过多时,bmap会有一个指向溢出桶bmap结构的指针。
二、 Map的数据结构
golang中map的数据结构使用的是hmap,其中含义明显的字段有count代表map中包含key-value的数量,即len(map)。其中B为map的底层数组长度的对数,即B=log_2(len(buckets))。其中oldbuckets是扩容时用于同时维持老数组的字段。
// A header for a Go map.
type hmap struct {
count int // map的长度
flags uint8
B uint8 // log_2(桶数量)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // 长度为2^B的桶数组. 如果map是空的可能是空.
oldbuckets unsafe.Pointer // 长度只有一半的老桶数组, 只有扩容时不为空
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}
数组中的每个元素都是bmap,bmap结构中包含tophash为一个长度为8的uint8数组,用于存储该桶对应hash值的高8位,用hash值的高8位定位桶,低8为定位桶中元素。看注释“接着是 bucketCnt 键,然后是 bucketCnt 元素。”,为什么没有显示声明字段在结构体中呢?个人理解为golang中存在指针计算偏移量的功能,后续插入、查询、扩容都是基于指针计算偏移量来进行的,只需创建的桶数组(本质是一段连续的内存)中包含8个bmap对象,每个bmap对象后面跟着8*(key_size+value_size)的大小即可。这样实现还有一个好处是没有额外的内存消耗,例如维护对象或者padding等。
注释原话是,“注意:将所有键打包在一起,然后将所有元素打包在一起,使代码比交替 keyelemkeyelem 更复杂一些…但它允许我们消除需要的填充,例如 map[int64]int8。后跟一个溢出指针。”
// Maximumnumber of key/elem pairs a bucket can hold.
bucketCntBits = 3
bucketCnt = 1 << bucketCntBits
// A bucket for a Go map.
type bmap struct {
// tophash generally contains the top byte of the hash value
// for each key in this bucket. If tophash[0] < minTopHash,
// tophash[0] is a bucket evacuation state instead.
tophash [bucketCnt]uint8
// Followed by bucketCnt keys and then bucketCnt elems.
// NOTE: packing all the keys together and then all the elems together makes the
// code a bit more complicated than alternating key/elem/key/elem/... but it allows
// us to eliminate padding which would be needed for, e.g., map[int64]int8.
// Followed by an overflow pointer.
}
三、Make一个Map
golang创建一个map对象实例通常使用make(map[k]v, hint),其中math.MulUintptr的含义为两个int指针的数据相乘的结果。
- 参数的输入,其中 t *maptype含义为key和value的类型及类型所占内存大小,以及以该类型为数据桶占的大小,h *hmap初始化时一半时nil,暂时无需关心
- 首先通过初始化输入的map数组长度hint和桶的大小相乘,获取最终占据的内存大小,以及内存大小是否溢出int值。
- 初始化map结构hmap,计算一个随机数
- 通过overLoadFactor计算map最终长度,为输入值hint的上一个2的B次方,即满足
2^B>hint
式子的最小B值。 - 如果不是懒加载,则通过makeBucketArray函数初始化map的底层数组h.buckets,这个函数会返回nextOverflow即为溢出桶
// makemap implements Go map creation for make(map[k]v, hint).
// If the compiler has determined that the map or the first bucket
// can be created on the stack, h and/or bucket may be non-nil.
// If h != nil, the map can be created directly in h.
// If h.buckets != nil, bucket pointed to can be used as the first bucket.
func makemap(t *maptype, hint int, h *hmap) *hmap {
mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
if overflow || mem > maxAlloc {
hint = 0
}
// initialize Hmap
if h == nil {
h = new(hmap)
}
h.hash0 = fastrand()
// Find the size parameter B which will hold the requested # of elements.
// For hint < 0 overLoadFactor returns false since hint < bucketCnt.
B := uint8(0)
for overLoadFactor(hint, B) {
B++
}
h.B = B
// allocate initial hash table
// if B == 0, the buckets field is allocated lazily later (in mapassign)
// If hint is large zeroing this memory could take a while.
if h.B != 0 {
var nextOverflow *bmap
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
- 可以轻易看出,如果b>=4即map的底层桶数组长度大于等于16时,此时会有
2^(b-4)
个溢出桶即原数据长度的1/16个溢出桶,和桶数组一起被分配。 - 假设当前b为4,即底层数组长度理论上为16,那么溢出桶为1个,实际底层数组长度为17。nextOverflow = (bmap)(add(buckets, baseuintptr(t.bucketsize))),其中add是加法函数将地址和偏移量加起来,其中base为数组基础长度16,故nextOverflow的含义就是底层数组第17个元素的地址。同理可得last为桶中第16个元素的地址,
- last.setoverflow(t, (*bmap)(buckets))的含义为不包含