使用实例
测试的主要目的是对于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]
从运行结果我们可以知道:
- 当传参为map的时候,其实传递的是指针地址。函数内外map的地址都是一样的。
- 函数内部的改变会透传到函数外部。
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没有初始化的时候,就不一样了:
- 没有初始化的map地址都是0;
- 函数内部初始化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