跳到主要内容

Map

问题

Go 中 Map 的底层数据结构是什么?扩容机制如何工作?为什么 Map 不是并发安全的?

答案

Map 基本使用

// 创建
m := map[string]int{
"alice": 90,
"bob": 85,
}
m2 := make(map[string]int) // 空 map,可以写入
m2["charlie"] = 88

// 读取(返回零值 + 是否存在)
score, ok := m["alice"] // 90, true
score2, ok2 := m["unknown"] // 0, false

// 删除
delete(m, "bob")

// 遍历(顺序不确定)
for k, v := range m {
fmt.Printf("%s: %d\n", k, v)
}

底层数据结构

Go map 底层是一个哈希表,核心结构定义在 runtime/map.go

// hmap 是 map 的头部结构
type hmap struct {
count int // 元素个数(len() 的返回值)
flags uint8 // 并发读写标记
B uint8 // 桶数量的对数(桶数 = 2^B)
noverflow uint16 // 溢出桶近似数量
hash0 uint32 // 哈希种子(随机化)
buckets unsafe.Pointer // 桶数组指针(指向 []bmap)
oldbuckets unsafe.Pointer // 扩容时旧桶数组
nevacuate uintptr // 扩容迁移进度
extra *mapextra // 溢出桶等额外信息
}

// bmap 是每个桶的结构
type bmap struct {
tophash [8]uint8 // 存储 8 个 key 哈希的高 8 位(快速比较)
// 后面紧跟 8 个 key 和 8 个 value(编译时确定布局)
// 最后是一个 overflow 指针(指向溢出桶)
}

查找过程

  1. 计算 key 的哈希值 hash(key)
  2. 用哈希值的低 B 位确定桶号:bucket = hash & (2^B - 1)
  3. 在桶内用 tophash(高 8 位)快速比较,找到候选位置
  4. 比较完整 key 确认匹配
  5. 如果当前桶没找到,沿 overflow 链继续查找
为什么 key 和 value 分开存储?

bmap 中 key 和 value 是分开排列的:key0, key1, ..., key7, val0, val1, ..., val7,而不是 key0, val0, key1, val1, ...。这样做是为了减少内存对齐 padding。例如 map[int8]int64,如果交替存储需要大量 padding(1+7+8=16 bytes/pair),分开存储只需要 1×8 + 8×8 = 72 bytes(8 对)。

扩容机制

Map 扩容分两种情况:

触发条件扩容方式说明
负载因子 > 6.5翻倍扩容(B+1)元素太多,桶不够用
溢出桶过多等量扩容(B 不变)元素分布不均匀,溢出桶太长

负载因子 = 元素数量 / 桶数量。当负载因子超过 6.5(平均每个桶超过 6.5 个元素),触发翻倍扩容。

扩容是渐进式的——不会一次性搬迁所有数据,而是在每次 map 操作(插入/删除)时搬迁 1-2 个旧桶,分摊开销(类似 Redis rehash)。

Map 并发安全问题

Go 的 map 不是并发安全的——多个 goroutine 同时读写 map 会导致 panic:

m := make(map[int]int)

// ❌ 并发写:fatal error: concurrent map writes
go func() {
for i := 0; i < 1000; i++ {
m[i] = i
}
}()
go func() {
for i := 0; i < 1000; i++ {
m[i] = i
}
}()

Go 运行时在 map 操作时会检查 hmap.flags 中的写标志,检测到并发写会直接 throw("concurrent map writes") 而非返回错误——这是不可 recover 的 fatal error

解决方案

// 方案 1:sync.RWMutex
type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func (sm *SafeMap) Get(key string) (int, bool) {
sm.mu.RLock()
defer sm.mu.RUnlock()
v, ok := sm.m[key]
return v, ok
}
func (sm *SafeMap) Set(key string, val int) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.m[key] = val
}

// 方案 2:sync.Map(适合读多写少场景)
var sm sync.Map
sm.Store("key", 42)
val, ok := sm.Load("key")
sm.Delete("key")
sm.Range(func(k, v any) bool {
fmt.Println(k, v)
return true // 返回 false 停止遍历
})
sync.Map 适用场景

sync.Map 并非万能。它在以下两种场景下性能好于 RWMutex + map

  1. 读多写少:key 一旦写入很少更新
  2. 不同 goroutine 操作不同 key(无竞争)

如果写操作频繁、key 集合变化大,RWMutex + map 通常更好。

Map 遍历顺序

Go map 的遍历顺序是故意随机化的——即使同一个 map,两次 for range 的顺序也可能不同。这是 Go 设计者有意为之,防止开发者依赖遍历顺序。

如果需要有序遍历,先取出 key 排序:

keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}

常见面试问题

Q1: Map 的 key 可以用哪些类型?

答案

Map 的 key 必须是**可比较的(comparable)**类型,即支持 ==!= 运算符的类型:

可做 key ✅不可做 key ❌
所有基本类型(int、string、bool、float...)slice
指针map
channelfunc
数组(元素可比较的)包含不可比较字段的 struct
接口(动态类型可比较的)
struct(所有字段都可比较的)
float 做 key 的坑

float 虽然可以做 key,但由于浮点精度问题,NaN != NaN,所以 NaN 作为 key 可以写入但永远读不出来。实际开发中不建议用 float 做 key

Q2: Map 的负载因子为什么是 6.5?

答案

这是 Go 团队通过大量基准测试选定的经验值。每个桶能存 8 个 KV 对,负载因子 6.5 意味着平均每个桶存了 6.5 个元素(桶利用率 81.25%)。这是在哈希冲突(查找性能下降)内存利用率之间的平衡点。太低浪费内存,太高冲突严重。

对比:Java HashMap 负载因子默认 0.75(每个桶平均不到 1 个元素),Go 因为每个桶能存 8 个元素,所以可以承受更高的负载因子。

Q3: 如何判断 map 中是否存在某个 key?

答案

comma ok 惯用法:

// ✅ 正确方式
if val, ok := m["key"]; ok {
// key 存在,val 是值
} else {
// key 不存在
}

// ❌ 不可靠:不能通过零值判断存在性
val := m["key"]
if val == 0 {
// 不确定是 key 不存在,还是 key 的值就是 0
}

Q4: map 可以边遍历边删除吗?

答案

可以。Go 官方明确允许在 for range 中删除元素,行为是安全的:

for k, v := range m {
if v < 60 {
delete(m, k) // OK,安全的
}
}

但在 for range新增的元素不保证被遍历到。规范说明:如果在遍历过程中创建了新的 map entry,可能被遍历到也可能不被遍历到——行为未定义。

Q5: sync.Map 和 RWMutex+map 怎么选?

答案

场景推荐方案
读多写少,key 相对稳定sync.Map
不同 goroutine 访问不同 keysync.Map
写操作频繁RWMutex + map
需要批量操作(遍历+修改)RWMutex + map
key 数量不确定,增删频繁RWMutex + map

sync.Map 内部用了两个 map(read map + dirty map)+ atomic 操作,读操作可以无锁完成,但写入和 miss 较多时性能反而会下降。

Q6: 为什么 Go map 不支持并发而不是直接内置锁?

答案

Go 的设计哲学是不为所有用户付费。内置锁意味着每次 map 操作都有锁开销,但绝大多数 map 使用场景是单 goroutine 的,不需要并发安全。让用户根据需要自行选择加锁策略(Mutex、RWMutex、sync.Map、channel)更灵活、更高效。

同时,运行时会检测并发读写并报 fatal error,而非静默产生数据竞争——这比悄悄出错要好得多。

相关链接