一文了解Golang的数据结构map

205 阅读4分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

底层实现

底层是使用哈希表实现的

// A header for a Go map.
type hmap struct {
	// Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.
	// Make sure this stays in sync with the compiler's definition.
    // count 记录已经存储的键值对数目
	count     int 	 // # live cells == size of map.  Must be first (used by len() builtin)
	// flags 状态标志位,用来标记该map是否正在扩容等
    flags     uint8
    // B 可以容纳2^B个项
	B         uint8
    // noverflow 记录使用的溢出桶的数目
	noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
	// hash0 hash种子
    hash0     uint32 // hash seed
    
    // buckets 记录桶在哪
	buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
	// oldbuckets 用于记录再扩容阶段,旧桶的数目
    oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
    // nevacuate 用于记录在桶扩容阶段下一个要前移的旧桶的编号
    // 同时也指示扩容进度,小于此地址的 buckets 迁移完成
    nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)
    // extra 与gc有关,当 key 和 value 都可以 inline 的时候,就会用这个字段
    // https://www.qcrao.com/2021/08/09/talk-about-map-extra-field/
    extra *mapextra // optional fields
}

桶 bucket

这里的buckets直接使用的是bucket的数组,而不是bucket*指针的数组

意味着第一个bucket和后面移除链的bucket分配有些不同

第一个bucket是用的一段连续的内存空间,后面移除链的bucket的空间是 malloc 分配的

hash结构使用的是一个可扩展的hash算法

由hash值mod当前hash表大小决定某一个值属于哪个桶,而hash表大小是2的倍数,即上面结构体的2^B

每次扩容的时候,会增大到上一次大小的两倍

结构体有一个buckets和一个oldbuckets用来实现增量扩容的,正常情况下直接使用buckets,如果oldbuckets不为空,则表示map正在扩容中,且buckets是oldbuckets大小的两倍

记录溢出桶相关信息

// mapextra holds fields that are not present on all maps.
type mapextra struct {
	// 意思是map的key和value不包含指针的话,就能避免gc期间对它的扫描
    // 在map中key数量非常大的时候能起到很好的优化作用
    // 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.
    
    // 如果 key 和 value 都不包含指针,并且可以被 inline(<=128 字节)
    // 使用 extra 来存储 overflow bucket,这样可以避免 GC 扫描整个 map
    // 然而 bmap.overflow 也是个指针。这时候我们只能把这些 overflow 的指针
    // 都放在 hmap.extra.overflow 和 hmap.extra.oldoverflow 中了
    // overflow 包含的是 hmap.buckets 的 overflow 的 bucket
    // oldoverflow 包含扩容时的 hmap.oldbuckets 的 overflow 的 bucket
    // overflow 是一个slice。记录已经被使用的溢出桶的地址
	overflow    *[]*bmap
    // oldoverflow 用于在扩容阶段存储旧桶用到的溢出桶的地址
	oldoverflow *[]*bmap

	// nextOverflow holds a pointer to a free overflow bucket.
    // nextOverflow 指向下一个空闲溢出桶,这个是预分配的bucket
	nextOverflow *bmap
}

bucket结构如下

// Maximum number of key/elem pairs a bucket can hold.
bucketCntBits = 3
bucketCnt     = 1 << bucketCntBits

// A bucket for a Go map.
type bmap struct {
    // 存储每个hash value的最高字节
    // 对于每个桶里的键,如果tophash[0] < minTopHash, 
    // tophash[0] 是桶疏散状态
    
    // hash值的高8位....低位从bucket的array定位到bucket
	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.
    // 将key放一起,将value放一起,防止交替存放的时候,字节对齐会浪费很多存储空间
    // 比如说map[int64]int8,在每一对key/value对齐后还要额外padding7字节
}

// 其实在编译期间,编译器会动态地创建一个新的结构
type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

bucketCnt是常数8,每个bucket中存放最多8个key-value对,多余的话就会多申请一个bucket(溢出桶),然后将它和之前的bucket链起来

