作者:
本文是对victoriametrics Go Maps Explained: How Key-Value Pairs Are Actually Stored的整理与翻译
发布日期:2024 年 8 月 16 日
阅读时间:17 分钟
分类:
- Go @ VictoriaMetrics
- 开源技术
如果你刚开始接触 Go,可能会觉得 Go 中的 Map 用起来有些令人困惑。
即使已经积累了更多 Go 开发经验,想真正搞清楚 Map 底层究竟是怎么工作的,也并不是一件容易的事情。
比如下面这个例子。
你有没有在创建 Map 时设置过一个hint,然后想过:
为什么这里叫作“hint(提示)”,而不像 Slice 那样直接叫 length 或 capacity 之类更加明确的东西?
// hint = 10m:=make(map[string]int,10)或者,你可能已经注意到:
使用for-range遍历一个 Map 时,得到的顺序并不等于键值对插入 Map 的顺序。
而且更加奇怪的是,即使遍历的是同一个 Map,在不同时间执行遍历时,得到的顺序也可能发生变化。
但很奇怪的是,如果你恰好在同一时间遍历它,顺序通常又会保持一致。
这是一个很长的故事。
所以,系好安全带,我们开始吧。
在继续之前先说明一下:本文中的内容基于Go 1.23。
如果 Go 后续发生了变化,导致本文内容已经过时,欢迎通过 X(@func25) 联系我。
Go 中的 Map:快速入门
先来聊聊 Go 中的 Map。
Map 是 Go 内置的一种类型,用于存储键值对。
数组中的键实际上只能是不断递增的索引,例如:
0 1 2 3 ...而 Map 则不同。
Map 的 Key 可以是任意可比较(comparable)类型。
因此,它拥有大得多的灵活性。
m:=make(map[string]int)m["a"]=1m["b"]=2m// map[a:1 b:2]Map[“a”: 1, “b”: 2]
在上面的例子中,我们使用make()创建了一个空 Map。
其中:
- Key 类型是
string - Value 类型是
int
当然,与其手动一个一个地向 Map 中加入 Key,你还可以通过 Map Literal,也就是 Map 字面量,节省一些时间。
这样就可以在创建 Map 时一次性把所有键值对都写进去:
m:=map[string]int{"a":1,"b":2,}你只需要在创建 Map 时,在花括号中列出所有 Key 以及对应的 Value。
就这么简单。
如果之后发现某个键值对已经不需要了,Go 也提供了一个非常方便的delete函数。
顾名思义,它可以删除你不想要的 Key:
delete(m,"a")Map 的零值是:
nil从某些角度来说,nilMap 和空 Map 很相似。
例如,你可以尝试在nilMap 中查找一个 Key。
Go 不会因此报错,也不会导致程序崩溃。
如果查询一个并不存在的 Key,Go 会直接返回这个 Map 的 Value 类型对应的零值:
varmmap[string]intprintln(m["a"])// 0m["a"]=1// panic: assignment to entry in nil map不过需要注意:
不能向nilMap 中添加新的键值对。
实际上,Go 处理 Map 的方式与处理 Slice 有些类似。
Map 和 Slice 的默认值都是nil。
而且,当它们处于nil状态时,只要执行的是某些“无害”的操作,Go 并不会直接 Panic。
例如:
你完全可以遍历一个nilSlice,不会发生任何问题。
那么,如果尝试遍历一个nilMap,会发生什么?
varmmap[string]intfork,v:=rangem{println(k,v)}什么都不会发生。
没有错误,也不会出现什么意外。
它只会安静地什么都不做。
Go 的设计理念之一,就是尽量让任何类型的默认值都是有意义、可使用的,而不是让它轻易把你的程序搞崩。
只有当你做了真正不合法的事情时,Go 才会报错。
例如:
- 尝试向一个
nilMap 中添加新的键值对 - 访问一个 Slice 中越界的索引
除此之外,还有几件关于 Go Map 的事情值得了解:
- 使用
for-range遍历 Map 时,Key 不会按照任何特定顺序返回。 - Map 不是线程安全的。如果同时对同一个 Map 进行读取(或者使用
for-range遍历)和写入,Go Runtime 会触发 Fatal Error。 - 可以通过简单的
ok检查判断某个 Key 是否存在:
_,ok:=m[key]- Map 的 Key 类型必须是comparable,也就是可比较类型。
接下来重点看看最后这一点。
前面提到:
Map 的 Key 可以是任意可比较类型。
但这里其实还有一些细节。
“那么,究竟什么是可比较类型?什么又不是?”
其实很简单:
如果两个相同类型的值能够使用==运算符进行比较,那么这个类型就是可比较类型。
例如:
funcmain(){varsmap[int]stringifs==s{println("comparable")}}// compile error: invalid operation: s == s (map can only be compared to nil)可以看到,上面的代码甚至无法通过编译。
编译器会报错:
invalid operation: s == s (map can only be compared to nil)同样的规则也适用于其他不可比较类型,例如:
- Slice
- Function
- 包含 Slice 的 Struct
- 包含 Map 的 Struct
- 等等
因此,如果你想把这些类型作为 Map 的 Key,那么是不行的。
例如:
funcmain(){varsmap[[]int]string}// compile error: invalid map key type []intcompilerIncomparableMapKey不过,这里还有一个小秘密:
Interface 既可能是可比较的,也可能是不可比较的。
这是什么意思?
你完全可以定义一个使用空接口作为 Key 的 Map,而不会产生任何编译错误。
但是要小心:
这样做很容易在运行时遇到错误。
funcmain(){m:=map[interface{}]int{1:1,"a":2,}m[[]int{1,2,3}]=3m[func(){}]=4}// panic: runtime error: hash of unhashable type []int// panic: runtime error: hash of unhashable type func()在你真正尝试把一个不可比较类型作为 Map Key 写进去之前,一切看起来都没有问题。
到了这一步,就会出现运行时错误。
而运行时错误通常比编译期错误更加棘手。
因此,除非确实有充分理由,并且能够通过约束防止错误使用,否则一般最好避免直接使用interface{}作为 Map 的 Key。
不过,刚才的错误消息:
hash of unhashable type []int可能有些令人费解。
这里为什么突然出现了hash?
这正好给了我们一个机会,继续深入看看 Go 在底层到底是如何处理 Map 的。
Map 的内部结构
在解释 Map 这样的内部实现时,很容易陷入 Go 源代码中的各种细枝末节。
不过,本文会尽量保持轻松和简单,让刚接触 Go 的人也能够跟得上。
在 Go 代码中,你看到的 Map 仿佛就是一个完整的数据结构。
但实际上,它只是一个抽象层,把底层复杂的数据组织方式隐藏了起来。
真正的 Go Map 是由许多更小的单元组成的。
这些单元叫作:
Bucket。
在 Go 源代码中,可以看到类似这样的结构:
typehmapstruct{...buckets unsafe.Pointer...}从上面的 Go 源码可以看到:
Map 中存在一个指针,它指向 Bucket 数组。
这也是为什么,当你把一个 Map 赋值给另一个变量,或者把 Map 传给一个函数时,新变量和函数参数都能够操作同一份 Map 数据。
例如:
funcchangeMap(m2map[string]int){m2["hello"]=2}funcmain(){m1:=map[string]int{"hello":1}changeMap(m1)println(m1["hello"])// 2}不过,不要因此产生误解。
Map 底层虽然可以看作包含一个指向hmap的指针,但 Map 并不是什么所谓的“引用类型”,也不是像 C# 的ref参数那样进行引用传递。
如果直接修改整个m2,调用者中的原始 Mapm1并不会跟着变化。
例如:
funcchangeMap(m2map[string]int){m2=map[string]int{"hello":2}}funcmain(){m1:=map[string]int{"hello":1}changeMap(m1)println(m1["hello"])// 1}在 Go 中:
所有东西都是按值传递的。
实际发生的事情稍微有些不同。
当我们把m1传给changeMap函数时,Go 会复制 Map 内部所包含的那个指向hmap的指针。
因此:
main()中的m1changeMap()中的m2
从变量本身来看,是两个独立的值。
但这两个值内部的指针都指向:
同一个hmap。
Map 是按值传递的
如果想进一步了解这个话题,可以阅读 Dave Cheney 的一篇非常好的文章:
There is no pass-by-reference in Go
每一个 Bucket 最多只能容纳:
8 个键值对。
如下图所示:
Map 的 Bucket
上面这个 Map 中有:
- 2 个 Bucket
len(map)为 6
那么,当你向 Map 中加入一个键值对时,Go 并不是随机把它扔进某个位置,也不是按照顺序依次插入。
相反,Go 会根据 Key 的 Hash 值决定应该把这组键值对放入哪个 Bucket。
这个 Hash 值由下面的操作得到:
hash(key, seed)下面看看最简单的赋值场景。
假设我们有一个空 Map,然后向里面加入:
"hello": 1向空 Map 中添加一个键值对
首先,Go 会计算"hello"的 Hash,得到一个数字。
然后,用这个数字对 Bucket 数量取模。
由于当前只有一个 Bucket,因此无论任何数字对 1 取模,结果都只能是:
0所以,这个键值对会直接进入:
bucket 0当再添加一个键值对时,也会执行同样的过程。
Go 会尝试把它放进 Bucket 0。
如果第一个 Slot 已经被占用,或者其中存储的是不同的 Key,就继续检查这个 Bucket 中的下一个 Slot。
再来看一下刚才提到的:
hash(key, seed)如果你使用for-range遍历两个拥有完全相同 Key 的 Map,可能会注意到:
它们返回 Key 的顺序可能不一样。
funcmain(){a:=map[string]int{"a":1,"b":2,"c":3,"d":4,"e":5,"f":6}b:=map[string]int{"a":1,"b":2,"c":3,"d":4,"e":5,"f":6}fori:=rangea{print(i," ")}println()fori:=rangeb{print(i," ")}}// Output:// a b c d e f// c d e f a b这是怎么回事?
Mapa中的 Key"a"和 Mapb中的 Key"a",使用的 Hash 算法难道不是一样的吗?
确实。
Go Map 针对相同 Key 类型使用的 Hash 函数是一致的。
但是:
Hash 函数使用的seed对每个 Map 实例来说都不同。
也就是说,每次创建一个新的 Map 时,Go 都会专门为这个 Map 生成一个随机 Seed。
因此,在上面的例子中:
a和b的 Key 都是string类型,所以它们使用同一个 Hash 函数。
但是:
两个 Map 各自拥有不同的 Seed。
“等等,一个 Bucket 只有 8 个 Slot?”
“如果 Bucket 满了怎么办?”
“它会像 Slice 一样扩容吗?”
某种程度上,是的。
当 Bucket 开始变满,或者接近“满”的状态时——具体什么叫“满”取决于算法的定义——Map 会触发扩容。
扩容过程中,主 Bucket 的数量可能会直接翻倍。
不过,接下来会变得更有意思。
刚才我特意使用了:
主 Bucket(main bucket)
这个说法。
因为接下来要引入另一个概念:
Overflow Bucket,也就是溢出 Bucket。
当 Hash 冲突比较严重时,就会使用 Overflow Bucket。
例如:
假设当前 Map 有 4 个 Bucket。
但是由于大量 Hash 冲突,其中一个 Bucket 已经塞满了 8 个键值对。
而剩下的另外 3 个 Bucket 仍然完全是空的。
Bucket 0 出现严重 Hash 冲突
现在,因为需要再加入一条数据,而不幸的是,这条数据仍然应该落入第一个已经装满的 Bucket。
难道仅仅为了这一条记录,就真的需要把整个 Map 从:
4 个 Bucket扩展成:
8 个 Bucket吗?
当然没有必要。
那样实在太浪费了。
Go 会通过一种更加高效的方式处理这种情况:
创建 Overflow Bucket。
这个 Overflow Bucket 会与原来的第一个 Bucket 链接起来。
新的键值对会被存进 Overflow Bucket,而不是直接触发整个 Map 完整扩容。
Map 的 Overflow Bucket
Go 中的 Map 会在满足下面两个条件之一时发生增长:
- Overflow Bucket 太多。
- Map 过载,也就是 Load Factor 太高。
由于存在两个不同条件,因此 Map 也有两种不同形式的增长:
- 当 Map 过载时,Bucket 数量翻倍。
- 当 Overflow Bucket 太多时,Bucket 数量保持不变,但重新分布其中的 Entry。
如果 Overflow Bucket 太多,那么相比单纯继续增加更多内存,更好的办法是:
重新分布现有 Entry。
目前 Go 使用的 Load Factor 阈值是:
6.5这意味着:
Go Map 的设计目标,是让每个 Bucket 平均维持大约:
6.5 个 Entry。
一个 Bucket 最多有 8 个 Slot。
因此,大约相当于:
80% 的容量使用率。
当 Load Factor 超过这个阈值时,就认为 Map 已经过载。
这种情况下,Map 会:
- 分配一个新的 Bucket 数组。
- 新 Bucket 数组大小是当前数组的两倍。
- 把原有元素重新 Hash 到这些新的 Bucket 中。
为什么一个 Bucket 还没有完全装满时,就已经需要考虑扩容?
原因还是性能。
通常我们会认为:
Map 的读取和赋值操作复杂度都是:
O(1)对吧?
但实际上,并没有这么简单。
严重 Hash 冲突会导致 Map 操作变慢
一个 Bucket 中被占用的 Slot 越多:
操作就越慢。
当你想向 Map 中添加另一个键值对时,并不只是简单判断:
“这个 Bucket 还有没有空间?”
还需要把新 Key 与 Bucket 中已有的 Key 逐个进行比较,从而判断:
- 这是新增一个 Entry
- 还是更新一个已经存在的 Entry
如果存在 Overflow Bucket,事情会变得更加糟糕。
因为还需要继续检查 Overflow Bucket 中的每一个 Slot。
同样的性能下降也会影响:
- Map 查询
- Map 删除
不过,Go 团队当然已经替我们对这个比较过程进行了优化。
还记得对"Hello"计算 Hash 后得到的那个值吗?
Go 并不会在计算完成之后直接把完整 Hash 丢掉。
它会把"Hello"的:
tophash
缓存到 Bucket 中。
tophash使用一个:
uint8保存。
当新的 Key 到来时,会先快速比较新 Key 和已有 Key 的tophash。
这个检查非常快。
Map 的 tophash
比较tophash之后,如果两者相同,只能说明:
两个 Key **“可能”**相同。
然后,Go 才会执行后面更加缓慢的真正 Key 比较,检查两个 Key 是否真的完全一致。
“为什么使用
make(map, hint)创建一个 Map 时,第二个参数不是精确大小,而只是一个hint?”
看到这里,你应该已经差不多可以回答这个问题了。
make(map, hint)中的hint参数告诉 Go:
你预计这个 Map 初始大概要容纳多少个元素。
这个 Hint 可以帮助减少:
Map 随着元素不断增加而发生扩容的次数。
因为每一次扩容都涉及:
- 分配一个新的 Bucket 数组
- 把已有元素复制或者迁移过去
这并不是一个特别高效的过程。
因此,如果一开始就提供一个较大的初始容量提示,可以避免其中一部分代价比较高的扩容操作。
下面看看在真实情况下,随着 Hint 增长,Bucket 数量究竟如何变化:
| Hint 范围 | Bucket 数量 | 容量 |
|---|---|---|
| 0 - 8 | 1 | 8 |
| 9 - 13 | 2 | 16 |
| 14 - 26 | 4 | 32 |
| 27 - 52 | 8 | 64 |
| 53 - 104 | 16 | 128 |
| 105 - 208 | 32 | 256 |
| 209 - 416 | 64 | 512 |
| 417 - 832 | 128 | 1024 |
| 833 - 1664 | 256 | 2048 |
“为什么 Hint 为 14 时,会得到 4 个 Bucket?”
“明明 2 个 Bucket 的总容量已经可以放下 14 个元素了。”
这就是 Load Factor 开始发挥作用的地方。
还记得前面提到的 Load Factor 阈值:
6.5吗?
它会直接影响 Map 应该在什么时候进行扩容。
- 当 Hint 为 13 时,我们拥有 2 个 Bucket,因此 Load Factor 为:
13 / 2 = 6.5正好达到阈值,但还没有超过阈值。
因此,当 Hint 增加到 14 时,Load Factor 就会超过 6.5。
于是必须扩容。
- Hint 为 26 时也是同样情况。
拥有 4 个 Bucket 时:
26 / 4 = 6.5同样刚好达到阈值。
当继续超过 26 后,Map 就需要增长,以便继续高效地容纳更多元素。
基本上,从第二个范围开始可以看到:
与前一个范围相比:
- Hint 范围翻倍
- Bucket 数量翻倍
- 总容量也翻倍
Map 扩容时的 Evacuation
前面提到过:
Evacuation 并不总意味着 Bucket 数量会翻倍。
如果只是因为 Overflow Bucket 太多而触发 Evacuation,那么新的 Bucket 数组大小仍然可能与旧数组完全相同。
相比之下,更有意思的情况是:
Bucket 数量翻倍。
因此,接下来主要讨论这种情况。
Map 的扩容机制可以回答两个经常出现的问题:
- 为什么不能获取 Map 中某个元素的地址?
- 为什么 Map 的
for-range遍历顺序在不同时间并不保证一致?
例如:
funcmain(){a:=map[string]int{"a":1}ptr:=&a["a"]}// compiler error: invalid operation: cannot// take address of a["a"] (map index expression of type int)当 Map 扩容时,会分配一个新的 Bucket 数组。
新的 Bucket 数组大小是旧数组的:
两倍。
这样一来,旧 Bucket 中所有 Entry 原来的位置都会失效。
它们必须移动到新 Bucket 中。
因此,它们的内存地址也会发生变化。
Map 的 Evacuation
问题在于:
假设一个 Map 中存在 1000 个键值对。
如果每次扩容都一次性把这 1000 个 Key 全部移动过去,这会是一个相当昂贵的操作。
它甚至可能让当前 Goroutine 阻塞一段用户能够明显感觉到的时间。
为了避免这个问题,Go 使用:
Incremental Growth,也就是渐进式扩容。
Map 不会一次性重新 Hash 所有元素。
而是每次只搬迁其中一部分。
这样,整个过程会被分散到多次操作中。
程序能够继续平稳运行,而不会突然出现明显的卡顿。
不过,这也会让整个过程变得更加复杂。
因为在扩容过程中,Go 仍然需要保证 Map 的完整性。
与此同时,还要支持:
- 读取
- 写入
- 删除
- 遍历
并且这时候:
旧 Bucket 和新 Bucket 会同时存在。
“渐进式扩容到底什么时候发生?”
只有两种操作会真正触发渐进式扩容:
- 向 Map 中写入一个键值对。
- 从 Map 中删除一个 Key。
这两种操作中的任意一种,都会触发 Evacuation。
而且每次至少会把一个旧 Bucket 迁移到新的 Bucket 数组中。
例如,我们执行:
m["Hello"]=2如果 Map 当前正在扩容,那么首先会对:
包含"Hello"Key 的旧 Bucket
执行 Evacuation。
这个旧 Bucket 中的每一个元素都会被移动到两个新 Bucket 中的其中一个。
即使 Map 拥有的不只是 2 个 Bucket,过程也是一样的。
Key"Hello"可能移动到两个新 Bucket 中的任意一个
例如:
假设正在从:
4 个 Bucket扩容到:
8 个 Bucket那么旧的:
bucket 1中的元素,只可能移动到新的:
bucket 1或者:
bucket 5我们怎么知道?
这里只需要做一点和位运算有关的数学推导。
假设:
hash % 4 == 1那么:
hash % 8的结果只可能是:
1或者:
5因为对于满足:
H % 4 == 1的旧 Bucket 来说,H最低两位一定是:
01当新的 Bucket 数量变成 8 时,我们需要观察最低三位:
- 如果从右往左数第三位是 0,那么最低三位是
001,也就是说:
H % 8 == 1- 如果从右往左数第三位是 1,那么最低三位是
010,也就是说:
H % 8 == 5旧 Bucket 如何执行 Evacuation
如果旧 Bucket 还挂着 Overflow Bucket,那么 Map 同样需要把 Overflow Bucket 中的元素一起移动到新的 Bucket 中。
当旧 Bucket 中的所有元素全部完成搬迁后,Map 会通过:
tophash字段把这个旧 Bucket 标记为:
已经 Evacuated。
今天关于 Go Map 的讨论就到这里。
实际上,Go Map 的内部实现比本文介绍的内容还要复杂。
其中还有大量细小的实现细节没有在这里展开。
例如:
tophash不仅仅用于 Key 比较,它还会参与 Evacuation。
保持联系
你好,我是 Phuong Le,一名 VictoriaMetrics 软件工程师。
上面的写作方式主要强调:
清晰和简单。
我希望通过一种容易理解的方式解释这些概念。
因此,其中的一些表达方式并不一定始终与严格的学术精确性完全一致。
如果你发现其中有任何内容已经过时,或者有任何问题,欢迎联系我。
可以通过 X 给我发私信:
X(@func25)
相关文章:
- VictoriaMetrics 的 Golang 系列文章
- Go I/O:Reader、Writer 与流动的数据
- Go 中的 Slice:要么增长,要么回家
- Go Sync Mutex:正常模式与饥饿模式
- Go Defer:从基础知识到各种陷阱
- Go 数组的工作原理,以及 For-Range 中那些棘手的问题
- 深入 Go 的 Unique Package:简单理解字符串驻留
- Vendoring,也就是
go mod vendor:它到底是什么?
我们是谁
如果你希望监控自己的服务、跟踪指标,并了解整个系统的实际运行表现,可以了解一下 VictoriaMetrics。
它是一种:
- 高性能
- 开源
- 节约成本
的基础设施监控方案。
而我们也是一群 Gopher。
我们热衷于:
- 研究 Go
- 对 Go 进行实验
- 分享 Go 以及整个 Go 生态系统中的知识
标签:
- go
- golang