jimyag's Blog

Go map 底层实现原理全解析

Last modified:

Go 的 map 用起来很简单:m[k] 查询,m[k] = v 写入,delete(m, k) 删除。但它背后的实现并不简单,尤其是 Go 1.26.3 这版已经不是很多老文章里讲的 hmap、bucket、overflow bucket 那套结构。

本文基于 Go 1.26.3 源码中的 src/runtime/map.go 和 src/internal/runtime/maps,按数据结构、创建、查询、写入、删除、扩容和遍历几个路径梳理 map 的真实执行过程。

核心结论先放前面:

  • Go 1.26.3 的 map 核心实现位于 internal/runtime/maps,runtime/map.go 主要是编译器、reflect 和 runtime 之间的转发层。
  • 新实现基于 Abseil Swiss Table 思路,每 8 个 key/value slot 组成一个 group,用 8 字节 control word 加速匹配。
  • hash 被拆成两部分:高位 H1 用来定位 group,低 7 位 H2 存进 control byte,用来快速筛候选 slot。
  • 小 map 最多 8 个元素时可以只用一个 group;更大时使用 table;再大时用 directory 管理多个 table。
  • 扩容不是旧实现里的渐进搬迁 bucket,而是 table 级重建;为了限制单次重建成本,大 map 会用 extendible hashing 拆成多个 table。

整体结构

runtime/map.go 里已经看不到旧的 hmap 定义,它引入的是:

1
import "internal/runtime/maps"

makemap、mapaccess1、mapassign、mapdelete 等入口最终都落到 maps.Map 上:

1
2
3
4
5
6
func makemap(t *abi.MapType, hint int, m *maps.Map) *maps.Map {
    if hint < 0 {
        hint = 0
    }
    return maps.NewMap(t, uintptr(hint), m, maxAlloc)
}

maps.Map 是 map 的顶层结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type Map struct {
    used uint64
    seed uintptr

    dirPtr unsafe.Pointer
    dirLen int

    globalDepth uint8
    globalShift uint8
    writing uint8

    tombstonePossible bool
    clearSeq uint64
}

几个字段的含义:

字段含义
usedmap 中真实存在的元素数量,也是 len(m) 读取的字段
seed每个 map 独立的 hash 种子,降低碰撞攻击风险
dirPtr小 map 时指向单个 group;大 map 时指向 table 指针数组
dirLendirectory 长度;为 0 表示 small map 模式
globalDepth/globalShiftextendible hashing 用来从 hash 高位选择 table
writing写入期间切换的标志,用来尽量发现并发读写/并发写
tombstonePossible是否可能存在删除留下的 tombstone
clearSeqclear(m) 的序号,遍历期间用它识别 clear

可以把 Go 1.26.3 的 map 看成下面这个层次:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
map variable
    │
    ▼
*maps.Map
    ├── small map: dirPtr -> group
    │
    └── full map: dirPtr -> directory []*table
                         │
                         ├── table
                         │     └── groups[] -> group -> 8 slots + control word
                         └── table
                               └── groups[] -> group -> 8 slots + control word

这和 slice 的区别很明显:slice 变量本身就是三字段描述符;map 变量更像一个指向 runtime map 头部的指针。var m map[string]int 的零值是 nil,查询没问题,写入会 panic。

当前底层结构图

Go 1.26.3 的 map 可以分成两条路径:小 map 只挂一个 group;大 map 通过 directory 找到 table,再在 table 的 groups 里查 slot。

  flowchart TD
    A["用户代码里的 map 变量"] --> B["*maps.Map"]

    B --> C["used<br/>元素数量,len(m) 读取它"]
    B --> D["seed<br/>每个 map 独立的 hash 种子"]
    B --> E["dirPtr / dirLen"]
    B --> F["globalDepth / globalShift<br/>directory 选 table"]
    B --> G["writing<br/>并发写检测"]

    E --> H{"dirLen == 0?"}

    H -->|是,小 map| I["dirPtr -> group"]
    I --> J["ctrlGroup<br/>8 个 control byte"]
    I --> K["8 个 slot<br/>key/elem key/elem ..."]

    H -->|否,大 map| L["dirPtr -> directory []*table"]
    L --> M["table A"]
    L --> N["table B"]
    L --> O["table C"]

    M --> P["groups[]"]
    P --> Q["group 0"]
    P --> R["group 1"]
    Q --> S["ctrlGroup"]
    Q --> T["8 个 key/elem slot"]

