Golang map实践以及实现原理

使用实例

测试的主要目的是对于map,当作为函数传参时候,函数内部的改变会不会透传到外部,以及函数传参内外是不是一个map,也就是传递的是实例还是指针。(golang里面的传参都是值传递)。

Test Case1:传参为map。

func main(){
   
	fmt.Println("--------------- m ---------------")
	m := make(map[string]string)
	m["1"] = "0"
	fmt.Printf("m outer address %p, m=%v \n", m, m)
	passMap(m)
	fmt.Printf("post m outer address %p, m=%v \n", m, m)
}

func passMap(m map[string]string) {
   
	fmt.Printf("m inner address %p \n", m)
	m["11111111"] = "11111111"
	fmt.Printf("post m inner address %p \n", m)
}

运行结果是:

--------------- m ---------------
m outer address 0xc0000b0000, m=map[1:0] 
m inner address 0xc0000b0000 
post m inner address 0xc0000b0000 
post m outer address 0xc0000b0000, m=map[1:0 11111111:11111111] 

从运行结果我们可以知道:

  1. 当传参为map的时候,其实传递的是指针地址。函数内外map的地址都是一样的。
  2. 函数内部的改变会透传到函数外部。

Test Case2:Test Case1的实现其实也有个特殊使用例子,也就是当函数入参map没有初始化的时候。

func main(){
   
	fmt.Println("--------------- m2 ---------------")
	var m2 map[string]string//未初始化
	fmt.Printf("m2 outer address %p, m=%v \n", m2, m2)
	passMapNotInit(m2)
	fmt.Printf("post m2 outer address %p, m=%v \n", m2, m2)
}

func passMapNotInit(m map[string]string)  {
   
	fmt.Printf("inner: %v, %p\n",m, m)
	m = make(map[string]string, 0)
	m["a"]="11"
	fmt.Printf("inner: %v, %p\n",m, m)
}

运行结果是:

--------------- m2 ---------------
m2 outer address 0x0, m=map[] 
inner: map[], 0x0
inner: map[a:11], 0xc0000ac120
post m2 outer address 0x0, m=map[] 

从结果可以看出,当入参map没有初始化的时候,就不一样了:

  1. 没有初始化的map地址都是0;
  2. 函数内部初始化map不会透传到外部map。

其实也好理解,因为map没有初始化,所以map的地址传递到函数内部之后初始化,会改变map的地址,但是外部地址不会改变。有一种方法,return 新建的map。

内存模型

我这边的源码版本是:go 1.13

Golang的map从high level的角度来看,采用的是哈希表,并使用链表查找法解决冲突。但是golang的map实现在链表解决冲突时候有很多优化,具体我们在后面看细节。

数据结构最能说明原理,我们先看map的数据结构:

// A header for a Go map.
type hmap struct {
   
	//map 中的元素个数,必须放在 struct 的第一个位置,因为内置的 len 函数会通过unsafe.Pointer会从这里读取
	count     int 
	flags     uint8
	// bucket的数量是2^B, 最多可以放 loadFactor * 2^B 个元素,再多就要 hashGrow 了
	B         uint8
	//overflow 的 bucket 近似数
	noverflow uint16
	hash0     uint32 // hash seed
	//2^B 大小的数组,如果 count == 0 的话,可能是 nil
	buckets    unsafe.Pointer 
	// 扩容的时候,buckets 长度会是 oldbuckets 的两倍,只有在 growing 时候为空。
	oldbuckets unsafe.Pointer
	// 指示扩容进度,小于此地址的 buckets 迁移完成
	nevacuate  uintptr // progress counter for evacuation (buckets less than this have been evacuated)
	// 当 key 和 value 都可以 inline 的时候,就会用这个字段
	extra *mapextra // optional fields 
}

这里B是map的bucket数组长度的对数,每个bucket里面存储了kv对。buckets是一个指针,指向实际存储的bucket数组的首地址。 bucket的结构体如下:

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.
}

上面这个数据结构并不是 golang runtime 时的结构,在编译时候编译器会给它动态创建一个新的结构,如下:

type bmap struct {
   
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

bmap 就是我们常说的“bucket”结构,每个 bucket 里面最多存储 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)。

这里引用网络上的一张图:
在这里插入图片描述
当 map 的 key 和 value 都不是指针,并且 size 都小于 128 字节的情况下,会把 bmap 标记为不含指针,这样可以避免 gc 时扫描整个 hmap。但是,我们看 bmap 其实有一个 overflow 的字段,是指针类型的,破坏了 bmap 不含指针的设想,这时会把 overflow 移动到 extra 字段来。

// mapextra holds fields that are not present on all maps.
type mapextra struct {
   
	// If both key and elem do not contain pointers and are inline, then we mark bucket
	// type as containing no pointers. This avoids scanning such maps.
	// However, bmap.overflow is a pointer. In order to keep overflow buckets
	// alive, we store pointers to all overflow buckets in hmap.extra.overflow and hmap.extra.oldoverflow.
	// overflow and oldoverflow are only used if key and elem do not contain pointers.
	// overflow contains overflow buckets for hmap.buckets.
	// oldoverflow contains overflow buckets for hmap.oldbuckets.
	// The indirection allows to store a pointer to the slice in hiter.
	overflow    *[]*bmap
	oldoverflow *[]*bmap

	// nextOverflow holds a pointer to a free overflow bucket.
	nextOverflow *bmap
}

