Go Map 详解:键值对实际上是如何存储的

作者:

本文是对victoriametrics Go Maps Explained: How Key-Value Pairs Are Actually Stored的整理与翻译

发布日期:2024 年 8 月 16 日

阅读时间:17 分钟

分类:

如果你刚开始接触 Go,可能会觉得 Go 中的 Map 用起来有些令人困惑。

即使已经积累了更多 Go 开发经验,想真正搞清楚 Map 底层究竟是怎么工作的,也并不是一件容易的事情。

比如下面这个例子。

你有没有在创建 Map 时设置过一个 hint,然后想过:

为什么这里叫作"hint(提示)",而不像 Slice 那样直接叫 length 或 capacity 之类更加明确的东西?

go 复制代码
// hint = 10
m := make(map[string]int, 10)

或者,你可能已经注意到:

使用 for-range 遍历一个 Map 时,得到的顺序并不等于键值对插入 Map 的顺序。

而且更加奇怪的是,即使遍历的是同一个 Map,在不同时间执行遍历时,得到的顺序也可能发生变化。

但很奇怪的是,如果你恰好在同一时间遍历它,顺序通常又会保持一致。

这是一个很长的故事。

所以,系好安全带,我们开始吧。

在继续之前先说明一下:本文中的内容基于 Go 1.23

如果 Go 后续发生了变化,导致本文内容已经过时,欢迎通过 X(@func25) 联系我。

Go 中的 Map:快速入门

先来聊聊 Go 中的 Map。

Map 是 Go 内置的一种类型,用于存储键值对。

数组中的键实际上只能是不断递增的索引,例如:

text 复制代码
0
1
2
3
...

而 Map 则不同。

Map 的 Key 可以是任意可比较(comparable)类型

因此,它拥有大得多的灵活性。

go 复制代码
m := make(map[string]int)
m["a"] = 1
m["b"] = 2

m // map[a:1 b:2]

Map"a": 1, "b": 2

在上面的例子中,我们使用 make() 创建了一个空 Map。

其中:

  • Key 类型是 string
  • Value 类型是 int

当然,与其手动一个一个地向 Map 中加入 Key,你还可以通过 Map Literal,也就是 Map 字面量,节省一些时间。

这样就可以在创建 Map 时一次性把所有键值对都写进去:

go 复制代码
m := map[string]int{
    "a": 1,
    "b": 2,
}

你只需要在创建 Map 时,在花括号中列出所有 Key 以及对应的 Value。

就这么简单。

如果之后发现某个键值对已经不需要了,Go 也提供了一个非常方便的 delete 函数。

顾名思义,它可以删除你不想要的 Key:

go 复制代码
delete(m, "a")

Map 的零值是:

go 复制代码
nil

从某些角度来说,nil Map 和空 Map 很相似。

例如,你可以尝试在 nil Map 中查找一个 Key。

Go 不会因此报错,也不会导致程序崩溃。

如果查询一个并不存在的 Key,Go 会直接返回这个 Map 的 Value 类型对应的零值

go 复制代码
var m map[string]int

println(m["a"]) // 0
m["a"] = 1      // panic: assignment to entry in nil map

不过需要注意:

不能向 nil Map 中添加新的键值对。

实际上,Go 处理 Map 的方式与处理 Slice 有些类似。

Map 和 Slice 的默认值都是 nil

而且,当它们处于 nil 状态时,只要执行的是某些"无害"的操作,Go 并不会直接 Panic。

例如:

你完全可以遍历一个 nil Slice,不会发生任何问题。

那么,如果尝试遍历一个 nil Map,会发生什么?

go 复制代码
var m map[string]int

for k, v := range m {
    println(k, v)
}

什么都不会发生。

没有错误,也不会出现什么意外。

它只会安静地什么都不做。

Go 的设计理念之一,就是尽量让任何类型的默认值都是有意义、可使用的,而不是让它轻易把你的程序搞崩。

只有当你做了真正不合法的事情时,Go 才会报错。

例如:

  • 尝试向一个 nil Map 中添加新的键值对
  • 访问一个 Slice 中越界的索引

除此之外,还有几件关于 Go Map 的事情值得了解:

  • 使用 for-range 遍历 Map 时,Key 不会按照任何特定顺序返回。
  • Map 不是线程安全的。如果同时对同一个 Map 进行读取(或者使用 for-range 遍历)和写入,Go Runtime 会触发 Fatal Error。
  • 可以通过简单的 ok 检查判断某个 Key 是否存在:
go 复制代码
_, ok := m[key]
  • Map 的 Key 类型必须是 comparable,也就是可比较类型

接下来重点看看最后这一点。

前面提到:

Map 的 Key 可以是任意可比较类型。

但这里其实还有一些细节。

"那么,究竟什么是可比较类型?什么又不是?"

其实很简单:

如果两个相同类型的值能够使用 == 运算符进行比较,那么这个类型就是可比较类型。

例如:

go 复制代码
func main() {
    var s map[int]string

    if s == s {
        println("comparable")
    }
}

// compile error: invalid operation: s == s (map can only be compared to nil)

可以看到,上面的代码甚至无法通过编译。

编译器会报错:

text 复制代码
invalid operation: s == s (map can only be compared to nil)

同样的规则也适用于其他不可比较类型,例如:

  • Slice
  • Function
  • 包含 Slice 的 Struct
  • 包含 Map 的 Struct
  • 等等

因此,如果你想把这些类型作为 Map 的 Key,那么是不行的。

例如:

go 复制代码
func main() {
  var s map[[]int]string
}

// compile error: invalid map key type []intcompilerIncomparableMapKey

不过,这里还有一个小秘密:

Interface 既可能是可比较的,也可能是不可比较的。

这是什么意思?

你完全可以定义一个使用空接口作为 Key 的 Map,而不会产生任何编译错误。

但是要小心:

这样做很容易在运行时遇到错误。

go 复制代码
func main() {
    m := map[interface{}]int{
        1: 1,
        "a": 2,
    }

    m[[]int{1, 2, 3}] = 3
    m[func() {}] = 4
}

// panic: runtime error: hash of unhashable type []int
// panic: runtime error: hash of unhashable type func()

在你真正尝试把一个不可比较类型作为 Map Key 写进去之前,一切看起来都没有问题。

到了这一步,就会出现运行时错误。

而运行时错误通常比编译期错误更加棘手。

因此,除非确实有充分理由,并且能够通过约束防止错误使用,否则一般最好避免直接使用 interface{} 作为 Map 的 Key。

不过,刚才的错误消息:

text 复制代码
hash of unhashable type []int

可能有些令人费解。

这里为什么突然出现了 hash

这正好给了我们一个机会,继续深入看看 Go 在底层到底是如何处理 Map 的。

Map 的内部结构

在解释 Map 这样的内部实现时,很容易陷入 Go 源代码中的各种细枝末节。

不过,本文会尽量保持轻松和简单,让刚接触 Go 的人也能够跟得上。

在 Go 代码中,你看到的 Map 仿佛就是一个完整的数据结构。

但实际上,它只是一个抽象层,把底层复杂的数据组织方式隐藏了起来。

真正的 Go Map 是由许多更小的单元组成的。

这些单元叫作:

Bucket。

在 Go 源代码中,可以看到类似这样的结构:

go 复制代码
type hmap struct {
  ...
  buckets unsafe.Pointer
  ...
}

从上面的 Go 源码可以看到:

Map 中存在一个指针,它指向 Bucket 数组。

这也是为什么,当你把一个 Map 赋值给另一个变量,或者把 Map 传给一个函数时,新变量和函数参数都能够操作同一份 Map 数据。

例如:

go 复制代码
func changeMap(m2 map[string]int) {
  m2["hello"] = 2
}

func main() {
  m1 := map[string]int{"hello": 1}
  changeMap(m1)
  println(m1["hello"]) // 2
}

不过,不要因此产生误解。

Map 底层虽然可以看作包含一个指向 hmap 的指针,但 Map 并不是什么所谓的"引用类型",也不是像 C# 的 ref 参数那样进行引用传递。

如果直接修改整个 m2,调用者中的原始 Map m1 并不会跟着变化。

例如:

go 复制代码
func changeMap(m2 map[string]int) {
  m2 = map[string]int{"hello": 2}
}

func main() {
  m1 := map[string]int{"hello": 1}
  changeMap(m1)
  println(m1["hello"]) // 1
}

在 Go 中:

所有东西都是按值传递的。

实际发生的事情稍微有些不同。

当我们把 m1 传给 changeMap 函数时,Go 会复制 Map 内部所包含的那个指向 hmap 的指针。

因此:

  • main() 中的 m1
  • changeMap() 中的 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 值由下面的操作得到:

text 复制代码
hash(key, seed)

下面看看最简单的赋值场景。

假设我们有一个空 Map,然后向里面加入:

text 复制代码
"hello": 1

向空 Map 中添加一个键值对

首先,Go 会计算 "hello" 的 Hash,得到一个数字。