对应到一次 m[key] 查询,可以简化成下面这条路径:

  flowchart TD
    A["m[key]"] --> B{"m == nil 或 used == 0?"}
    B -->|是| Z["返回元素零值 / ok=false"]
    B -->|否| C["hash = Hasher(key, seed)"]
    C --> D["拆分 hash<br/>H1 = 高位<br/>H2 = 低 7 位"]
    D --> E{"small map?"}

    E -->|是| F["直接检查单个 group"]
    E -->|否| G["用 hash 高位选择 directory index"]
    G --> H["拿到 table"]
    H --> I["用 H1 创建 probeSeq"]
    I --> J["定位 group"]

    F --> K["ctrlGroup.matchH2(H2)"]
    J --> K
    K --> L{"有 H2 候选 slot?"}
    L -->|有| M["对候选 key 做完整 Equal 比较"]
    M --> N{"key 相等?"}
    N -->|是| O["返回 elem"]
    N -->|否| P["检查下一个候选"]
    P --> L
    L -->|没有| Q{"group 有 empty?"}
    Q -->|是| Z
    Q -->|否| R["probeSeq.next()<br/>继续下一个 group"]
    R --> J

写入和删除也是同一套定位逻辑:先 hash,再选 table/group,再通过 matchH2 缩小候选范围。区别在于写入找不到 key 时要找 empty/deleted slot,空间不够会触发 rehash;删除找到 key 后要清理 key/elem,并把 control byte 改成 empty 或 deleted。


Abseil Swiss Table 是什么

Swiss Table 是 Abseil 提供的一组高性能哈希表设计,官方设计说明在 Swiss Tables Design Notes。它的核心不是发明一种新的哈希函数,而是把「怎么在哈希冲突下更快找到候选位置」这件事做得更紧凑。

传统开放寻址哈希表通常是这样查找:

1
hash(key) -> 起始位置 -> 比较 slot0 -> 不匹配 -> 比较 slot1 -> 不匹配 -> ...

这种方式的问题是,每走一个 slot 都可能要访问 key,并调用一次完整相等比较。key 如果是字符串或复杂结构,这个成本不低。

Swiss Table 多加了一层 metadata,也就是 control byte。每个 slot 除了存 key/value,还对应 1 字节 metadata。这个 byte 记录两类信息:

  • slot 状态:empty、deleted、full;
  • hash 的一小段指纹:H2,也就是 hash 的低 7 位。

于是一次查询变成两阶段:

1
2
第一阶段:只看 metadata,快速筛出 H2 相同的候选 slot
第二阶段:只对候选 slot 做完整 key 比较

用图表示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
hash(key)
   │
   ├── H1:定位起始 group / probe 位置
   │
   └── H2:和 control byte 批量比较,筛候选 slot

group:
  ctrls: [80][12][34][fe][12][80][7a][09]
             ▲           ▲
             └── H2 命中 ┘

只比较 H2 命中的 slot 对应的 key。

这就是 Swiss Table 快的主要原因:它先在很小、连续、容易被 CPU 处理的 metadata 上做筛选,再访问真正的 key/value。Abseil 的 C++ 实现会用 SIMD 一次检查更多 metadata;Go 1.26.3 的实现固定一个 group 8 个 slot,用一个 8 字节 control word 表示 8 个 control byte。源码里也保留了同样的抽象:

1
2
3
4
5
type ctrlGroup uint64

func (g ctrlGroup) matchH2(h uintptr) bitset {
    return ctrlGroupMatchH2(g, h)
}

Go 这里借用的是 Swiss Table 的核心思路:

