Map 是 Go 语言内置的键值对(key-value)哈希集合,属于引用类型,核心作用是通过唯一键快速索引对应值,查询、插入、删除的平均时间复杂度均为O(1),是 Go 开发中最常用的数据结构之一。
1. Map 的基础特性
- 键唯一性:同一个 Map 中,key 唯一,重复插入会覆盖旧值。
- 键类型限制:key 必须是可比较类型(int、string、bool、结构体等),切片、map、函数不可作为 key。
- 无序性:Map 不存储键值对插入顺序,遍历顺序随机(Go 1.12+ 每次遍历顺序不一致)。
- 零值特性:Map 零值为
nil,nil map无法写入数据,仅可读取。 - 引用类型:赋值、传参仅传递引用,多个变量指向同一个底层数据
2. Map 的四种初始化方式
Go 语言 Map 必须初始化后才能写入数据,未初始化的nil map直接写入会触发运行时 panic。以下是所有合法初始化方式。
2.1 声明未初始化(nil map)
仅定义变量,未分配内存,零值为nil,只能读、不能写。
package main
import "fmt"
func main() {
// 声明nil map,未初始化
var m map[string]int
fmt.Println(m == nil) // 输出:true
// m["a"] = 1
// 报错:panic: assignment to entry in nil map
}
2.2 字面量初始化(空 Map/带初始值)
直接初始化并分配内存,支持空 Map 和预设键值对,最常用。
// 1. 初始化空map
m1 := map[string]int{}
m1["age"] = 20 // 可正常写入
// 2. 带初始值的map
m2 := map[string]int{
"张三": 18,
"李四": 22,
}
fmt.Println(m2) // 输出:map[张三:18 李四:22]
2.3 函数 make 初始化
通过内置函数 make 初始化,可指定初始容量,但无法进行同时进行键值对赋值。适合已知数据量级的场景,可以减少扩容的开销。语法:make(map[keyType]valType, 初始容量),容量可省略,默认 0。
// 初始化容量为8的map
m := make(map[string]int, 8)
// 键值对赋值
m["score"] = 95
fmt.Println(m) // 输出:map[score:95]
2.4 结构体嵌套 Map 初始化
复杂场景常用嵌套结构,需逐层初始化,避免内层map nil报错。
package main
import "fmt"
func main() {
// 嵌套map:key为班级,value为学生信息map
classMap := make(map[string]map[string]int)
// 内层map单独初始化
classMap["一班"] = make(map[string]int)
classMap["一班"]["小明"] = 99
fmt.Println(classMap) // 输出:map[一班:map[小明:99]]
}
3. Map 常用操作
Map 常用操作包含:增、改、查、删、遍历、获取长度,所有操作均有固定语法。
3.1 插入数据
Go 中插入新键值对只需要直接给 map 中的 key 赋value 值就行了。
// 初始化map
student := make(map[string]int, 4)
// 新增/修改:key存在则覆盖,不存在则新增
student["小明"] = 18
student["小红"] = 19
student["小明"] = 20 // 覆盖旧值
而且不需要自己判断数据有没有插入成功,Go 运行时会保证插入总是成功的,除非内存耗尽。
另外,如果插入键值对时,key 已经存在于 map 中了,会直接用新值覆盖旧值。
3.2 查找和读取数据
相比较插入,map 类型更多用于查找和数据读取的场合。查找数据即判断 key 是否存在于 map 中。
不要直接使用v := m["key"],然后想当然的认为如果 v 有值则表示 “key” 存在。即使这个键在 map 中并不存在,也会返回一个值,这个值是该键的元素类型的零值。
Go 提供一种名为“comma ok”的惯用法来支持对某个 key 是否存在的查询。
student := map[string]int{
"小明": 18,
}
v, ok := student["小红"]
if ok {
fmt.Println("小红年龄:", v)
} else {
fmt.Println("key不存在")
}
这里的ok是布尔类型变量 ,用来判断键 key 是否存在于 map 中。如果存在,变量就会被正确地赋值为键 key 对应的 value。
如果并不关心某个键对应的 value,而只关心某个键是否在于 map 中,可以使用空标识符_替代变量 v,忽略可能返回的 value:
_, ok := student["小红"]
if ok {
fmt.Println("key存在")
} else {
fmt.Println("key不存在")
}
注意: 查询必须用
comma ok惯用法双返回值来判断 key 是否存在,避免零值干扰。 查询必须用comma ok惯用法双返回值来判断 key 是否存在,避免零值干扰。 查询必须用comma ok惯用法双返回值来判断 key 是否存在,避免零值干扰。
3.3 删除数据
Go 提供了内置函数 delete 来从 map 中删除数据。函数的第一个参数是 map 的类型变量,第二个参数是想要删除的键。
delete(student, "小红")
需要注意的是:
delete函数是从 map 中删除键的唯一方法。- 即使传给
delete的键在 map中不存在,delete函数的执行也不会失败,更不会抛出运行时的异常
3.4 遍历数据
和删除数据一样,遍历 map 键值对只有一种方法,就是通过 for range 语句对 map 数据进行遍历。
student := map[string]int{
"小明": 18,
"小红": 15,
"小王": 19,
}
for k, v := range student {
fmt.Printf("学生:%s,年龄:%d\n", k, v)
}
/* 输出:
学生:小红,年龄:15
学生:小王,年龄:19
学生:小明,年龄:18
*/
循环每次迭代都会返回一个键值对,键存储在变量 k 中,值存储在变量 v 中。
在 Go 语言的 for range 循环中,如果只有一个接收变量,永远拿到的是第一个返回值,因此,如果只想要 key,可以这么写:
for k := range student {
...
}
/*
这样写也行,两者完全等价,但更推荐上面的写法,更加简洁。
for k, _ := range student {
...
}
*/
如果只想要 value,可以使用空标识符_来占位实现:
for _, v := range student {
...
}
Go 语言 map 类型还有一个重要特点,那就是每次遍历元素的次序都不相同。这是很多 Go 初学者容易踩坑的一个地方。
其实这是 Go 语言在设计时故意这么做的,如果遍历顺序看起来"稳定",开发者就会容易写出依赖顺序的代码(比如靠遍历顺序渲染前端字段、写单元测试断言顺序等),一旦底层实现变化或触发扩容,这些代码就会莫名其妙地出错。因此 Go 团队选择主动随机化,让这类问题在开发阶段就暴露出来,而不是潜伏到生产环境。
如果非要有序遍历怎么办呢?这就只能自行写代码来实现了。通常的做法是提取 key → 排序 → 按序取值。
m := map[string]int{
"a": 5,
"b": 3,
"c": 1
}
// 1. 提取 key
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
// 2. 排序
sort.Strings(keys)
// 3. 按序取值
for _, k := range keys {
fmt.Println(k, m[k])
}
一定要记住:程序逻辑千万不要依赖遍历 map 所得到的的元素次序。 Go 中的 map 追求的是
O(1)的查找性能,而不是有序性。如果需要有序,就手动排序。这恰好是 Go “显式优于隐式“哲学的体现。
3.5 获取长度
和切片一样,map 也可以通过内置函数 len 来获取当前已存储的键值对数量。
student := map[string]int{
"小明": 18,
"小红": 15,
"小王": 19,
}
fmt.Println("map长度:", len(student))
和切片不一样的是,map 类型不支持通过 cap 函数来获取当前容量,这和 map 的底层实现有关,切片类型底层是一个数组,可以明确知道其容量大小,而 map 底层实现更为复杂,无法确切的知道其容量大小。
4. Map 底层实现原理
4.1 Go 1.24 前实现原理
Go 语言 1.24 版本前,Map 底层实现由哈希表 + 桶数组 + 溢出桶的组合结构实现,源码位于 runtime/map.go,核心结构为 hmap。
map 类型在运行时示意图:

4.1.1 核心底层结构 hmap
每个 Map 对应一个 hmap 结构体,核心字段:
| 字段 | 描述 |
|---|---|
| count | 当前 map 中的元素个数。对 map 类型变量运用 len 内置函数时,len 函数返回的就是 count 这个值 |
| flags | 当前 map 所处的状态标志。目前定义了四个状态值:iterator、olditerator、hashWriting 和 sameSizeGrow |
| B | B 的值是 bucket 数量的以 2 为底的对数,也就是 2^B = bucket 数量 |
| noverflow | overflow bucket 的大约数量 |
| hash0 | 哈希函数的种子值 |
| buckets | 指向 bucket 数组的指针 |
| oldbuckets | 在 map 扩容阶段指向前一个 bucket 数组的指针 |
| nevacuate | 在 map 扩容阶段充当扩容进度计数器,所有下标号小于 nevacuate 的 bucket 都已经完成了数据排空和迁移操作 |
| extra | 可选字段。如果有 overflow bucket 存在,且 key、value 都因不包含指针而被内联(inline)的情况下,这个字段将存储所有指向 overflow bucket 的指针,保证 overflow bucket 是始终可用的(不被 GC 掉) |
4.1.2 桶结构 bmap
map 中真正用来存储键值对数据的是桶,每个桶(bmap)固定存储** 8 组key-value**,是 Map 最小存储单元。
桶中除了存储键值对之外,还有一片 tophash 区域,这个区域非常关键,是用来存放 key 的 hashcode 值的,运行时会把 hashcode 分为两部分来看待,其中低位区的值会用来做 Mask 计算,算出 bucket 的位置,而高位区的值用于在某个 bucket 中确定 key 的位置。
桶中的 key 和value 分开存储,而非成对存储,这样可以节省内存对齐开销。
单个桶存满后,会关联溢出桶,继续存储数据。
假设一个桶中存了 3 个键值对,内存布局如下(以 map[int64]int64 为例,key 和 value 各占 8 字节):
偏移 →
┌─────────────────────────────────────────────────┐
│ tophash[0] = 0xA1 ← 有数据 │ 偏移 0
│ tophash[1] = 0xB2 ← 有数据 │ 偏移 1
│ tophash[2] = 0xC3 ← 有数据 │ 偏移 2
│ tophash[3] = 0 ← 空槽位 (emptyRest) │ 偏移 3
│ tophash[4] = 0 ← 空槽位 │ 偏移 4
│ tophash[5] = 0 ← 空槽位 │ 偏移 5
│ tophash[6] = 0 ← 空槽位 │ 偏移 6
│ tophash[7] = 0 ← 空槽位 │ 偏移 7
├─────────────────────────────────────────────────┤ ← dataOffset
│ keys[0] = 100 ← 有数据 │ 偏移 8
│ keys[1] = 200 ← 有数据 │ 偏移 16
│ keys[2] = 300 ← 有数据 │ 偏移 24
│ keys[3] = 0 ← 零值(不是空的,是零值) │ 偏移 32
│ keys[4] = 0 ← 零值 │ 偏移 40
│ keys[5] = 0 ← 零值 │ 偏移 48
│ keys[6] = 0 ← 零值 │ 偏移 56
│ keys[7] = 0 ← 零值 │ 偏移 64
├─────────────────────────────────────────────────┤ ← dataOffset + 8×8
│ values[0] = 10 ← 有数据 │ 偏移 72
│ values[1] = 20 ← 有数据 │ 偏移 80
│ values[2] = 30 ← 有数据 │ 偏移 88
│ values[3] = 0 ← 零值 │ 偏移 96
│ values[4] = 0 ← 零值 │ 偏移 104
│ values[5] = 0 ← 零值 │ 偏移 112
│ values[6] = 0 ← 零值 │ 偏移 120
│ values[7] = 0 ← 零值 │ 偏移 128
├─────────────────────────────────────────────────┤
│ overflow = nil ← 无溢出桶 │ 偏移 136
└─────────────────────────────────────────────────┘
4.1.3 数据存储流程
- 使用 hash 函数对 key 进行哈希运算,得到哈希值
- 通过哈希值的高位确定归属桶(定位 buckets 数组下标)
- 通过哈希值的低位在桶内匹配已有的 key
- 匹配成功则覆盖值,匹配失败则新增,桶满则挂载溢出桶
- 当某个 bucket(比如 buckets[0]) 的 8 个空槽 slot)都填满了,且 map 尚未达到扩容的条件,运行时会建立溢出桶(overflow bucket),并将这个溢出桶挂在上面桶(如 buckets[0])末尾的 overflow 指针上,这样两个 buckets 形成了一个链表结构,直到下一次 map 扩容之前,这个结构都会一直存在。
数据插入,寻址示意图:

4.1.4 数据查找过程
了解了桶的结构和数据存储流程后,数据的查找过程也就不难理解了。在$GOROOT/src/runtime/map.go 源码中可以找到mapaccess1函数,这个函数是 Go 运行时实现 m[key] 读取操作的核心函数。下面是带详细注释的版本:
// mapaccess1 返回 map 中 key 对应的 value 指针。
// 如果 key 不存在,返回 nil 指针(对应零值)。
// t 是 map 的 type,h 是 *hmap 指针,key 是要查找的 key。
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// === 第0步:安全检查 ===
// 如果 map 为 nil(未 make),直接返回零值
if h == nil {
return nil
}
// 如果 map 正在被另一个 goroutine 写入,触发 panic
if h.flags&hashWriting != 0 {
throw("concurrent map read and map write")
}
// === 第1步:计算哈希值 ===
// 使用 map 类型注册的 hash 函数,传入 random seed (hash0) 防止哈希碰撞攻击
// hash0 在 map 创建时随机生成,同一 key 在不同程序运行中 hash 值不同
hash := t.hasher(key, uintptr(h.hash0))
// 提前检查是否超过最大 bucket 数量,防止 B 溢出
var (
b *bmap
top uint8
)
// === 第2步:处理未扩容阶段 ===
// 如果 B < bucketCntBits(=3),说明桶数量 < 8,没有溢出桶,数据都在主桶数组中
if h.B < bucketCntBits {
// 取哈希的低 B 位,定位到主桶数组中的具体桶
b = (*bmap)(add(h.buckets, hash&bucketMask(h.B)))
} else {
// === 第3步:处理可能正在扩容的阶段 ===
// 如果 oldbuckets 不为 nil,说明正在扩容中
if h.oldbuckets != nil {
// 旧桶数组的大小是 2^(B-1)
if h.flags&iterator == 0 {
// 如果不是迭代操作(即普通的 m[key] 读取),
// 需要检查是否正在从旧桶迁移数据到新桶
// advanceBucket 会确保我们不会读到被迁移走的数据
advanceBucket(h, t, hash)
}
// 使用旧的 B 值(B-1)来定位旧桶数组
b = (*bmap)(add(h.oldbuckets, hash&bucketMask(h.B-1)))
} else {
// 没有扩容,直接用当前 B 值定位主桶
b = (*bmap)(add(h.buckets, hash&bucketMask(h.B)))
}
}
// === 第4步:计算 tophash(哈希高8位) ===
top = tophash(hash)
// === 第5步:在当前桶中线性搜索 ===
for ; b != nil; b = b.overflow(t) {
// 遍历桶内的 8 个槽位
for i := bucketCnt - 1; i >= 0; i-- {
// 第一步:tophash 快速筛选
// 比较当前槽位的 tophash 与目标 tophash
if b.tophash[i] != top {
// tophash 不匹配,继续下一个槽位
// 注意:这里从后往前遍历,是因为在扩容迁移时,
// 数据可能从后往前被搬移,从后往前扫可以提高缓存命中率
continue
}
// tophash 匹配,需要进一步做完整的 key 比较
// 但先做一个快速检查:如果 tophash[i] == emptyRest,
// 说明从这个位置开始后面都是空的,可以提前终止搜索
if b.tophash[i] == emptyRest {
break
}
// === 第6步:完整 key 比较 ===
// 通过指针运算定位到 keys 数组中第 i 个 key 的内存地址
// dataOffset 是 keys 区域相对于桶起始地址的偏移
// i * uintptr(t.keysize) 定位到第 i 个 key
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
// 使用 map 类型注册的 equal 函数比较 key
// 对于 string 类型,equal 会比较字符串内容而非指针
if t.key.equal(key, k) {
// === 第7步:找到匹配的 key,计算 value 的地址并返回 ===
// value 数组紧跟在 keys 数组后面
// values 区域的起始偏移 = dataOffset + bucketCnt * keysize
// 第 i 个 value 的偏移 = i * valueSize
v := add(unsafe.Pointer(b),
dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
return v
}
}
// 当前桶没找到,沿着 overflow bucket 链表继续找
// overflow bucket 是普通 heap 分配的内存,通过指针链接
}
// === 第8步:所有桶都没找到,返回零值 ===
// 返回 map 值类型的零值指针
// 例如 int 类型的零值是 0,string 类型的零值是 ""
return unsafe.Pointer(&zeroVal[0])
}
查找流程总结:
| 步骤 | 操作 | 关键代码/公式 |
|---|---|---|
| 0 | nil 检查和并发写检查 | if h == nil { return nil } |
| 1 | 计算 64 位哈希值 | hash := hasher(key, h.hash0) |
| 2 | 取低 B 位定位桶 | bucket = hash & (2^B - 1) |
| 3 | 取高 8 位作为 tophash | top = hash >> (64 - 8) |
| 4 | 遍历桶链(含溢出桶) | for b != nil; b = b.overflow |
| 5 | tophash 快速筛选 | if b.tophash[i] != top { continue } |
| 6 | 完整 key 比较 | t.key.equal(key, k) |
| 7 | 通过同一下标 i 定位 value | valueAddr = b + dataOffset + 8×keysize + i×valuesize |
| 8 | 未找到返回零值指针 | return &zeroVal[0] |
4.1.5 Map 扩容机制
Map不会自动缩容,仅触发扩容。一般会有两种扩容情况:
- 键值对的总个数过多
- 大量删除数据导致溢出桶过多
Go 运行时的 map 实现中引入了一个 LoadFactor(负载因子),当 count > LoadFactor * 2^B 运行时会自动对 map 进行扩容。count 是指键值对的总个数,LoadFactor 常量为 6.5。
当键值对的总个数超过LoadFactor * 2^B时,运行时会建立一个两倍于现有规模的 bucket,然后像蚂蚁搬家一样,慢慢排空和迁移旧 bucket,迁移操作并不会一次性发生,而是采用渐进式迁移,每次增删改操作时迁移少量数据,避免单次扩容卡顿,提升性能。
而大量删除数据导致溢出桶过多时,触发的是等量扩容,运行时会新建一个和现有规模一样的 bucket,整理迁移数据。这种方式下的数据迁移也是渐进式的。
4.1.6 无序性底层原因
Go 在遍历 Map 时,会随机从某个桶开始遍历,而非从第一个桶开始,同时扩容会打乱数据存储位置,因此每次遍历顺序不一致,彻底杜绝开发者依赖遍历顺序的错误写法。
4.2 Go 1.24后实现原理
待补充
5. Map 常见错误及避坑方案
5.1 错误1:向 nil map 写数据
错误代码:
var m map[string]int
m["a"] = 1 // panic: assignment to entry in nil map
原因:仅声明未初始化,map 底层无内存空间。
修复:使用 make 或字面量初始化后再写入。
5.2 错误2:通过遍历修改Map value(结构体场景)
错误代码:遍历获取的是值拷贝,修改无效
type User struct{ age int }
m := map[string]User{"小明": {18}}
for k, v := range m {
v.age = 20 // 修改的是拷贝值,原map无变化
}
fmt.Println(m) // 原值不变
修复:使用指针作为 value,或通过 key 直接赋值修改
// 方案1:指针map
m := map[string]*User{"小明": {18}}
// 方案2:key直接赋值
m["小明"] = User{20}
5.3 错误3:忽略 key 不存在的零值歧义
问题:无法区分“key 不存在” 和 “key 值为零值”
m := map[string]int{"小明": 0}
fmt.Println(m["小明"]) // 0
fmt.Println(m["小红"]) // 0 两个结果一致,无法区分
修复:强制使用comma ok惯用法双返回值判断
if _, ok := m["小红"]; !ok {
fmt.Println("key不存在")
}
5.4 错误4:遍历 Map 时新增和删除数据
现象:遍历中新增数据,可能遍历不到;删除数据无报错但逻辑混乱。
原因:遍历过程中 Map 扩容、数据迁移会改变遍历索引。
修复:遍历仅做查询,新增/删除逻辑放在遍历外;或遍历前拷贝 key 列表。
5.5 错误5:切片、Map 作为 Map 的 key
错误原因:切片、map、函数是不可比较类型,无法作为 key,编译报错。
修复:转为 string、数组等可比较类型后作为 key。