bmap 是存放 k-v 的地方,我们看看bmap详细的存储分布细节:
在这里插入图片描述
上图就是 bucket 的内存模型,HOB Hash 指的就是 top hash字段。我们可以看到bucket的kv分布分开的,没有按照我们常规的kv/kv/kv…这种。源码里说明这样的好处是在某些情况下可以省略掉 padding 字段,节省内存空间。

比如: map[int64]int8

如果按照 key/value/key/value/… 这样的模式存储,那在每一个 key/value pair 之后都要额外 padding 7 个字节;而将所有的 key,value 分别绑定到一起,这种形式 key/key/…/value/value/…,则只需要在最后添加 padding。

每个 bucket 设计成最多只能放 8 个 key-value 对,如果有第 9 个 key-value 落入当前的 bucket,那就需要再构建一个 bucket ,通过 overflow 指针连接起来。

创建map

map的创建非常简单,比如下面的语句:

m := make(map[string]string)
// 指定 map 长度
m := make(map[string]string, 10)

make函数实际上会被编译器定位到调用 runtime.makemap(),主要做的工作就是初始化 hmap 结构体的各种字段,例如计算 B 的大小,设置哈希种子 hash0 等等。

// 这里的hint就是我们 make 时候后面指定的初始化长度.
func makemap(t *maptype, hint int, h *hmap) *hmap {
   
	//......省略各种检查的逻辑
	
	// 找到一个 B,使得 map 的装载因子在正常范围内。
	B := uint8(0)
	for overLoadFactor(hint, B) {
   
		B++
	}
	h.B = B

	// 初始化 hash table
	// 如果 B 等于 0,那么 buckets 就会在赋值的时候再分配
	// 如果长度比较大,分配内存会花费长一点
	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
}

注意,这个函数返回的结果:*hmap 是一个指针,而我们之前讲过的 makeslice 函数返回的是 Slice 结构体对象。这也是 makemap 和 makeslice 返回值的区别所带来一个不同点:当 map 和 slice 作为函数参数时,在函数参数内部对 map 的操作会影响 map 自身;而对 slice 却不会(之前讲 slice 的文章里有讲过)。

主要原因:一个是指针(*hmap),一个是结构体(slice)。Go 语言中的函数传参都是值传递,在函数内部,参数会被 copy 到本地。*hmap指针 copy 完之后,仍然指向同一个 map,因此函数内部对 map 的操作会影响实参。而 slice 被 copy 后,会成为一个新的 slice,对它进行的操作不会影响到实参。

hash函数

关于hash函数的细节,这里就不介绍了。这里需要重点提示的是,哈希函数的算法与key的类型一一对应的。根据 key 的类型, maptype结构体的 key字段的alg 字段会被设置对应类型的 hash 和 equal 函数。

key定位和碰撞解决

对于 hashmap 来说,最重要的就是根据key定位实际存储位置。key 经过哈希计算后得到哈希值,哈希值是 64 个 bit 位(针对64位机)。根据hash值的最后B个bit位来确定这个key落在哪个桶。如果 B = 5,那么桶的数量,也就是 buckets 数组的长度是 2^5 = 32。

suppose,现在有一个 key 经过哈希函数计算后,得到的哈希结果是:

10010111 | 000011110110110010001111001010100010010110010101010 │ 01010

用最后的 5 个 bit 位,也就是 01010,值为 10,也就是 10 号桶。这个操作实际上就是取余操作,但是取余开销太大,所以代码实现上用的位操作代替。

再用哈希值的高 8 位,找到此 key 在 bucket 中的位置,这是在寻找已有的 key。最开始桶内还没有 key,新加入的 key 会找到第一个空位,放入。

buckets 编号就是桶编号,当两个不同的 key 落在同一个桶中,也就是发生了哈希冲突。冲突的解决手段是用链表法:在 bucket 中,从前往后找到第一个空位。这样,在查找某个 key 时,先找到对应的桶,再去遍历 bucket 中的 key。

下面是检索的示意图:
在这里插入图片描述
上图中,假定 B = 5,所以 bucket 总数就是 2^5 = 32。首先计算出待查找 key 的哈希,使用低 5 位 00110,找到对应的 6 号 bucket,使用高 8 位 10010111,对应十进制 151,在 6 号 bucket 中 遍历bucket 寻找 tophash 值(HOB hash)为 151 的 key,找到了 2 号槽位,这样整个查找过程就结束了。

如果在 bucket 中没找到,并且 overflow 不为空,还要继续去 ove

  • 26
    点赞
  • 60
    收藏
    觉得还不错? 一键收藏
  • 11
    评论
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值