Swiss Table 思路Go 1.26.3 map 中的对应实现
metadata/control bytectrlGroup,8 个 control byte 打包成 uint64
H1/H2 拆分 hashh1(hash) 取高位,h2(hash) 取低 7 位
批量筛候选 slotmatchH2 返回 bitset,再逐个校验候选 key
empty/deleted/full 状态ctrlEmpty、ctrlDeleted、full = H2
开放寻址 + probingprobeSeq 按二次探测继续找 group

但 Go 没有直接照搬 Abseil 的 C++ 容器。Go 还要满足自己的语言语义:内置 map 的零值、len、delete、clear、range、GC 扫描、写屏障、反射、并发错误检测,以及遍历期间修改 map 的规则。所以 Go 在 Swiss Table 的 table/group 结构之外,又加了 Map、directory、extendible hashing、iterator 兼容逻辑这些 runtime 层设计。

简单说:Swiss Table 解决的是「单个哈希表内部怎么查得快、放得紧凑」;Go 的 maps.Map 还要解决「内置 map 如何创建、扩容、拆分、遍历、配合 GC 和语言规范」。


group 与 control byte

Swiss Table 的基本单位是 group。Go 这里固定一个 group 有 8 个 slot:

1
2
const MapGroupSlotsBits = 3
const MapGroupSlots = 1 << MapGroupSlotsBits // 8

一个 group 的逻辑布局是:

1
2
3
4
5
6
7
8
9
type group struct {
    ctrls ctrlGroup
    slots [8]slot
}

type slot struct {
    key  typ.Key
    elem typ.Elem
}

源码里没有直接写出这个 Go 结构体,而是由编译器根据 key/elem 类型生成对应布局。groupReference 通过偏移拿到 key 和 elem:

1
2
3
4
5
6
7
8
9
func (g *groupReference) key(typ *abi.MapType, i uintptr) unsafe.Pointer {
    offset := groupSlotsOffset + i*typ.SlotSize
    return unsafe.Pointer(uintptr(g.data) + offset)
}

func (g *groupReference) elem(typ *abi.MapType, i uintptr) unsafe.Pointer {
    offset := groupSlotsOffset + i*typ.SlotSize + typ.ElemOff
    return unsafe.Pointer(uintptr(g.data) + offset)
}

control word 是 8 字节,每个字节对应一个 slot。control byte 有三种状态:

1
2
3
empty:   1000 0000
deleted: 1111 1110
full:    0hhh hhhh   // h 是 hash 的低 7 位,也就是 H2

查找时不会直接逐个比较 8 个 key,而是先用 control word 一次筛出 H2 相同的候选 slot:

1
2
3
func (g ctrlGroup) matchH2(h uintptr) bitset {
    return ctrlGroupMatchH2(g, h)
}

这一步可能有假阳性,因为 H2 只有 7 bit,平均每 128 个 slot 会有一个控制字节碰上。但这没关系,runtime 会继续调用 key 的 Equal 函数做完整比较。control word 的价值是先把绝大多数不可能匹配的 slot 排除掉。

内存布局可以这样理解:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
group
┌─────────────────────────────────────────────┐
│ ctrls: [c0][c1][c2][c3][c4][c5][c6][c7]    │
├─────────────────────────────────────────────┤
│ slot0: key0, elem0                          │
│ slot1: key1, elem1                          │
│ ...                                         │
│ slot7: key7, elem7                          │
└─────────────────────────────────────────────┘

control byte:
  0x80  -> empty
  0xfe  -> deleted
  0x00~0x7f -> full,值为 H2

hash:H1 与 H2

map 查询、写入、删除都会先算 hash:

1
hash := typ.Hasher(key, m.seed)

Go 把 hash 拆成 H1 和 H2:

1
2
func h1(h uintptr) uintptr { return h >> 7 }
func h2(h uintptr) uintptr { return h & 0x7f }

两者分工不同:

  • H1:高位,用来选择 table 和 group,决定从哪里开始探测。
  • H2:低 7 位,写进 control byte,用来快速筛候选 slot。

