本文详细介绍golang的哈希表的底层实现、扩容机制、插入查询过程以及并发安全性。
文章目录
定义
字典(Map)类型其实是哈希表(Hash Table)的一个实现。字典用于存储键-值对的无序集合。
Key无序性
为什么 map 的 key 无序?
- 扩容后键重新分布:键值对的存储位置随着扩容发生显著变化。
- 随机化遍历起点:遍历时从随机
bucket
和随机cell
开始,避免返回固定顺序。- 防止误解:杜绝程序员误以为
map
有固定的遍历顺序,避免潜在错误。- 语言特性:从 Go 1.0 起就设计为无序,以保持哈希表特性的一致性。
Key唯一性
注意: 同一个字典中的每个键都是唯一的。如果我们在向字典中放入一个键值对的时候其中已经有相同的键的话,那么与此键关联的那个值会被新值替换。
Key可比性
字典的键类型必须是可比较的(支持
==
和!=
运算的类型),否则会引起错误。也就是说,map的键不能是切片、字典或函数类型,可以是布尔值、数字、字符串、指针、通道、接口类型、结构体、只包含上述类型的数组。
从语法上看,float
类型符合键的要求,但慎用。虽然 float 类型符合 Go 的 map 键要求(可比较),但由于浮点数的特性,如精度问题、NaN 的特殊性以及底层的哈希机制,使用 float 类型作为键可能导致一些诡异行为。
基本使用
-
使用字面量或者内置函数
make
进行声明。 -
内置函数
delete
用于删除 map 中指定键的元素。如果 map 为nil
或不存在该键,delete
是无操作的。// The delete built-in function deletes the element with the specified key // (m[key]) from the map. If m is nil or there is no such element, delete // is a no-op. func delete(m map[Type]Type1, key Type)
-
v,ok:=m[key] 查找key是否存在,返回v为key对应的value,如果不存在则为对应类型零值,ok为bool表示是否存在。
底层实现
哈希表实现hmap
字典使用哈希表作为底层实现,一个哈希表里可以有多个哈希表节点(bucket),而每个 bucket 保存了 map 中的一个或一组键值对。
map
的数据结构由 runtime/map.go
中的 hmap
定义:
type hmap struct {
count int // 当前保存的元素个数
...
B uint8 // 指示 bucket 数组的大小
...
buckets unsafe.Pointer // bucket 数组指针,数组的大小为 2^B
...
}
示例:一个拥有 4 个 bucket 的 map
:
hmap.B = 2
hmap.buckets
长度为2^B = 4
。
元素经过哈希运算后会落到某个 bucket 中进行存储。查找过程类似。
bucket 数据结构bmap
bucket很多时候被翻译为桶,所谓的哈希桶实际上就是bucket。
bucket 的数据结构由 runtime/map.go
中的 bmap
定义:
type bmap struct {
tophash [8]uint8 // 存储哈希值的高 8 位
data byte[1] // key-value 数据: key/key/key/.../value/value/value...
overflow *bmap // 溢出 bucket 的地址
}
- 每个 bucket 可以存储 8 个键值对。
- tophash 是一个长度为 8 的数组,低位用来选择桶,高位区分桶中不同的项。哈希值相同的键(准确的说是哈希值低位相同的键)存入当前bucket时会将哈希值的高位存储在该数组中,以方便后续匹配。
- data 区存放的是
key-value
数据,顺序为key/key/.../value/value/...
,以节省字节对齐带来的空间浪费。 - overflow 指针指向的是下一个 bucket,用类似链表的方式将 bucket 连接起来以解决冲突。
注意:上述中data和overflow并不是在结构体中显示定义的,而是直接通过指针运算进行访问的。
下图展示bucket存放8个key-value对:
链地址法哈希冲突
当有两个或以上的键被哈希到同一个 bucket 时,就发生了冲突。Go 使用链地址法解决冲突。
- 每个 bucket 可以存放 8 个键值对。
- 超过 8 个键值对时会创建溢出(overflow) bucket,将其连接到原 bucket。
下图展示产生冲突后的map如下:
事实上哈希冲突并不是好事情,它降低了存取效率,好的哈希算法可以保证哈希值的随机性,但冲突过多也是要控制的,这就需要负载因子。
负载因子
负载因子衡量哈希表的冲突情况:
负载因子 = 键数量 / bucket 数量
例如,对于一个bucket数量为4,包含4个键值对的哈希表来说,这个哈希表的负载因子为1.
哈希表需要将负载因子控制在合适的大小,超过其阀值需要进行rehash,也即键值对重新组织:
- 负载因子过小:空间利用率低。
- 负载因子过大:冲突严重,存取效率低。
每个哈希表的实现对负载因子容忍程度不同,Go 中负载因子达到 6.5 时会触发扩容,而 Redis 负载因子大于 1 就触发扩容。
因为Redis的每个bucket只能存1个键值对,而Go的bucket可能存8个键值对,所以Go可以容忍更高的负载因子。
扩容
增量扩容
当负载因子过大时,Go 会新建一个 bucket,长度为原来的 2 倍,将旧 bucket 的数据搬迁到新 bucket。考虑到如果map存储了数以亿计的key-value,一次性搬迁将会造成比较大的延时,Go 使用逐步搬迁策略:
- 每次访问
map
时都会触发搬迁,每次搬迁 2 个键值对。
下图展示了包含一个bucket满载的map(为了描述方便,图中bucket省略了value区域):
当前map存储了7个键值对,只有1个bucket。此地负载因子为7。再次插入数据时将会触发扩容操作,扩容之后再将新插入键写入新的bucket。
当第8个键值对插入时,将会触发扩容,扩容后示意图如下:
hmap数据结构中oldbuckets成员指身原bucket,而buckets指向了新申请的bucket。新的键值对被插入新的bucket中。 后续对map的访问操作会触发迁移,将oldbuckets中的键值对逐步的搬迁过来。当oldbuckets中的键值对全部搬迁完毕后,删除oldbuckets。
搬迁完成后的示意图如下:
数据搬迁过程中原bucket中的键值对将存在于新bucket的前面,新插入的键值对将存在于新bucket的后面。 实际搬迁过程中比较复杂,将在后续源码分析中详细介绍。
等量扩容
等量扩容并不会增加容量,而是把松散的键值对重新排列一次,提高 bucket 的使用率。适用于以下极端场景:
- 大量增删操作导致键值对集中在少数 bucket,而负载因子不高,无法触发增量扩容。如下图所示:
上图可见,overflow的buckt中大部分是空的,访问效率会很差。此时进行一次等量扩容,即buckets数量不变,经过重新组织后overflow的bucket数量会减少,即节省了空间又会提高访问效率。
查找过程
查找过程如下:
-
计算哈希值:
根据key
值计算哈希值,确定键的哈希分布。 -
定位 bucket:
取哈希值的低位与hmap.B
取模,确定目标 bucket 的位置。 -
查找
tophash
:
在 bucket 的tophash
数组中查找哈希值高位,用于快速定位可能的键。 -
比较键值对:
如果tophash
匹配,进一步比较 bucket 中的实际键(key
)值,确认是否找到目标键。 -
处理溢出 bucket:
如果当前 bucket 未找到目标键,则继续查找其溢出 bucket(overflow bucket
)。 -
处理扩容中间态:
- 如果
map
正在扩容,需同时检查新旧 bucket:- 先查找新 bucket。
- 对未搬迁的旧 bucket,判断其
tophash[0]
是否在(0,4)
区间内:- 已迁移:直接查找新 bucket。
- 未迁移:从旧 bucket 中筛选符合当前 bucket 的键(基于
lowbits
判断键是否属于当前新 bucket)。
- 如果
-
返回结果:
- 如果找到目标键,返回其值。
- 如果查找不到,返回对应类型的零值(
0
、""
、nil
等)。
注:查找不到时,返回对应类型的零值。
插入过程
以下是整合了具体赋值过程的 Go 中 map
赋值流程:
- 计算哈希值:定位
bucket
。 - 并发检查:检测写标志位,防止多协程冲突。
- 扩容检查与搬迁:
- 判断是否正在扩容。
- 若是,确保老
bucket
数据迁移完成。
- 定位插入点:
- 遍历当前
bucket
和其overflow bucket
。 - 找到空位或匹配键,记录插入点。
- 遍历当前
- 扩容触发与重新定位:
- 判断是否需要扩容,必要时重新查找插入位置。
- 插入或更新键值:
- 空闲位置插入新键。
- 匹配位置直接更新值。
- 更新状态:
- 扩容后更新键值分布。
- 更新元素计数
count
。 - 清除写标志位。
- 返回值指针:返回键值对中
value
的指针,用于完成赋值操作。
如果写标志位 (hashWriting) 被置为 1,说明当前有其他协程在写入。程序此时会 panic,因为 Go 的 map 不支持并发写操作。
删除流程
- 写操作安全检查:检查写标志位,防止并发写冲突。
- 定位目标
bucket
:通过哈希值找到目标bucket
。 - 检查扩容状态:如果正在扩容的过程中,直接触发一次搬迁操作。
- 查找目标键:在
bucket
和overflow bucket
中查找key
的位置。 - 清除键值对:
- 使用
typedmemclr
或设置指针为nil
清除key
和value
。
- 使用
- 更新状态:
- 将
tophash
值置为空。 - 减少
count
值,更新元素计数。
- 将
非并发安全
Go 的 map
不是线程安全的,在并发情况下,只读是线程安全的,同时读写是线程不安全的。
- 多个协程中,如果多个协程同时对一个 map 执行只读操作(例如查找键值),是线程安全的。 map 的只读操作不会修改其内部结构,不会引发竞态条件。
- 多个协程中,如果一个协程对 map 执行写操作(如插入或删除键值对),而另一个协程同时执行读操作,则会引发数据竞态问题。Go 会在运行时检测到并触发
panic
,提示:concurrent map writes
。 - 在同一个协程内操作
map
,包括遍历、删除、插入等,是安全的,但需要注意操作的顺序和可能的行为。Go 的 map 本质是单线程安全的,因此单线程内可以同时进行读和写操作,而不会引发 panic 或数据错误。但是不推荐这样做,插入/删除的键可能出现在遍历结果中,也可能不出现。
map
的线程安全机制
写标志机制
- 每次写操作(如插入或删除键值对)都会设置一个写标志
hashWriting
,来防止并发写引发的不一致问题。 - 如果在写标志置位期间再次发生写操作,Go 会检测到并触发
panic
。
写标志代码示例
- 检测写标志:
if h.flags&hashWriting == 0 { throw("concurrent map writes") }
- 设置写标志:
h.flags |= hashWriting
解决方案
1. 使用读写锁
使用 sync.RWMutex
保护 map
的读写操作:
- 读锁:
- 调用
RLock()
进行读操作。 - 读完后调用
RUnlock()
解锁。
- 调用
- 写锁:
- 调用
Lock()
进行写操作。 - 写完后调用
Unlock()
解锁。
- 调用
示例:
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.RWMutex
m := make(map[int]string)
// 写操作
mu.Lock()
m[1] = "one"
mu.Unlock()
// 读操作
mu.RLock()
fmt.Println(m[1])
mu.RUnlock()
}
2. 使用 sync.Map
sync.Map
是 Go 提供的线程安全map
,适用于高并发场景。- 特性:
- 支持并发读写。
- 提供内置的遍历和删除方法。
- 示例:
package main import ( "fmt" "sync" ) func main() { var sm sync.Map // 写入 sm.Store(1, "one") sm.Store(2, "two") // 读取 val, ok := sm.Load(1) if ok { fmt.Println(val) } // 删除 sm.Delete(1) // 遍历 sm.Range(func(k, v interface{}) bool { fmt.Printf("%v: %v\n", k, v) return true }) }
Q&A
key 和 value 为什么不能取地址?
-
不能取地址的原因:
- 键和值可能因扩容或哈希冲突而重新分配内存。
- 为避免内存安全问题,Go 禁止直接对
map
的键或值取地址。
-
解决方案:
- 使用临时变量。
- 将值存储为指针类型。
- 用
struct
包装值。
比较两个 map
是否相等的方法
- 检查长度是否一致。
- 遍历其中一个
map
,检查每个键值对是否存在于另一个map
中。 - 对于复杂值类型,使用嵌套比较或
reflect.DeepEqual
(内置函数,可以递归比较复杂数据结构)。
虽然 Go 不支持直接比较 map
,但通过这些方法可以实现灵活且安全的比较逻辑。