然后,用这个数字对 Bucket 数量取模。

由于当前只有一个 Bucket,因此无论任何数字对 1 取模,结果都只能是:

text 复制代码
0

所以,这个键值对会直接进入:

text 复制代码
bucket 0

当再添加一个键值对时,也会执行同样的过程。

Go 会尝试把它放进 Bucket 0。

如果第一个 Slot 已经被占用,或者其中存储的是不同的 Key,就继续检查这个 Bucket 中的下一个 Slot。

再来看一下刚才提到的:

text 复制代码
hash(key, seed)

如果你使用 for-range 遍历两个拥有完全相同 Key 的 Map,可能会注意到:

它们返回 Key 的顺序可能不一样。

go 复制代码
func main() {
	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}

	for i := range a {
		print(i, " ")
	}
	println()

	for i := range b {
		print(i, " ")
	}
}

// Output:
// a b c d e f
// c d e f a b

这是怎么回事?

Map a 中的 Key "a" 和 Map b 中的 Key "a",使用的 Hash 算法难道不是一样的吗?

确实。

Go Map 针对相同 Key 类型使用的 Hash 函数是一致的。

但是:

Hash 函数使用的 seed 对每个 Map 实例来说都不同。

也就是说,每次创建一个新的 Map 时,Go 都会专门为这个 Map 生成一个随机 Seed。

因此,在上面的例子中:

ab 的 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 从:

text 复制代码
4 个 Bucket

扩展成:

text 复制代码
8 个 Bucket

吗?

当然没有必要。

那样实在太浪费了。

Go 会通过一种更加高效的方式处理这种情况:

创建 Overflow Bucket。

这个 Overflow Bucket 会与原来的第一个 Bucket 链接起来。

新的键值对会被存进 Overflow Bucket,而不是直接触发整个 Map 完整扩容。

Map 的 Overflow Bucket

Go 中的 Map 会在满足下面两个条件之一时发生增长:

  1. Overflow Bucket 太多。
  2. Map 过载,也就是 Load Factor 太高。

由于存在两个不同条件,因此 Map 也有两种不同形式的增长:

  • 当 Map 过载时,Bucket 数量翻倍。
  • 当 Overflow Bucket 太多时,Bucket 数量保持不变,但重新分布其中的 Entry。

如果 Overflow Bucket 太多,那么相比单纯继续增加更多内存,更好的办法是:

重新分布现有 Entry。

目前 Go 使用的 Load Factor 阈值是:

text 复制代码
6.5

这意味着:

Go Map 的设计目标,是让每个 Bucket 平均维持大约:

6.5 个 Entry。

一个 Bucket 最多有 8 个 Slot。

因此,大约相当于:

80% 的容量使用率。

当 Load Factor 超过这个阈值时,就认为 Map 已经过载。

这种情况下,Map 会:

  1. 分配一个新的 Bucket 数组。
  2. 新 Bucket 数组大小是当前数组的两倍。
  3. 把原有元素重新 Hash 到这些新的 Bucket 中。

为什么一个 Bucket 还没有完全装满时,就已经需要考虑扩容?

原因还是性能。

通常我们会认为:

Map 的读取和赋值操作复杂度都是:

text 复制代码
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 使用一个:

go 复制代码
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 阈值:

text 复制代码
6.5

吗?

它会直接影响 Map 应该在什么时候进行扩容。

  • 当 Hint 为 13 时,我们拥有 2 个 Bucket,因此 Load Factor 为:
text 复制代码
13 / 2 = 6.5

正好达到阈值,但还没有超过阈值。

因此,当 Hint 增加到 14 时,Load Factor 就会超过 6.5。

于是必须扩容。

  • Hint 为 26 时也是同样情况。

拥有 4 个 Bucket 时:

text 复制代码
26 / 4 = 6.5

同样刚好达到阈值。

当继续超过 26 后,Map 就需要增长,以便继续高效地容纳更多元素。

基本上,从第二个范围开始可以看到:

与前一个范围相比:

  • Hint 范围翻倍
  • Bucket 数量翻倍
  • 总容量也翻倍

Map 扩容时的 Evacuation

前面提到过:

Evacuation 并不总意味着 Bucket 数量会翻倍。

如果只是因为 Overflow Bucket 太多而触发 Evacuation,那么新的 Bucket 数组大小仍然可能与旧数组完全相同。

相比之下,更有意思的情况是:

Bucket 数量翻倍。

因此,接下来主要讨论这种情况。

