Go map 源码详解

Go 的 map 查找过程是 Go 运行时(runtime)中非常经典的一部分。

如果从源码角度看,核心文件主要在:

src/runtime/map.go

其中最重要的数据结构是:

type hmap struct {
    count     int
    flags     uint8
    B         uint8
    noverflow uint16
    hash0     uint32

    buckets    unsafe.Pointer
    oldbuckets unsafe.Pointer
    nevacuate  uintptr

    extra *mapextra
}

1. map 的整体结构

假设:

m := map[string]int{
    "a": 1,
    "b": 2,
    "c": 3,
}

底层类似:

hmap
 ├── bucket0
 │      ├── key
 │      ├── value
 │      ├── key
 │      ├── value
 │      └── ...
 ├── bucket1
 ├── bucket2
 └── bucket3

bucket 数量:

2^B

例如:

B = 3

则:

2³ = 8 个 bucket

2. bucket 长什么样

源码:

type bmap struct {
    tophash [8]uint8
}

注意这里看起来只有:

tophash

实际上后面通过内存布局紧跟:

┌─────────────┐
│ tophash[8]  │
├─────────────┤
│ key[8]      │
├─────────────┤
│ value[8]    │
├─────────────┤
│ overflow    │
└─────────────┘

一个 bucket 最多存:

8  kv

这是 Go 的设计:

bucketCnt = 8

3. 查找过程入口

代码:

v := m[key]

编译后:

mapaccess1()

或者:

v, ok := m[key]

变成:

mapaccess2()

源码:

func mapaccess1(
    t *maptype,
    h *hmap,
    key unsafe.Pointer,
) unsafe.Pointer

4. 第一步:计算 Hash

例如:

m["hello"]

首先:

hash := t.Hasher(key, uintptr(h.hash0))

实际上:

hash("hello")

得到:

110101010101101011...

注意:

hash0

是随机种子。

目的:

防止 Hash DoS 攻击

每个 map 都不同。


5. 第二步:定位 Bucket

源码:

bucket := hash & bucketMask(h.B)

而:

bucketMask(B)
=
(1<<B)-1

例如:

B=3

那么:

mask = 111b

假设:

hash = 1101010110

取低 3 位:

1101010110
       110

得到:

bucket = 6

直接定位:

bucket[6]

时间复杂度:

O(1)

6. 第三步:取 TopHash

Go 不会直接比较 Key。

先比较:

top := uint8(hash >> (PtrSize*8 - 8))

即:

Hash 高 8 位

例如:

Hash:

10101100 11101010 ...
TopHash

存进:

bmap.tophash[i]

为什么这么设计?

如果 bucket 里面:

8个 key

直接比较:

key == key

可能很贵。

例如:

string

需要逐字节比较。

所以:

先比较 TopHash

只有:

tophash相同

才继续比较 Key。


7. 第四步:扫描 Bucket

源码逻辑类似:

for i := 0; i < 8; i++ {
    if b.tophash[i] != top {
        continue
    }

    if key == keys[i] {
        return values[i]
    }
}

例如:

Bucket 6

tophash:

[10][35][77][88][99][0][0][0]

查找:

top = 77

那么:

10 ×
35 ×
77 √

只比较第三个 Key。


8. 第五步:比较 Key

源码:

if t.Key.Equal(key, k) {
    return value
}

对于:

string

最终:

memequal()

比较字符串内容。

如果:

int

直接:

==

即可。


9. Bucket 满了怎么办

每个 bucket:

只能存 8 个元素

超过:

8

会创建:

overflow bucket

结构:

bucket0
overflow1
overflow2

查找时:

for b != nil {
    scan bucket
    b = b.overflow
}

类似:

链表

10. 扩容时如何查找

Go 的 map 是渐进式扩容。

扩容期间:

oldbuckets != nil

表示:

正在搬迁

此时查找流程:

if oldbuckets != nil {
    先判断 bucket 是否已搬迁
}

源码:

if !evacuated(oldb) {
    b = oldb
} else {
    b = newb
}

因此:

查找期间也能正常访问

无需停机迁移。


11. 一个完整例子

假设:

m["golang"]

计算:

hash("golang")
=
0x123456789ABCDEF0

Step1

取 Bucket

B=4
bucket = hash & 1111

结果:

bucket = 0

Step2

取 TopHash

