map源码解析
1 map 的初始化
常用的 map 初始化方式有两种:一种是字面量初始化,一种是使用 make
m1 := map[string]int{"a":1, "b":2} //字面量初始化
m2 := make(map[string]int, 10) //使用make初始化
查看一下程序的编译结果
无论是使用字面量初始化方式,还是使用 make 初始化方式,都是通过调用 runtime 库里的 makemap_small() 或 makemap() 函数来进行初始化的。
区别在于,如果初始化的键值对数量或容量的小于或等于8,就会使用 makemap_small() ,大于8则会使用 makemap() 。判断初始化 map 容量大小的操作由编译器在编译时完成,并确定调用哪个函数。
makemap() 和 makemap_small() 的函数签名分别如下:
func makemap_small() *hmap
func makemap(t *maptype, hint int, h *hmap) *hmap
从函数签名可知,map 的本质是一个指向 hmap 结构体的指针。
2 map 的结构
// hmap 是 Go map 的底层结构
// 先有个印象,一个 hamp 下会有多个哈希桶(bmap)
type hmap struct {
count int
flags uint8
B uint8 // 哈希桶的数量的log2,如果有8个桶,则B=3,即有2^B个桶
// loadFactor 表示每个桶的期望负载,则最多能容纳 loadFactor * 2^B 个键值对
noverflow uint16 // 溢出桶的数量(估计)
hash0 uint32 // 哈希种子
buckets unsafe.Pointer // 指向哈希桶数组的指针,这个哈希桶数组有2^B个桶
oldbuckets unsafe.Pointer // 扩容时指向旧桶的指针,只有当扩容时才不为nil
nevacuate uintptr //
extra *mapextra // 可选字段
}
对 hmap 有印象后,我们再看看 buckets 这个指针。
在 makemap() 中,它被下面这段代码赋值
追溯 makeBucketArray() ,……
buckets 数组中的每一个桶都是一个 bmap 结构体
// 每一个桶都是一个 bmap 结构体
type bmap struct {
// tophash generally contains the top byte of the hash value
// for each key in this bucket. If tophash[0] < minTopHash,
// tophash[0] is a bucket evacuation state instead.
tophash [bucketCnt]uint8
// 其实,bmap 结构体还包括另外三个字段,但是查看代码时是看不到的。怎么实现的?
// 分别是 keys 数组,elems 数组,和 overflow 指针
// 之所以没写死,是因为 key 和 elem 的类型是不确定的,需要编译时才能确定
// keys 是一个数组,大小为 bucketCnt, 存放 key,实质上 bucketCnt = 8
// elems 是一个数组,大小为 bucketCnt, 存放 Value
// 之所以将所有key存放在一个数组,将value存放在一个数组,
// 而不是键值对的形式(key/value/key/value...这样交替出现)
// 是为了消除例如map[int64]int8所需的填充(内存对齐)
// overflow 是一个指针,指向溢出桶,当该桶不够用时,才会使用溢出桶
}
至此,我们对 map 的理解可以用下面这个图表示
3 map 的访问(查找)
根据上面的理解,在map中查找一个键值对,首先要获取桶号,然后逐个比较该桶和溢出桶中的tophash(哈希高八位)的值。如果 tophash 匹配成功,则说明有可能 key 相等,接着验证key值是否相等,如果相等就说明找到了。
我们在程序中访问 map 通常有两种返回值情形,一种只获得值,一种获得值以及表示是否查找到的布尔值
val := m["a"] // 只获得值
val, ok := m["a"] // 获得值以及表示是否查找到的布尔值
这在 runtime 库中对应两个函数,其签名分别为
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool)
我们发现其实,这里返回的值其实都是指向对应值的指针,注意这里永远不返回 nil,如果对应的 key 不存在,则返回值类型对应的零值。
// 以 mapaccess1 为例,我们看下具体的查找过程
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// ...略
// 如果哈希表 h 为 nil 或 元素数量为 0,则返回零值
// 特别当哈希表 h 为 nil 时,再继续往下很可能会引起 Panic 所以一定要提前返回零值
if h == nil || h.count == 0 {
if t.hashMightPanic() {
t.hasher(key, 0) // see issue 23734
}
return unsafe.Pointer(&zeroVal[0])
}
// 防止 map 并发读写,如果检测到并发读写就 panic
if h.flags&hashWriting != 0 {
fatal("concurrent map read and map write")
}
// 对 key 和 hash0 进行哈希,计算哈希值
hash := t.hasher(key, uintptr(h.hash0))
// 计算桶号
// 计算桶掩码m,之后用这个掩码 m & hash(与上hash)就可以得到桶号
// 桶掩码的计算实质是 将 1 右移 B 位之后减1 得到的,其实就是低B位为1,高位为0
// 也就是说,桶号就是 hash 的低 B 位
m := bucketMask(h.B)
// 这里根据桶号,桶大小,桶数组起始地址,来计算得到指向对应桶的指针
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
// 如何h.oldbuckets != nil,说明此时map正在扩容,需要判断当前要访问的key是在新桶中还是在旧桶中
// 总之下面这段代码会将 b 指向合适的新桶/旧桶
if c := h.oldbuckets; c != nil {
if !h.sameSizeGrow() {
// There used to be half as many buckets; mask down one more power of two.
m >>= 1
}
oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
if !evacuated(oldb) {
b = oldb
}
}
// 获得 tophash 即hash 的高八位
top := tophash(hash)
// 如果算出来的桶不为 nil 进入查找一次,查不到就会去找溢出桶,直到 b 为 nil
bucketloop:
for ; b != nil; b = b.overflow(t) {
// 进入桶后,逐个比较 tophash,找到匹配的那一个才往下
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
// 匹配到 tophash 后,通过桶地址、数据起始地址和索引i以及键大小来计算指向键的指针
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
// 比较键是否相等,相等就找到了,计算指向值的指针并返回,
// 注意这里用了bucketCnt,说明一个桶的空间是都分配了的,不管有没有填
// 否则就继续往下匹配 tophash
if t.key.equal(key, k) {
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
if t.indirectelem() {
e = *((*unsafe.Pointer)(e))
}
return e
}
}
}
// 没找到就返回零值
return unsafe.Pointer(&zeroVal[0])
}
4 map 的扩容
map 为什么要扩容呢?
首先就是当可用空间不足时就需要扩容。其次当哈希碰撞比较严重时,很多数据都会落在同一个桶中,那么就会导致越来越多的溢出桶被链接起来。这样的话,查找的时候最坏的情况就是要遍历整个链表,时间复杂度很高,效率很低。而且当删除了很多元素后,可能会导致虽然有很多溢出桶,但是桶中的元素很稀疏。
map扩容的时机:
- 达到最大的负载因子(6.5,也就是平均每个桶中k-v的数量大于6.5)
- 溢出桶的数量太多
扩容发生时机是在插入时判断的,
map扩容的类型:
- 等量扩容:数据不多但是溢出桶太多了(整理)
- 翻倍扩容:数据太多了
等量扩容,溢出桶太多了,导致查询效率低。扩容时,桶的数量不增加。
翻倍扩容,每个桶的k-v太多了,需要增加普通桶的数量,扩容后桶的数量为原来的两倍。
3.1 map扩容的步骤
步骤1:创建新桶
- 创建一组新桶
- oldbuckets指向原有的桶数组
- buckets指向新的桶的数组
- map标记为扩容状态
步骤2:迁移数据 将所有的数据从旧桶驱逐到新桶 采用渐进式驱逐 每次操作一个旧桶时(插入、删除数据),将旧桶数据驱逐到新桶 读取时不进行驱逐,只判断读取新桶还是旧桶
在每次插入或删除数据时,会判断map是否在扩容,如果在扩容就需要执行一些扩容的工作,也就是将旧桶中的数据驱逐到新桶,会驱逐当前要操作的桶及其溢出桶。
假设原来的桶的数量为4,那么B为2。当进行翻倍扩容后,桶的数量为8,B为3。那么这里就体现出了使用B的智慧,扩容后B为3,那么取哈希值的二进制后三位就有两种情况:010b和110b,分别是2和6。所以原来二号桶中的数据就会分布到新桶数组的2号和6号桶中。
步骤3:回收旧桶
- 所有旧桶驱逐完成后,回收oldbuckets
3.2 扩容时访问数据
在map扩容期间访问map需要确定数据是在旧桶还是在新桶中,也就是判断数据是否已经被疏散到新桶中。
相关资料
4. map 的 key 可以是 nil 吗
不可以。
5. 怎么实现一个线程安全的map
- 加锁。对整个map加上读写锁 sync.RWMutex
package main
import (
"sync"
)
type RWMap struct {
Map map[int]int
sync.RWMutex
}
func NewRWMap(capacity int) *RWMap {
if capacity < 0 {
capacity = 0
}
return &RWMap{
Map: make(map[int]int, capacity),
}
}
func (m *RWMap) Set(key, value int) {
m.Lock()
defer m.Lock()
m.Map[key] = value
}
func (m *RWMap) Delete(key int) {
m.Lock()
defer m.Lock()
delete(m.Map, key)
}
func (m *RWMap) Get(key int) int {
m.RLock()
defer m.RUnlock()
return m.Map[key]
}
优点是实现简单,缺点是锁粒度大。
- 分段加锁
- sync.Map