完整查询路径大致是:

  flowchart TD
    A["m[key]"] --> B{"m == nil 或 used == 0?"}
    B -->|是| Z["返回 zeroVal / ok=false"]
    B -->|否| C{"writing != 0?"}
    C -->|是| X["fatal: concurrent map read and map write"]
    C -->|否| D["hash = Hasher(key, seed)"]
    D --> E{"dirLen == 0?"}
    E -->|是,小 map| F["在单个 group 内 matchH2 + Equal"]
    E -->|否,大 map| G["用 hash 高位选择 table"]
    G --> H["用 H1 构造 probeSeq"]
    H --> I["逐 group 探测"]
    I --> J["matchH2 找候选 slot"]
    J --> K{"Equal(key, slotKey)?"}
    K -->|是| L["返回 elem"]
    K -->|否| M{"group 有 empty slot?"}
    M -->|是| Z
    M -->|否| I

注意查询缺失 key 时,mapaccess1 返回的是 zeroVal 的地址,不是 nil;mapaccess2 才额外返回 ok=false。这就是 v := m[k] 和 v, ok := m[k] 的底层差异。


创建 map

常见创建方式:

1
2
m := make(map[string]int)
m2 := make(map[string]int, 100)

入口是 makemap,它会调用 maps.NewMap。关键逻辑是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func NewMap(mt *abi.MapType, hint uintptr, m *Map, maxAlloc uintptr) *Map {
    if m == nil {
        m = new(Map)
    }

    m.seed = uintptr(rand())

    if hint <= abi.MapGroupSlots {
        return m
    }

    targetCapacity := (hint * abi.MapGroupSlots) / maxAvgGroupLoad
    ...
}

如果 hint 不超过 8,runtime 不急着分配 group,只创建 Map 头部并设置 hash seed。第一次写入时才会走 growToSmall 分配一个 group。

执行路径:

  flowchart TD
    A["make(map[K]V, hint)"] --> B["runtime.makemap"]
    B --> C["maps.NewMap"]
    C --> D["创建或复用 *maps.Map"]
    D --> E["seed = rand()"]
    E --> F{"hint <= 8?"}
    F -->|是| G["返回空 Map\n暂不分配 group"]
    F -->|否| H["按 7/8 负载因子计算 targetCapacity"]
    H --> I["创建 directory"]
    I --> J["创建一个或多个 table"]
    J --> K["返回 Map"]

小 map 第一次写入时:

1
2
3
4
5
6
7
func (m *Map) growToSmall(typ *abi.MapType) {
    grp := newGroups(typ, 1)
    m.dirPtr = grp.data

    g := groupReference{data: m.dirPtr}
    g.ctrls().setEmpty()
}

也就是说,make(map[K]V) 本身可能只分配 map 头部,真正的 slot 存储可以延迟到第一次写入。


写入 map

写入入口是 mapassign,最终会调用 Map.PutSlot 或 fast path。先看几个关键点:

  • nil map 写入直接 panic:assignment to entry in nil map。
  • 写入前会计算 hash,然后把 writing 标志切换为写入中。
  • 小 map 未满时直接在一个 group 里插入。
  • 小 map 超过 8 个元素后会转成 full map。
  • full map 中,如果当前 table 没有足够空间,会 rehash:要么 table 扩容,要么 table split。

简化后的写入路径:

  flowchart TD
    A["m[key] = value"] --> B{"m == nil?"}
    B -->|是| P["panic: assignment to entry in nil map"]
    B -->|否| C{"writing != 0?"}
    C -->|是| X["fatal: concurrent map writes"]
    C -->|否| D["hash = Hasher(key, seed)"]
    D --> E["writing ^= 1"]
    E --> F{"dirPtr == nil?"}
    F -->|是| G["growToSmall: 分配 1 个 group"]
    F -->|否| H
    G --> H{"dirLen == 0?"}
    H -->|是,小 map| I{"used < 8?"}
    I -->|是| J["putSlotSmall"]
    I -->|否| K["growToTable: 转 full map"]
    H -->|否| L["按 hash 选择 table"]
    K --> L
    L --> M["table.PutSlot"]
    M --> N{"找到已有 key?"}
    N -->|是| O["返回 elem slot,覆盖 value"]
    N -->|否| Q{"有 empty/deleted slot 且 growthLeft > 0?"}
    Q -->|是| R["写入 key,返回 elem slot"]
    Q -->|否| S["rehash 后重试"]
    J --> T["写 elem"]
    O --> T
    R --> T
    T --> U["writing ^= 1"]