0x12
=
18

Step3

扫描 bucket0

tophash

[3][18][77][88][0][0][0][0]

找到:

18

Step4

比较 Key

keys[1] == "golang"

成立。


Step5

返回

values[1]

时间复杂度为什么接近 O(1)

因为查找路径基本固定:

Hash计算
定位Bucket
最多扫描8个槽位
比较Key

即:

O(1)

而不是:

O(logN)

像红黑树那样逐层查找。


Go Map 查找流程总结

mapaccess1/mapaccess2
      计算Hash
  bucket = hash & ((1<<B)-1)
      找到Bucket
      提取TopHash
    遍历8个槽位
    TopHash匹配?
      Key比较
       返回Value

理解这套流程后,再看 Go Map 的扩容(growWork)、渐进迁移(evacuate)、overflow bucket 管理、为什么 bucket 固定为 8 个槽位,就能把整个 runtime/map.go 的设计思路串起来。


删除一个值

Go 的 map delete 比查找更有意思,因为 Go 并不会像链表那样真正把元素从内存中移除,而是采用一种「标记删除 + 后续复用槽位」的设计。

源码主要看:

src/runtime/map.go

删除入口:

delete(m, key)

编译器会转换成:

mapdelete()

源码:

func mapdelete(t *maptype, h *hmap, key unsafe.Pointer)

整体流程图

delete(m, key)
 mapdelete()
 计算 Hash
 定位 Bucket
 找到 Key
 清空 Key
 清空 Value
 修改 TopHash
 count--

先回顾 Bucket 结构

一个 Bucket:

┌───────────────────┐
│ tophash[8]        │
├───────────────────┤
│ keys[8]           │
├───────────────────┤
│ values[8]         │
├───────────────────┤
│ overflow          │
└───────────────────┘

例如:

Bucket0

tophash:
[12][45][88][99][00][00][00][00]

keys:
["a"]["b"]["c"]["d"]

values:
[1][2][3][4]

第一步:计算 Hash

与查找完全一致:

hash := t.Hasher(key, uintptr(h.hash0))

例如:

delete(m, "c")

得到:

hash("c")
=
0x12345678

第二步:定位 Bucket

bucket := hash & ((1<<B)-1)

例如:

B=3

则:

bucket = hash & 111

找到:

bucket[5]

第三步:扫描 Bucket

源码逻辑和查找几乎一样:

for b != nil {
    for i := 0; i < 8; i++ {
        ...
    }

    b = overflow
}

首先比较:

tophash

再比较:

key

找到目标元素。


找到 Key 后发生什么

假设:

slot2

key   = "c"
value = 3

Go 不会移动后面的元素。

不会变成:

[a][b][d]

因为这样代价太大。

而是:

[a][b][ ][d]

留下空洞。


第一步:清空 Key

源码:

memclrHasPointers(...)

或者:

memclrNoHeapPointers(...)

根据 Key 类型决定。

例如:

string

删除后:

key = nil

第二步:清空 Value

例如:

int

删除后:

value = 0

删除前:

keys:
[a][b][c][d]

values:
[1][2][3][4]

删除后:

keys:
[a][b][ ][d]

values:
[1][2][0][4]

第三步:修改 TopHash

这是关键。

Go 用特殊状态表示:

emptyOne

源码常量:

const (
    emptyRest = 0
    emptyOne  = 1
)

删除后:

b.tophash[i] = emptyOne

变成:

tophash

[12][45][1][99]

这里:

1 = emptyOne

表示:

这里曾经有元素
现在被删除了

为什么不用 0?

因为:

0 = emptyRest

代表:

从这里开始后面全是空的

查找时可以直接停止。

例如:

[12][45][0][0][0]

扫描到:

0

立刻结束。


而删除场景:

[12][45][1][99]

如果写成:

[12][45][0][99]

查找:

m["d"]

看到:

0

会误认为:

后面没有元素

导致找不到 d。

因此必须区分:

emptyOne
emptyRest

删除后如何复用

后续插入:

m["x"] = 100

插入逻辑会优先找:

emptyOne

槽位。

例如:

删除后:

[12][45][1][99]

插入:

[12][45][55][99]

直接复用 slot2。

无需新建 bucket。


count–

删除成功后:

h.count--

例如:

删除前:

len(m) = 100

删除后:

len(m) = 99