Map 的扩容机制可以回答两个经常出现的问题:

  1. 为什么不能获取 Map 中某个元素的地址?
  2. 为什么 Map 的 for-range 遍历顺序在不同时间并不保证一致?

例如:

go 复制代码
func main() {
	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 会同时存在。

"渐进式扩容到底什么时候发生?"

只有两种操作会真正触发渐进式扩容:

  1. 向 Map 中写入一个键值对。
  2. 从 Map 中删除一个 Key。

这两种操作中的任意一种,都会触发 Evacuation。

而且每次至少会把一个旧 Bucket 迁移到新的 Bucket 数组中。

例如,我们执行:

go 复制代码
m["Hello"] = 2

如果 Map 当前正在扩容,那么首先会对:

包含 "Hello" Key 的旧 Bucket

执行 Evacuation。

这个旧 Bucket 中的每一个元素都会被移动到两个新 Bucket 中的其中一个。

即使 Map 拥有的不只是 2 个 Bucket,过程也是一样的。

Key "Hello" 可能移动到两个新 Bucket 中的任意一个

例如:

假设正在从:

text 复制代码
4 个 Bucket

扩容到:

text 复制代码
8 个 Bucket

那么旧的:

text 复制代码
bucket 1

中的元素,只可能移动到新的:

text 复制代码
bucket 1

或者:

text 复制代码
bucket 5

我们怎么知道?

这里只需要做一点和位运算有关的数学推导。

假设:

text 复制代码
hash % 4 == 1

那么:

text 复制代码
hash % 8

的结果只可能是:

text 复制代码
1

或者:

text 复制代码
5

因为对于满足:

text 复制代码
H % 4 == 1

的旧 Bucket 来说,H 最低两位一定是:

text 复制代码
01

当新的 Bucket 数量变成 8 时,我们需要观察最低三位:

  • 如果从右往左数第三位是 0,那么最低三位是 001,也就是说:
text 复制代码
H % 8 == 1
  • 如果从右往左数第三位是 1,那么最低三位是 010,也就是说:
text 复制代码
H % 8 == 5

旧 Bucket 如何执行 Evacuation

如果旧 Bucket 还挂着 Overflow Bucket,那么 Map 同样需要把 Overflow Bucket 中的元素一起移动到新的 Bucket 中。

当旧 Bucket 中的所有元素全部完成搬迁后,Map 会通过:

text 复制代码
tophash

字段把这个旧 Bucket 标记为:

已经 Evacuated。

今天关于 Go Map 的讨论就到这里。

实际上,Go Map 的内部实现比本文介绍的内容还要复杂。

其中还有大量细小的实现细节没有在这里展开。

例如:

tophash 不仅仅用于 Key 比较,它还会参与 Evacuation。

保持联系

你好,我是 Phuong Le,一名 VictoriaMetrics 软件工程师。

上面的写作方式主要强调:

清晰和简单。

我希望通过一种容易理解的方式解释这些概念。

因此,其中的一些表达方式并不一定始终与严格的学术精确性完全一致。

如果你发现其中有任何内容已经过时,或者有任何问题,欢迎联系我。

可以通过 X 给我发私信:

X(@func25)

相关文章:

我们是谁

如果你希望监控自己的服务、跟踪指标,并了解整个系统的实际运行表现,可以了解一下 VictoriaMetrics

它是一种:

  • 高性能
  • 开源
  • 节约成本

的基础设施监控方案。

而我们也是一群 Gopher。

我们热衷于:

  • 研究 Go
  • 对 Go 进行实验
  • 分享 Go 以及整个 Go 生态系统中的知识

标签:

相关推荐
万物智能12 分钟前
设备树DTS-【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
后端·算法
CadeCode15 分钟前
Oracle 逗号拼接字段处理
数据库·后端·性能优化
geovindu27 分钟前
CSharp: State Pattern
开发语言·后端·c#·.net·状态模式·行为模式
山岚的运维笔记30 分钟前
mysql 专业笔记 -- 第 1 章:MySQL 入门
运维·数据库·笔记·后端·学习·mysql·dba
fliter1 小时前
我们如何通过优化 1.1.1.1 的 DNS 缓存节省 100 TB 内存
后端
一开1 小时前
一个自己开发的 Agent Harness-子 Agent篇
后端
KoPa1 小时前
HeySmart:事件总线——异步解耦的艺术
前端·后端
吃饱了得干活1 小时前
MySQL 内核剖析:ACID 实现、引擎对决、B+树、索引与主从复制闭环
后端·mysql
一百昏1 小时前
cen19c01(Oracle 19c RAC 单节点)网络与 CRS 故障修复报告
后端