Golang的Map元素不能寻址的底层见解

915 阅读4分钟

先看一个例子:

func main() {
	m := make(map[string]int)
	m["Tom"] = 20
	m["Lucy"] = 20
	fmt.Println(&m["Lucy"])
}

这段代码是打印key为Lucy的地址,结果是:

# command-line-arguments
.\main.go:14:14: cannot take the address of m["Lucy"] 

不能拿到地址,因为map中的元素并不是一个变量,因此我们不能对map的元素进行取址操作,禁止对map元素取址的原因是:map可能随着元素数量的增长而重新分配更大的内存空间,从而可能导致之前的地址无效。

上面说到_重新分配更大的内存空间_这句话是什么意思呢?这就需要看看map的底层结果是什么样子了。底层里面map 的结构体是 hmap,其源码如下:

本文Go版本是1.14

type hmap struct {
    // 元素个数,调用 len(map) 时,直接返回此值
    count     int
    flags     uint8
    // buckets 的对数 log_2
    B         uint8
    // overflow 的 bucket 近似数
    noverflow uint16
    // 计算 key 的哈希的时候会传入哈希函数
    hash0     uint32
    // 指向 buckets 数组,大小为 2^B
    // 如果元素个数为0,就为 nil
    buckets    unsafe.Pointer
    // 扩容的时候,buckets 长度会是 oldbuckets 的两倍
    oldbuckets unsafe.Pointer
    // 指示扩容进度,小于此地址的 buckets 迁移完成
    nevacuate  uintptr
    extra *mapextra // optional fields
}

说明一下,B 是 buckets 数组的长度的对数,也就是说 buckets 数组的长度就是 2^B。bucket 里面存储了 key 和 value,后面会再讲。

buckets 是一个指针,最终它指向的是一个结构体:

type bmap struct {
  tophash [bucketCnt]uint8
}

但这只是表面(src/runtime/hashmap.go)的结构,编译期间会给它加料,动态地创建一个新的结构:

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

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

如图:

再来看看map扩容:

在向 map 插入新 key 的时候,会进行条件检测,符合下面这 2 个条件,就会触发扩容:

  •  装载因子超过阈值,源码里定义的阈值是 6.5。 

  • overflow 的 bucket 数量过多:当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B;当 B >= 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15。

    装载因子:loadFactor := count / (2^B) 【count 就是 map 的元素个数,2^B 表示 bucket 数量。】

  • 第 1 点:我们知道,每个 bucket 有 8 个空位,在没有溢出,且所有的桶都装满了的情况下,装载因子算出来的结果是 8。因此当装载因子超过 6.5 时,表明很多 bucket 都快要装满了,查找效率和插入效率都变低了。在这个时候进行扩容是有必要的。 

  • 第 2 点:是对第 1 点的补充。就是说在装载因子比较小的情况下,这时候 map 的查找和插入效率也很低,而第 1 点识别不出来这种情况。表面现象就是计算装载因子的分子比较小,即 map 里元素总数少,但是 bucket 数量多(真实分配的 bucket 数量多,包括大量的 overflow bucket)。 不难想像造成这种情况的原因:不停地插入、删除元素。先插入很多元素,导致创建了很多 bucket,但是装载因子达不到第 1 点的临界值,未触发扩容来缓解这种情况。之后,删除元素降低元素总数量,再插入很多元素,导致创建很多的 overflow bucket,但就是不会触犯第 1 点的规定,你能拿我怎么办?overflow bucket 数量太多,导致 key 会很分散,查找插入效率低得吓人,因此出台第 2 点规定。

其实简单理解就是:当操作map很频繁之后,它的元素很少,桶很多,导致数据特别分散,这种情况,查询效率会特别低下。这就像是一座空城,房子很多,但是住户很少,都分散了,找起人来很困难。所以map需要数据扩容并进行数据的重新迁移,把元素集中的均匀分布,达到在6.5阈值之内。那这个时候呢就会出现元素跑到其他桶里,地址就会被改变。

参考文章:map 的扩容过程是怎样的

彻底理解Golang Map彻底理解Golang Map