PutSlot 只负责找到 elem 的存储位置并返回指针,真正把 value 写进去是外层做的:

1
2
3
4
func (m *Map) Put(typ *abi.MapType, key, elem unsafe.Pointer) {
    slotElem := m.PutSlot(typ, key)
    typedmemmove(typ.Elem, slotElem, elem)
}

这也解释了为什么 map 元素不能取地址:

1
2
// 编译错误:cannot take address of m["a"]
_ = &m["a"]

map 写入可能触发 table 重建,元素位置并不稳定。允许用户长期持有元素地址,会让 runtime 无法移动和重排 slot。


探测序列

full map 查找一个 key 时,不一定一次就落到正确 group。碰撞时需要继续探测后续 group。

Go 1.26.3 使用二次探测,源码注释给出的形式是:

1
p(i) = hash + (i^2 + i)/2  (mod mask+1)

实现很短:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
type probeSeq struct {
    mask   uint64
    offset uint64
    index  uint64
}

func makeProbeSeq(hash uintptr, mask uint64) probeSeq {
    return probeSeq{
        mask:   mask,
        offset: uint64(hash) & mask,
        index:  0,
    }
}

func (s probeSeq) next() probeSeq {
    s.index++
    s.offset = (s.offset + s.index) & s.mask
    return s
}

因为 group 数量总是 2 的幂,& mask 可以替代取模。探测过程有一个重要不变量:table 不能 100% 填满,探测序列最终必须遇到 empty slot。遇到 empty slot 就说明 key 不存在,可以停止。


删除 map 元素

删除入口是:

1
delete(m, key)

删除和查询类似,也会先 hash,再定位 table/group/slot。找到 key 后要做三件事:

  1. used--,更新 map 和 table 的元素数量。
  2. 清理 key/elem 内存,尤其是包含指针的对象,避免 GC 继续把它们当成可达对象。
  3. 更新 control byte:设为 empty 或 deleted。

为什么有时不能直接设为 empty?因为开放寻址里查找遇到 empty 就会停止。如果一个 slot 位于某个探测链中间,直接设 empty 可能让后面的 key 永远查不到。

所以 full map 删除时有两种情况:

1
2
3
4
5
group 里还有 empty slot:
    当前 slot 可以设为 empty

group 原本是满的:
    当前 slot 必须设为 deleted,也就是 tombstone

流程图:

  flowchart TD
    A["delete(m, key)"] --> B{"m == nil 或 used == 0?"}
    B -->|是| Z["返回"]
    B -->|否| C["hash = Hasher(key, seed)"]
    C --> D{"small map?"}
    D -->|是| E["单 group 查找"]
    D -->|否| F["选择 table 并探测"]
    E --> G{"找到 key?"}
    F --> G
    G -->|否| Z
    G -->|是| H["清理 key/elem"]
    H --> I["used--"]
    I --> J{"删除后 group 有 empty slot?"}
    J -->|是| K["control = empty"]
    J -->|否| L["control = deleted tombstone"]
    K --> M{"map used == 0?"}
    L --> M
    M -->|是| N["重置 hash seed"]
    M -->|否| Z

删除到 map 为空时,runtime 会重置 seed,降低反复构造碰撞的攻击面。


扩容与拆分

table 有三个和容量相关的字段:

1
2
3
4
5
6
type table struct {
    used       uint16
    capacity   uint16
    growthLeft uint16
    ...
}

capacity 是 slot 总数,必须是 2 的幂。最大平均负载因子是 7/8:

1
const maxAvgGroupLoad = 7

正常 table 的可继续插入数量是:

1
growthLeft = (capacity * 7) / 8 - used - tombstones

当 growthLeft == 0 时,插入会触发 rehash:

1
2
3
4
5
6
7
8
9
func (t *table) rehash(typ *abi.MapType, m *Map) {
    newCapacity := 2 * t.capacity
    if newCapacity <= maxTableCapacity {
        t.grow(typ, m, newCapacity)
        return
    }

    t.split(typ, m)
}

Go 1.26.3 里单个 table 的最大容量是 1024:

1
const maxTableCapacity = 1024

因此扩容有两种:

  flowchart TD
    A["table 没有 growthLeft"] --> B["newCapacity = 2 * capacity"]
    B --> C{"newCapacity <= 1024?"}
    C -->|是| D["grow: 创建更大的 table"]
    D --> E["重新 hash 并搬迁旧元素"]
    E --> F["directory 指向新 table"]
    C -->|否| G["split: 拆成 left/right 两个 table"]
    G --> H["按 localDepth 对应的 hash 高位分流"]
    H --> I{"localDepth == globalDepth?"}
    I -->|是| J["directory 扩大一倍"]
    I -->|否| K["复用现有 directory 空间"]
    J --> L["安装 left/right"]
    K --> L

这和旧 map 实现差别很大。旧实现强调 bucket 逐步搬迁;新实现里 table 重建是一次性的。为了避免一个超大 table 一次重建太贵,map 会把数据分散到多个 table。某个 table 满了,只重建或拆分这个 table。

directory 选择 table 的方式是 extendible hashing。globalDepth 表示用 hash 的多少个高位做 table 索引:

1
2
3
4
5
6
func (m *Map) directoryIndex(hash uintptr) uintptr {
    if m.dirLen == 1 {
        return 0
    }
    return hash >> (m.globalShift & 63)
}

示意:

1
2
3
4
5
6
7
globalDepth = 2

hash 高 2 位:
  00 -> directory[0] -> table A
  01 -> directory[1] -> table A   // 多个目录项可以指向同一个 table
  10 -> directory[2] -> table B
  11 -> directory[3] -> table C

一个 table split 后,directory 中原来指向它的一段索引会改成分别指向 left/right。如果当前 directory 深度不够,先扩一倍。


遍历 map

for k, v := range m 对 map 的要求比看上去复杂。语言规范允许遍历期间修改 map,并规定了几个语义:

  • 不保证遍历顺序。
  • 遍历期间新增的元素可能出现,也可能不出现。
  • 遍历期间删除还没访问到的元素,不应该再被返回。
  • 同一个元素不能返回两次。

runtime 用 maps.Iter 保存遍历状态:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type Iter struct {
    key  unsafe.Pointer
    elem unsafe.Pointer
    typ  *abi.MapType
    m    *Map

    entryOffset uint64
    dirOffset   uint64
    clearSeq    uint64
    globalDepth uint8
    dirIdx      int
    tab         *table
    group       groupReference
    entryIdx    uint64
}

初始化时会随机化起点:

1
2
it.entryOffset = rand()
it.dirOffset = rand()

所以 map 遍历顺序不是插入顺序,也不是 hash 顺序。它是有意随机化的。

为什么遍历不按顺序返回

map 没有「顺序」这个语义。它不是数组、slice 或链表,内部也不会记录 key 的插入先后。元素放在哪个 slot,主要取决于:

  1. key 的 hash 值;
  2. 每个 map 独立的随机 hash seed;
  3. 当前 table/directory 的大小;
  4. 碰撞后探测序列找到的空 slot;
  5. 后续 grow、split、delete 留下的 tombstone。

同一批 key,只要 map 的 seed 不同,hash 结果就可能不同;同一个 map,只要扩容或 split 发生,元素也可能被重新分布到新的 table/group 中。所以 runtime 没有稳定的「从第一个插入的元素开始遍历」这种信息可用。

即使暂时不考虑扩容,遍历也不是从第 0 个 directory、第 0 个 group、第 0 个 slot 固定开始。Iter.Init 会设置两个随机偏移:

1
2
it.entryOffset = rand()
it.dirOffset = rand()

后续遍历 directory 和 slot 时都会带上这两个偏移:

1
2
dirIdx := int((uint64(it.dirIdx) + it.dirOffset) & uint64(it.m.dirLen-1))
entryIdx := (it.entryIdx + it.entryOffset) & entryMask

因此,同一个 map 连续 range 两次,也可能得到不同顺序。这个设计有两个直接效果:

  • 避免开发者误以为 map 有稳定顺序,并在业务代码里依赖这个实现细节;
  • 让不同 hash seed、不同扩容状态下的遍历行为更接近语言规范里的「顺序未指定」。

如果业务上需要顺序,必须自己定义顺序。最常见做法是先收集 key,再排序,然后按排序后的 key 访问 map:

1
2
3
4
5
6
7
8
9
keys := make([]string, 0, len(m))
for k := range m {
    keys = append(keys, k)
}
slices.Sort(keys)

for _, k := range keys {
    fmt.Println(k, m[k])
}

遍历最麻烦的是扩容期间的语义。源码里的策略是:

  1. 如果遍历开始时的 table 后续被 grow/split,iterator 仍然保留旧 table,用旧 table 决定哪些 key 已经走过。
  2. 返回元素前,再去当前 map 里按 key 查一次。
  3. 如果 key 已删除,就跳过。
  4. 如果 value 被更新,就返回最新 value。

这样可以避免扩容后同一个 key 被返回两次,也能尽量符合「删除不再返回、更新返回最新值」的语义。

clear(m) 期间的语义

前面 Map 和 Iter 结构里都出现了 clearSeq,它就是给 clear(m) 准备的。clear 不是逐个 delete,而是一次性把 map 逻辑上清空,所以 iterator 需要一个更便宜的方式知道“从我开始遍历之后,这个 map 已经被整表清掉了”。

做法是:clear(m) 时递增 Map.clearSeq;迭代器初始化时把当时的序号快照到 Iter.clearSeq。后续 Next() 推进时,如果发现两者不相等,就说明遍历过程中发生过 clear。这时 iterator 不需要再按旧快照继续吐元素,而是可以直接按“这些元素已经在遍历前被批量删除”的语义处理。

从使用者角度看,range 期间执行 clear(m) 是安全且有定义的:它不会把 pre-clear 的元素继续完整吐完,也不会因为 bulk delete 打破“已删除元素不该再返回”的约束。clearSeq 就是 runtime 用来低成本维持这条语义的版本号。


fast path

源码里有几类 fast path:

1
2
3
4
5
6
7
runtime/map_fast32.go
runtime/map_fast64.go
runtime/map_faststr.go

internal/runtime/maps/runtime_fast32.go
internal/runtime/maps/runtime_fast64.go
internal/runtime/maps/runtime_faststr.go

它们分别服务于常见 key 类型:

  • 32 位 key
  • 64 位 key
  • string key

以 string 为例,runtime/map_faststr.go 只是 linkname 声明,真正实现被推到 internal/runtime/maps/runtime_faststr.go。这样可以绕开一层普通函数调用,也可以针对字符串做更具体的比较和 hash 优化。

对业务代码来说不需要手动使用这些 fast path。编译器会在合适场景把 m[k]、m[k] = v、delete(m, k) 降到对应 runtime 入口。


常见陷阱

nil map 可以读,不能写

1
2
3
4
5
6
var m map[string]int

fmt.Println(m["x"]) // 0
fmt.Println(len(m)) // 0

m["x"] = 1 // panic: assignment to entry in nil map

查询 nil map 会返回元素零值;写入 nil map 会 panic。修复方式是先 make:

1
2
m = make(map[string]int)
m["x"] = 1

判断 key 是否存在必须用 ok

1
2
3
4
m := map[string]int{"a": 0}

fmt.Println(m["a"]) // 0
fmt.Println(m["b"]) // 0

只看返回值分不清 key 存在且值为零,还是 key 不存在。应该写:

1
v, ok := m["a"]

底层上,缺失 key 会返回 zeroVal,ok 才是存在性信息。

map 不是并发安全的

runtime 里有 writing 标志,会尽量发现并发问题:

1
2
3
fatal: concurrent map writes
fatal: concurrent map read and map write
fatal: concurrent map iteration and map write

这只是运行时检测,不是同步机制。并发读写 map 必须用 sync.Mutex、sync.RWMutex,或者在适合的读多写少场景使用 sync.Map。

map 元素不能取地址

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

// _ = &m["a"] // 编译错误

根本原因是 map 扩容、rehash、split 都可能移动元素位置。允许用户拿到元素地址会破坏 runtime 对内部存储的管理。

如果值是结构体,不能直接改字段:

1
2
3
4
5
6
7
type User struct {
    Name string
}

m := map[int]User{1: {Name: "old"}}

// m[1].Name = "new" // 编译错误

常见写法是取出、修改、再放回:

1
2
3
u := m[1]
u.Name = "new"
m[1] = u

或者让 map 存指针:

1
2
m := map[int]*User{1: {Name: "old"}}
m[1].Name = "new"

遍历顺序不稳定

1
2
3
4
5
6
7
8
9
m := map[string]int{
    "a": 1,
    "b": 2,
    "c": 3,
}

for k := range m {
    fmt.Println(k)
}

不要依赖输出顺序。需要稳定顺序时,先把 key 拿出来排序:

1
2
3
4
5
6
7
8
9
keys := make([]string, 0, len(m))
for k := range m {
    keys = append(keys, k)
}
slices.Sort(keys)

for _, k := range keys {
    fmt.Println(k, m[k])
}

delete 不会缩容

delete 会清理 slot,并可能留下 tombstone。后续插入可能复用这些位置,rehash 也会清理 tombstone,但 map 不会因为大量删除自动把 directory/table 缩小。

如果一个大 map 删到很小且长期保留,想释放内存,通常直接新建一个 map:

1
2
3
4
5
6
old := bigMap
newMap := make(map[string]int, len(old))
for k, v := range old {
    newMap[k] = v
}
bigMap = newMap

如果只是清空并复用,可以用 Go 1.21 引入的内置函数:

1
clear(m)

clear 会删除所有元素,但它的目标是清空内容,不是保证缩容。

大 key / 大 value 可能间接存储

abi.MapType 里有两个标志:

1
2
func (mt *MapType) IndirectKey() bool
func (mt *MapType) IndirectElem() bool

Go 的常量里也能看到阈值:

1
2
3
4
const (
    MapMaxKeyBytes  = 128
    MapMaxElemBytes = 128
)

超过阈值的 key 或 value 可能不直接放在 slot 里,而是 slot 保存指针,实际对象单独分配。这会增加一次间接访问和 GC 压力。实践上,map 的 key 应尽量小、可比较、稳定;value 很大时要明确选择存值还是存指针。


小结

Go 1.26.3 的 map 可以用一句话概括:顶层是 Map,小 map 直接挂一个 8-slot group;大 map 通过 directory 选择 table;table 内部是 Swiss Table 风格的 groups;每个 group 用 control word 一次筛 8 个 slot;扩容时 table 级重建,超过上限后通过 extendible hashing 拆分。

理解这些细节后,很多日常现象就不再神秘:

  • nil map 能读不能写,是因为 nil map 没有 runtime 头部可写。
  • 缺失 key 返回零值,是因为 mapaccess1 返回 zeroVal。
  • map 遍历顺序不稳定,是因为 iterator 起点随机化。
  • map 元素不能取地址,是因为 rehash/split 会移动元素。
  • 并发读写会崩,是因为 map 内部结构写入期间没有同步保护。
  • 大量 delete 不一定释放内存,是因为 table/directory 不按删除自动缩容。

map 的接口很小,但 runtime 为了把平均 O(1)、内存局部性、删除语义、遍历语义和并发错误检测同时做好,内部复杂度并不低。写业务代码时,不需要记住所有字段;真正有用的是记住它的边界:不要依赖遍历顺序,不要并发读写,不要拿元素地址,容量和删除行为要按 runtime 的实现去理解。

参考资料

#Go #Map #底层原理 #Runtime