本文已参与「新人创作礼」活动,一起开启掘金创作之路。
底层实现
底层是使用哈希表实现的
// 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讲 - 极客时间