Overflow Bucket 不会立即释放

假设:

bucket0
overflow1
overflow2

删除了很多元素:

bucket0
overflow1
overflow2

链表仍然存在。

Go 不会:

立即回收 overflow bucket

原因:

避免频繁申请释放内存。


Go Map 不会因为删除而缩容

这是很多人不知道的。

例如:

m := make(map[int]int)

插入:

100万个元素

扩容后:

2048 buckets

然后:

删除999999个元素

剩:

1个元素

Map 仍然保持:

2048 buckets

不会缩容。


为什么不缩容

如果自动缩容:

删除
缩容
插入
扩容
删除
缩容

会出现:

频繁搬迁

性能震荡严重。

Go 团队选择:

只扩容
不缩容

保证稳定性能。


删除期间的 GC 处理

假设:

type User struct {
    Name string
}

Map:

map[string]*User

删除:

delete(m, "tom")

运行时:

key = nil
value = nil

这样 GC 才能发现:

已经没有引用

后续回收 User 对象。

否则会产生内存泄漏。


源码中的关键逻辑(简化版)

可以近似理解成:

func mapdelete(key) {

    hash := hash(key)

    bucket := buckets[hash&mask]

    for bucket != nil {

        for i := 0; i < 8; i++ {

            if tophash[i] != top {
                continue
            }

            if keys[i] == key {

                clear(keys[i])

                clear(values[i])

                tophash[i] = emptyOne

                count--

                return
            }
        }

        bucket = bucket.overflow
    }
}

删除后的 Bucket 示意图

删除前

┌──────────────────────────┐
│ TopHash                  │
│ 12 45 88 99 00 00 00 00 │
├──────────────────────────┤
│ Keys                     │
│ a  b  c  d              │
├──────────────────────────┤
│ Values                   │
│ 1  2  3  4              │
└──────────────────────────┘

删除 c

┌──────────────────────────┐
│ TopHash                  │
│ 12 45 01 99 00 00 00 00 │
├──────────────────────────┤
│ Keys                     │
│ a  b  -  d              │
├──────────────────────────┤
│ Values                   │
│ 1  2  0  4              │
└──────────────────────────┘

其中:

01 = emptyOne

表示:

这里曾经有元素
已删除
可以复用

一句话总结

Go Map 删除元素的本质不是“移除节点”,而是:

找到 Key
清空 Key/Value
TopHash 标记为 emptyOne
count--
后续插入复用该槽位

因此删除的时间复杂度仍然接近:

O(1)

而且不会触发元素搬移、Bucket 重排或 Map 缩容,这也是 Go delete() 性能非常稳定的重要原因。


扩容机制

Go Map 的扩容(Grow)是整个 runtime/map.go 最精彩的部分之一。

因为 Go 采用的是:

渐进式扩容(Incremental Rehash)

而不是:

一次性扩容(Stop-The-World Rehash)

所以即使一个 Map 有几百万个元素,扩容时也不会卡顿很久。


1. 为什么需要扩容

Map 本质:

bucket
bucket
bucket

每个 bucket:

bucketCnt = 8

最多存:

8个 kv

假设:

B = 2

则:

2² = 4 个 bucket

结构:

bucket0
bucket1
bucket2
bucket3

继续插入:

for i:=0;i<100;i++{
    m[i]=i
}

越来越多元素落入:

overflow bucket

变成:

bucket0
overflow1
overflow2

查找性能开始下降。


因此需要扩容。


2. Go 的扩容条件

源码:

func overLoadFactor(count int, B uint8) bool

核心:

count > bucketCnt &&
count > loadFactorNum*(bucketShift(B)/loadFactorDen)

Go 当前负载因子约:

6.5

即:

平均每个bucket约6.5个元素

就触发扩容。


例如:

8 个 bucket

容量:

8 × 6.5
=
52

插入第53个元素:

触发扩容

3. 第二种扩容条件

源码:

tooManyOverflowBuckets()

即:

overflow bucket 太多

例如:

bucket0
overflow1
overflow2
overflow3

虽然总元素不多:

20个

但哈希分布极差。

查找变慢。


也会扩容。


4. 两种扩容类型

源码:

sameSizeGrow

标志位:

h.flags

Go 有两种扩容。


第一种:翻倍扩容

正常情况:

bucket数量 ×2

例如:

B = 3

当前:

2³ = 8 bucket

扩容后:

B = 4

变成:

2⁴ = 16 bucket

第二种:等量扩容

当:

overflow太多

时:

bucket数量不变

例如:

8 bucket

扩容后仍然:

8 bucket

只是:

重新分布元素

清理 overflow。


5. 开始扩容

核心函数:

func hashGrow(t *maptype, h *hmap)

扩容前:

hmap

buckets
 old bucket array

扩容后:

hmap

buckets
 new bucket array

oldbuckets
 old bucket array

源码:

h.oldbuckets = oldbuckets
h.buckets = newbuckets

此时:

新旧 bucket 同时存在

6. 为什么不直接搬迁

传统 HashMap:

扩容
搬迁全部元素
恢复服务

例如:

100万元素

可能搬:

100万次

造成长时间暂停。


Go 不这么做。

Go:

先申请新桶

然后:

边访问边搬

7. 渐进式迁移

hmap:

oldbuckets
nevacuate

例如:

old bucket:

0
1
2
3
4
5
6
7

开始扩容:

nevacuate = 0

表示:

0之前已迁移

8. growWork()

每次:

mapassign()

或者:

mapdelete()

都会执行:

growWork()

源码:

growWork(t, h, bucket)

作用:

顺便搬迁几个bucket

例如:

第一次插入:

迁移 bucket0

第二次:

迁移 bucket1

第三次:

迁移 bucket2

逐步完成。


9. evacuate()

真正搬迁函数:

evacuate()

源码:

func evacuate(t *maptype,h *hmap, oldbucket uintptr)

负责:

old bucket
new bucket

迁移。


10. 元素搬到哪里

扩容前:

B=3

000
001
010
011
100
101
110
111

8 个 bucket。


扩容后:

B=4

16 个 bucket。


新增了一位:

0000
0001
0010
...
1111

因此旧 bucket 中元素:

要么留在原位置
要么移动到新位置

举例

原来:

bucket 3

011

扩容后:

新增一位:

0 011
1 011

即:

3
11

所以:

旧bucket3

中的元素:

部分去3
部分去11

11. x / y 分流

源码:

x
y

两个目标桶。


示意:

old bucket 3


   hash新位

    0 ?
   /   \
  ▼     ▼

bucket3
bucket11

判断逻辑:

if hash & newbit == 0

进入:

x

否则:

y

这就是扩容时的再哈希。


12. 如何保证查找正确

扩容期间:

oldbuckets != nil

查找:

mapaccess()

会先判断:

evacuated()

如果:

bucket未迁移

查:

old bucket

如果:

bucket已迁移

查:

new bucket

因此:

扩容期间依然可读

13. 搬迁完成

当:

nevacuate == oldBucketCount

说明:

全部搬完

源码:

h.oldbuckets=nil

释放:

old bucket array

最终:

只有新桶

存在。


14. 扩容过程完整图

插入元素
达到负载因子
hashGrow()
    ├──── 创建新桶
    ├──── oldbuckets=旧桶
    └──── buckets=新桶

         growWork()


         evacuate()


       bucket0迁移

       bucket1迁移

       bucket2迁移

       bucket3迁移
          ...

       oldbuckets=nil

一个具体例子

扩容前:

B=2

bucket0
bucket1
bucket2
bucket3

共:

4 bucket

扩容:

B=3

变:

8 bucket

旧 bucket2:

010

里面:

A
B
C

看 hash 新增位:

A -> 0010
B -> 1010
C -> 0010

结果:

bucket2:
A,C

bucket6:
B

完成分流。


为什么 Go Map 扩容性能好

Java HashMap(传统实现):

扩容
一次搬迁所有元素
暂停较久

Go Map:

扩容
创建新桶
每次操作顺便搬一点
逐步完成

因此:

单次延迟极低

这就是 Go Runtime Map 最核心的设计之一。


一句话总结

Go Map 扩容的本质是:

达到负载因子
创建新 bucket 数组
oldbuckets 保存旧桶
growWork() 驱动
evacuate() 逐桶迁移
元素按新增 hash 位分流
全部迁移完成后释放旧桶

也就是说,Go Map 采用的是 渐进式扩容(Incremental Rehash),把一次巨大的迁移成本拆分到后续的插入、删除操作中,从而避免长时间停顿。