如果hash表要分配的桶的数目大于2^4,就会预分配2^(B-4)个溢出桶做备用

桶在内存中的排列是连续的,只是前2^B个作为常规桶,剩下的作为溢出桶作备用

存储布局

按key类型采用相应的hash算法得到key的hash值,将hash值的低位当做hmap结构体中buckets数组的index,用于选择桶,将hash的高8位存储在bucket的tophash中,以区分单个桶中的entry

  • 高8位是作为一个主键,在查找tophash数组的时候每一项进行顺序匹配的
    • 先比较hash值高位与bucket的tophash[i]是否相等
    • 如果相等返回其对应的value
    • 否则到overflow buckets上继续寻找

存储近似如下图(图源eggo)

最上面一行的tophash,每个tophash都对应key的hash值的高八位

overflow是一个溢出桶指针

一些优化

将key放一起,将value放一起,防止交替存放的时候,字节对齐会浪费很多存储空间。这样能让内存排列更加紧凑

比如说map[int64]int8,在每一对key/value对齐后还要额外padding7字节,而key/key/...vlaue/value/...这样排列只需要在末尾处padding

如何比较两个map相等

map深度相等的条件

  • 都为 nil
  • 非空、长度相等,指向同一个 map 实体对象
  • 相应的 key 指向的 value “深度”相等

==来判断的话只能比较是否为nil,两个map使用==来判断是不能通过遍历的

因此只能是遍历 map的每个元素,比较每个元素是否都深度相等

float可以作为map的key吗

float64作为key的时候先要转成uint64类型,再插入key

具体实现为Float64frombits函数

// 主要是将浮点数表示成 IEEE 754 规定的格式
// 如下2.4和2.4000000000000000000000001经过转换后是一样的key
// Float64frombits returns the floating-point number corresponding
// to the IEEE 754 binary representation b, with the sign bit of b
// and the result in the same bit position.
// Float64frombits(Float64bits(x)) == x.
func Float64frombits(b uint64) float64 { return *(*float64)(unsafe.Pointer(&b)) }

// NAN 不同,直接调用的Float64frombits
// 传入的参数是常量uvnan
// NaN returns an IEEE 754 ``not-a-number'' value.
func NaN() float64 { return Float64frombits(uvnan) }

const(
    uvnan    = 0x7FF8000000000001
)

// 对于float64的hash func
func f64hash(p unsafe.Pointer, h uintptr) uintptr {
	f := *(*float64)(p)
	switch {
	case f == 0:
		return c1 * (c0 ^ h) // +0, -0
    // 针对NAN,会再加一个随机数
	case f != f:
		return c1 * (c0 ^ h ^ uintptr(fastrand())) // any kind of NaN
	default:
		return memhash(p, h, 8)
	}
}
// NAN的特性如下
// NAN != NAN
// hash(NAN) != hash(NAN)
// 因此在map中查找的key为NAN时,什么都查不到

// 如果key是引用类型的话,需要判断keyhash后的值相等且key的字面量相等
func TestT(t *testing.T) {
	type S struct {
		ID	int
	}
	s1 := S{ID: 1}
	s2 := S{ID: 1}

	var h = map[*S]int {}
	h[&s1] = 1
	t.Log(h[&s1])
	t.Log(h[&s2])
	t.Log(s1 == s2)
}
/*
=== RUN   TestT
--- PASS: TestT (0.00s)
    endpoint_test.go:74: 1
    endpoint_test.go:75: 0
    endpoint_test.go:76: true
PASS

Process finished with exit code 0
*/

可以对map取地址吗

无法对map的key或value取址,代码不能通过编译

map的key/value的地址是临时昌盛的结果,是不可寻址的

就算如果通过其他hack的方式获取到key-value地址也不能长期持有,只要扩容位置就会改变

参考

幼麟实验室

《go语言笔试面试宝典》

go语言核心36讲 - 极客时间