前言
go的map是一个高频使用的数据结构,了解其底层原理不管对面试还是工程上使用都很有帮助
本文是第一篇,介绍map的数据结构,初始化,读,写逻辑
未来第二篇将介绍map的删除,for range遍历,扩容的相关的流程
本文使用源码版本:go1.21
数据结构
go的map是一个runtime.hmap结构
type hmap struct {
count int
flags uint8
B uint8
noverflow uint16
hash0 uint32
buckets unsafe.Pointer
oldbuckets unsafe.Pointer
nevacuate uintptr
clearSeq uint64
extra *mapextra
}
- count:当前 map 中有效键值对的数量
- flags:用于记录 map 的当前状态,主要用于避免并发读写
- B:哈希桶数量的对数,表示当前哈希表有
2^B个 主桶(buckets)。例如:B = 0-> 2^0 = 1 个 bucketB = 3-> 2^3 = 8 个 bucketsB = 10-> 2^10 = 1024 个 buckets
- hash0:随机生成的哈希种子,用于打乱 key 的哈希值
- 每次new一个map,
hash0都是随机的,使得 key 的分布随机化,增强安全性。避免攻击者构造大量哈希值相同的 key,使 map 退化为链表,导致性能急剧下降(从O(1)退化成O(n))
- 每次new一个map,
- buckets:指向hash数组
- oldbuckets:指向旧桶数组指针,仅在扩容过程中为非nil
- nevacuate:扩容迁移进度计数器
- 记录已经从
oldbuckets迁移到buckets的 bucket 数量 - 例如:
nevacuate = 5表示前 5 个 old bucket 已迁移完成
- 记录已经从
extra字段指向mapextra结构:
type mapextra struct {
overflow *[]*bmap
// ...
}
overflow:指向预分配的溢出桶链表- 作用是提升性能:预分配 overflow buckets,避免每次hash冲突时都
malloc
- 作用是提升性能:预分配 overflow buckets,避免每次hash冲突时都
hmap的buckets字段指向了hash桶数组,数组里放着一个个桶,类型为bmap
bmap在源码中的定义为:
type bmap struct {
tophash [8]uint8
}
-
tophash:存储该 bucket 中每个key哈希值的高 8 位
-
作用:提升查询性能。在查找 key 时,先比较
tophash,快速过滤不匹配的 slot,避免每次都要调用key.equal函数 -
特殊用途:当
tophash[0] < 4时,tophash[0]不再表示哈希值,而是表示 bucket 的特殊状态。后文遇到时再详细介绍
-
虽然 bmap 只显式定义了 tophash,但在其后的内存中连续排列着以下数据
type bmap struct {
tophash [8]uint8
[8]key
[8]value
overflow *bmap
}
为什么不在源码层面就定义好后面这些字段?
因为Go的struct 必须是编译时确定大小,而在源代码层面,不知道用户的K和V是什么类型,也就无法在源码层面确定大小
于是只能通过隐式内存布局来存储类型无关的KV数据
先介绍overflow字段:当某个bucket的8个slot都被占用,再往该bucket插入新key时
- 就会分配一个溢出桶,新key放到溢出桶中
- 原来的bucket通过overflow字段关联新建的溢出桶。多个溢出桶形成链表:
b0.overflow -> b1, b1.overflow -> b2, b2.overflow -> nil
KV聚合存储
bmap中先存储8个key,再存储8个value
为啥不采用key/elem/key/elem/... 交替存储,而是先存8个key,再存8个value?
这是为了内存对齐优化:避免因内存对齐产生大量 padding
例如,对于map[int64]int8来说
- 如果采用交替存储:每个
int8后要 padding 7 字节对齐,浪费严重
+--------+--------+--------+--------+
| tophash[8] | 8 字节
+--------+--------+--------+--------+
| key0 | | 8 字节
| elem0 | pad[7] | 8 字节(1 + 7 padding)
| key1 | | 8 字节
| elem1 | pad[7] | 8 字节
- 如果采用聚合存储:所有key连续存储,所有value连存储,无 padding
+--------+--------+--------+--------+
| tophash[8] | 8 字节
+--------+--------+--------+--------+
| key0 | | 8 字节
| key1 | | 8 字节
| ... | |
| key7 | | 8 字节
+--------+--------+--------+--------+
| elem0 | elem1 ... elem7 | 8 字节(1*8 + 0 padding)
+--------+--------+--------+--------+
| overflow | 8 字节
+--------+--------+--------+--------+
同时对cpu cache友好:
- 交替存储:每次访问
key[i],都要跳过value[i]。如果value很大,key[i+1]和key[i]就不在同一个 cache line,导致大量cache miss - 聚合存储:查找 key 时,
tophash和前面的key都在连续内存中,大多数场景下能加载进有限几个cpu cache line中。即使 value 很大,也不影响 key 的查找性能
tophash设计
bmap设计了 tophash[8] 这个数组,用来存储每个key的哈希值的高8位
其核心作用是:快速过滤不匹配的key,避免频繁调用昂贵的 key.Equal 比较函数
-
tophash是整数比较,速度非常快 -
而
key.Equal,可能是字符串比较等昂贵操作,速度较慢
tophash的取值中,0~4为保留位,用于特殊状态。于是如果计算出来某个key的tophash小于5,就会加上5,强制避免正常的hash值和状态码冲突
具体是哪些保留值,用于什么场景,下面遇到时再介绍
func tophash(hash uintptr) uint8 {
// 计算高8位
top := uint8(hash >> (goarch.PtrSize*8 - 8))
// minTopHash=5
if top < minTopHash {
top += minTopHash
}
return top
}
hash冲突的解决方案
当出现hash冲突时,Go 选择桶内连续存储 + 溢出桶,而 Java 选择链表 /红黑树
- go中,每个 bucket 最多存 8 个KV对,当某个 bucket 满了,会分配一个 溢出桶,通过
overflow指针连接 - java中,每个 bucket 存一个
Node链表,冲突时直接插入链表,链表过长(默认 >8)且 table 大小 >64 时,转为红黑树
go的设计有以下优势:
| go | java | |
|---|---|---|
| 内存分配开销低 | 8个KV 存在一个 bmap 中紧凑存储并且通过所有 keys 连续,所有 values 连续,减少padding的浪费 | 每个Node都是一个指针,包含key,value,和next指针 同样是8个KV,至少比go多了8个next指针的内存开销 |
| 内存分配效率高 | 每8个KV分配一个bmap桶,内存分配频率低 | 每个Node都要分配一次内存 |
| 对cache友好 | 查找时,8 个 tophash 和key,只有有限一个cpu cache line | Node 是独立分配的对象,内存不连续遍历链表时,每次访问 next 指针都可能触发 cache miss |
为什么是每个桶8对KV
为什么每个桶是8对KV,而不是4或16?
如果是4,也就是越靠近java的设计,上面论述的相对于java的优势就会减少
那如果每个桶的KV更多呢?我们举比较极端的例子,假设每个桶存储64对KV,看会带来哪些负面
| 影响 | 每个桶8对KV | 每个桶64对KV |
|---|---|---|
| 内存浪费 | 假设平均每个 bucket 有 6.5 个元素,空间利用率 = 6.5 / 8 = 81.25% | 平假设均每个 bucket 有 6.5 个元素,空间利用率 = 6.5 / 64 = 10.1% 且 负载因子越低,内存浪费越大 |
| 查询效率低 | 查询不存在的KV,每个桶最多比较8次 | 即使只存储了1,2对KV,当查询不存在的key时,也需要比较所有的tophash,也就是比较 64次 |
| cpucache miss | 假设key是string,tophash和key连续存储。大概占用2个cacheline | 假设key是string,tophash和key连续存储,大概占用10个cacheline导致在一个桶内查询时,cachemiss率暴增 |
重点分析下cpu cache miss的问题
大家可能会觉得,假设要存储64对KV,每个bmap最多存8对KV的话,会链接7个溢出桶来存。往后查溢出桶也有开销,不比bmap存64对KV的cachemiss开销小
现实情况是,当bmap最多存8对KV时,根本没有后面链接7个溢出桶的机会,因为负载因子超过6.5就会扩容,进而降低每个bmap的KV对数量
而bmap最多存储64个对KV时,相应的触发扩容的负载因子阈值肯定也要提升,就会造成查询效率低,和cpu cache miss率高的问题
初始化
map初始化代码
m := make(map[string]string, 20)
会被编译成如下汇编:
// map的type类型
00032 LEAQ type:map[string]string(SB), AX
// BX = 20
00039 MOVL $20, BX
// SP+32存储hmap结构
00044 LEAQ main..autotmp_1+32(SP), CX
00049 CALL runtime.makemap(SB)
主要是将20作为容量参数,调用runtime.makemap方法:
func makemap(t *maptype, hint int, h *hmap) *hmap {
if h == nil {
h = new(hmap)
}
// 随机生成的哈希种子,用于打乱key的哈希值,防止哈希碰撞攻击
h.hash0 = uint32(rand())
// 缺点B的值
B := uint8(0)
for overLoadFactor(hint, B) {
B++
}
h.B = B
if h.B != 0 {
var nextOverflow *bmap
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
下面拆解这个map初始化方法
确定B的值
确定B的值,也就是桶数量的对数:
具体做法:循环递增B,直到 2^B 个 bucket 能容纳 hint 个元素而不超负载因子6.5为止
B := uint8(0)
for overLoadFactor(hint, B) {
B++
}
h.B = B
overLoadFactor:判断总量count,在2^B个桶下,是否超过负载因子
也就是count是否大于6.5倍的2^B
也就是每个桶平均是否装了超过6.5对KV
func overLoadFactor(count int, B uint8) bool {
return count > 8 && uintptr(count) > 6.5* (1 << B)
}
分配桶数组
if h.B != 0 {
var nextOverflow *bmap
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
if nextOverflow != nil {
h.extra = new(mapextra)
// 存储预分配的溢出桶到 h.extra.nextOverflow
h.extra.nextOverflow = nextOverflow
}
}
分配桶数组方法如下:
func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
// 计算桶数量 = 1 << b
base := bucketShift(b)
nbuckets := base
// 预分配一些溢出桶
// 如果b < 4,主桶数较少(最多 8 个),溢出概率低,无需预分配
if b >= 4 {
// 预分配桶的数量:1 << (b - 4)
nbuckets += bucketShift(b - 4)
sz := t.Bucket.Size_ * nbuckets
up := roundupsize(sz, !t.Bucket.Pointers())
if up != sz {
nbuckets = up / t.Bucket.Size_
}
}
if dirtyalloc == nil {
buckets = newarray(t.Bucket, int(nbuckets))
} else {
// ...
}
// 设置预分配溢出桶链表
if base != nbuckets {
nextOverflow = (*bmap)(add(buckets, base*uintptr(t.BucketSize)))
// ...
}
return buckets, nextOverflow
}
如果b >= 4,预分配一些溢出桶,以减少后续 mapassign 时的内存分配开销
潜台词是:如果b < 4,主桶数较少(最多 8 个),认为溢出概率低,无需预分配
然后做一些内存对齐
// 预分配一些溢出桶
// 如果b < 4,主桶数较少(最多 8 个),溢出概率低,无需预分配
if b >= 4 {
// 预分配桶的数量:1 << (b - 4)
nbuckets += bucketShift(b - 4)
sz := t.Bucket.Size_ * nbuckets
up := roundupsize(sz, !t.Bucket.Pointers())
if up != sz {
nbuckets = up / t.Bucket.Size_
}
}
具体分配桶数组:
buckets = newarray(t.Bucket, int(nbuckets))
- 调用
newarray(t.Bucket, int(nbuckets))分配nbuckets个bmap的连续内存。
如果预分配了溢出桶,设置预分配溢出桶链表
// 如果base != nbuckets 表示预分配了溢出桶
if base != nbuckets {
// nextOverflow:指向第一个预分配的溢出桶
nextOverflow = (*bmap)(add(buckets, base*uintptr(t.BucketSize)))
// ...
}
// 返回主桶,溢出桶
return buckets, nextOverflow
回到makemap方法,将makeBucketArray返回的桶赋值给h.buckets
func makemap(t *maptype, hint int, h *hmap) *hmap {
// ...
if h.B != 0 {
var nextOverflow *bmap
// 使用分配的桶数组
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
if nextOverflow != nil {
// 如果预分配了溢出桶,将其存入extra.nextOverflow里
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
读
map的读有两种形式
v := m[k]
会被编译成runtime.mapaccess1方法
v, ok := m[k]
会被编译成runtime.mapaccess2方法
mapaccess1和mapaccess2在逻辑上几乎一致,唯一的区别是,mapaccess2额外返回一个bool代表key是否存在
我们以mapaccess2来分析map的读方法:
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool) {
if h == nil || h.count == 0 {
if err := mapKeyError(t, key); err != nil {
panic(err) // see issue 23734
}
return unsafe.Pointer(&zeroVal[0]), false
}
if h.flags&hashWriting != 0 {
fatal("concurrent map read and map write")
}
// 计算key的hash值
hash := t.Hasher(key, uintptr(h.hash0))
// 计算掩码 m = (1 << h.B) - 1
m := bucketMask(h.B)
// 定位到要查找的bucket
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.BucketSize)))
// 如果正在扩容过程中
if c := h.oldbuckets; c != nil {
if !h.sameSizeGrow() {
m >>= 1
}
// 定位到旧bucket
oldb := (*bmap)(add(c, (hash&m)*uintptr(t.BucketSize)))
// 判断该旧 bucket 是否已被迁移。
if !evacuated(oldb) {
// 如果 未迁移,则优先在 oldb 中查找(因为数据还在旧 bucket 中)
b = oldb
}
}
// 提取哈希值的高 8 位
top := tophash(hash)
bucketloop:
// 外层循环:遍历 bucket 链表(主 bucket + 溢出 bucket)
for ; b != nil; b = b.overflow(t) {
// 内层循环:遍历当前 bucket 的 8 个 slot
for i := uintptr(0); i < abi.OldMapBucketCount; i++ {
// 先比较tophash,如果不匹配,跳过
if b.tophash[i] != top {
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
if t.IndirectKey() {
k = *((*unsafe.Pointer)(k))
}
if t.Key.Equal(key, k) {
e := add(unsafe.Pointer(b), dataOffset+abi.OldMapBucketCount*uintptr(t.KeySize)+i*uintptr(t.ValueSize))
if t.IndirectElem() {
e = *((*unsafe.Pointer)(e))
}
return e, true
}
}
}
return unsafe.Pointer(&zeroVal[0]), false
}
接下来拆解mapaccess2方法:
- 如果
h == nil(例如未初始化的 map)或h.count == 0(空 map),直接返回(零值指针, false)
if h == nil || h.count == 0 {
// ...
return unsafe.Pointer(&zeroVal[0]), false
}
因此: 即使读一个未初始化的ma也不会panic,而是返回 (零值, false)
- 并发检测:如果
h.flags设置了hashWriting标志位(写的时候会设置),这里又在读,表示产生了并发读写。则触发fatal,程序崩溃
至于为什么检测到并发时选择fatal而不是panic,在下文介绍写流程时详细分析
if h.flags&hashWriting != 0 {
fatal("concurrent map read and map write")
}
- 根据key的hash值和桶的size,定位到要查找的桶
// 计算key的hash值
hash := t.Hasher(key, uintptr(h.hash0))
// 计算掩码 m = (1 << h.B) - 1
m := bucketMask(h.B)
// 定位到要查找的bucket
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.BucketSize)))
- 处理扩容过程中,要根据是否迁移,决定从老桶还是新桶读
关于扩容期间对读写流程的影响,下一篇文章详细分析
// 如果正在扩容过程中
if c := h.oldbuckets; c != nil {
if !h.sameSizeGrow() {
// 如果不是等量扩容,老数组的size是新数组size的一半
m >>= 1
}
// 定位到旧bucket
oldb := (*bmap)(add(c, (hash&m)*uintptr(t.BucketSize)))
// 判断该旧 bucket 是否已被迁移。
if !evacuated(oldb) {
// 如果 未迁移,则在 oldb 中查找(因为数据还在旧 bucket 中)
b = oldb
}
}
-
遍历循环
- 外层循环:遍历 bucket 链表(主 bucket + 溢出 bucket)
- 内层循环:遍历当前 bucket 的 8 个 slot
// 提取哈希值的高 8 位
top := tophash(hash)
bucketloop:
// 外层循环:遍历 bucket 链表(主 bucket + 溢出 bucket)
for ; b != nil; b = b.overflow(t) {
// 内层循环:遍历当前 bucket 的 8 个 slot
for i := uintptr(0); i < abi.OldMapBucketCount; i++ {
// 先比较tophash,如果不匹配,跳过
if b.tophash[i] != top {
// if b.tophash[i] == emptyRest:emptyRest 表示从此索引开始,后续所有 slot 都为空
// 遇到 emptyRest 可直接跳出整个查找循环(break bucketloop),无需继续。
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
// 计算 key 在内存中的地址
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
// 判断 key 是否是间接存储(大 key 存指针,小 key 直接存值)
if t.IndirectKey() {
k = *((*unsafe.Pointer)(k))
}
// 调用 key 类型的相等比较函数
if t.Key.Equal(key, k) {
// 若 key 相等,说明找到了,则构造 value 指针 e,返回
e := add(unsafe.Pointer(b), dataOffset+abi.OldMapBucketCount*uintptr(t.KeySize)+i*uintptr(t.ValueSize))
if t.IndirectElem() {
e = *((*unsafe.Pointer)(e))
}
return e, true
}
}
}
- 未找到,返回零值
return unsafe.Pointer(&zeroVal[0]), false
写
对map的写操作:
m[k] = v
会被编译成对runtime.mapassign方法的调用
方法签名为:
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
- 参数:
t *maptype:map 的类型信息h *hmap:map 的底层结构体key unsafe.Pointer:指向要插入/更新的 key
- 返回值:
unsafe.Pointer:指向 value 存储位置的指针,调用者将通过此指针写入 value
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// 向 nil map 赋值会直接 panic
if h == nil {
panic(plainError("assignment to entry in nil map"))
}
// 如果检测到写写并发,触发fatal
if h.flags&hashWriting != 0 {
fatal("concurrent map writes")
}
// 计算key的hash值
hash := t.Hasher(key, uintptr(h.hash0))
// 设置写标志位 hashWriting
h.flags ^= hashWriting
// ...
again:
// 计算 key 应该落入的 bucket 索引
bucket := hash & bucketMask(h.B)
// 判断 map 是否正在扩容(oldbuckets != nil)
if h.growing() {
// 在插入前,先迁移 bucket 和其对应的旧 bucket 中的部分数据
// 这是 渐进式扩容(incremental growing) 的体现
// 每次写操作都顺带迁移一点数据,避免一次性迁移导致停顿
growWork(t, h, bucket)
}
// b:指向目标bucket
b := (*bmap)(add(h.buckets, bucket*uintptr(t.BucketSize)))
// 计算tophash
top := tophash(hash)
// 将要插入的 tophash[i] 的地址
var inserti *uint8
// 将要插入的 key 的地址
var insertk unsafe.Pointer
// 将要插入的 value 的地址
var elem unsafe.Pointer
bucketloop:
for {
// 遍历当前 bucket 的 8 个 slot
for i := uintptr(0); i < 8; i++ {
// 先比较 tophash,不匹配则跳过
if b.tophash[i] != top {
// 若 tophash 为空(isEmpty)且 inserti 未设置,则记录第一个可用 slot(用于插入新 key)
if isEmpty(b.tophash[i]) && inserti == nil {
inserti = &b.tophash[i]
insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
elem = add(unsafe.Pointer(b), dataOffset+ 8 *uintptr(t.KeySize)+i*uintptr(t.ValueSize))
}
// 若 tophash == emptyRest,表示后续全空,跳出查找
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
// 到这里说明tophash 匹配
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
if t.IndirectKey() {
k = *((*unsafe.Pointer)(k))
}
// 检查 key 是否相等
if !t.Key.Equal(key, k) {
continue
}
// 到这里说明同tophash匹配,key也匹配
// 需要更新value
if t.NeedKeyUpdate() {
typedmemmove(t.Key, k, key)
}
elem = add(unsafe.Pointer(b), dataOffset+abi.OldMapBucketCount*uintptr(t.KeySize)+i*uintptr(t.ValueSize))
goto done
}
// 遍历溢出桶
ovf := b.overflow(t)
if ovf == nil {
break
}
b = ovf
}
// 判断是否需要扩容
// 当前不在扩容中,且(插入后元素数 h.count+1 超过负载因子 或 溢出桶过多)
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
// 触发开始扩容
hashGrow(t, h)
// 要扩容的话,前面计算的各种变量都失效了,需要重试
goto again
}
// 如果 inserti == nil,说明当前 bucket 及其所有溢出桶都满了
if inserti == nil {
// 分配一个新的 bmap 作为溢出桶
newb := h.newoverflow(t, b)
// 在新桶的第一个 slot 插入
inserti = &newb.tophash[0]
insertk = add(unsafe.Pointer(newb), dataOffset)
elem = add(insertk, abi.OldMapBucketCount*uintptr(t.KeySize))
}
// 处理间接存储
if t.IndirectKey() {
kmem := newobject(t.Key)
*(*unsafe.Pointer)(insertk) = kmem
insertk = kmem
}
if t.IndirectElem() {
vmem := newobject(t.Elem)
*(*unsafe.Pointer)(elem) = vmem
}
// 写入 key
typedmemmove(t.Key, insertk, key)
// 写入tophash
*inserti = top
// 元素计数 +1
h.count++
done:
if h.flags&hashWriting == 0 {
fatal("concurrent map writes")
}
// 清除 hashWriting 标志位
h.flags &^= hashWriting
// 如果 value 是间接存储,elem 指向的是指针,需要解引用一次,返回指向 value 本身的指针
if t.IndirectElem() {
elem = *((*unsafe.Pointer)(elem))
}
// 返回 elem,调用者将通过此指针写入 value
return elem
}
下面拆解mapassign方法:
- 向nil map写,直接panic
if h == nil {
panic(plainError("assignment to entry in nil map"))
}
- 检测到并发写,触发fatal
if h.flags&hashWriting != 0 {
fatal("concurrent map writes")
}
- 计算key的hash值,设置写标志位
// 计算key的hash值
hash := t.Hasher(key, uintptr(h.hash0))
// 设置写标志位 hashWriting
h.flags ^= hashWriting
- 如果正在扩容,确保当前key对应的老桶已完成迁移。并额外迁移一个别的桶
// 计算 key 应该落入的 bucket 索引
bucket := hash & bucketMask(h.B)
// 判断 map 是否正在扩容(oldbuckets != nil)
if h.growing() {
// 在插入前,先迁移 bucket 和其对应的旧 bucket 中的部分数据
// 这是 渐进式扩容(incremental growing) 的体现
// 每次写操作都顺带迁移一点数据,避免一次性迁移导致停顿
growWork(t, h, bucket)
}
- 两层for循环,计算应该在哪个位置更新或插入value
- 第一层for:遍历桶和溢出桶
- 第二层for:遍历桶内部的8个slot
bucketloop:
for {
// 遍历当前 bucket 的 8 个 slot
for i := uintptr(0); i < 8; i++ {
// 先比较 tophash,不匹配则跳过
if b.tophash[i] != top {
// 若 tophash 为空(isEmpty)且 inserti 未设置,则记录第一个可用 slot(用于插入新 key)
if isEmpty(b.tophash[i]) && inserti == nil {
inserti = &b.tophash[i]
insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
elem = add(unsafe.Pointer(b), dataOffset+ 8 *uintptr(t.KeySize)+i*uintptr(t.ValueSize))
}
// 若 tophash == emptyRest,表示后续全空,跳出查找
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
// 到这里说明tophash 匹配
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
if t.IndirectKey() {
k = *((*unsafe.Pointer)(k))
}
// 检查 key 是否相等
if !t.Key.Equal(key, k) {
continue
}
// 到这里说明同tophash匹配,key也匹配
// 需要更新value
if t.NeedKeyUpdate() {
typedmemmove(t.Key, k, key)
}
elem = add(unsafe.Pointer(b), dataOffset+abi.OldMapBucketCount*uintptr(t.KeySize)+i*uintptr(t.ValueSize))
goto done
}
// 遍历溢出桶
ovf := b.overflow(t)
if ovf == nil {
break
}
b = ovf
}
- 如果是新增KV,需要判断是否需要触发扩容。并且当桶都装满时,需要分配溢出桶
// 判断是否需要扩容
// 当前不在扩容中,且(插入后元素数 h.count+1 超过负载因子 或 溢出桶过多)
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
// 触发开始扩容
hashGrow(t, h)
// 要扩容的话,前面计算的各种变量都失效了,需要重试
goto again
}
// 如果 inserti == nil,说明当前 bucket 及其所有溢出桶都满了
if inserti == nil {
// 分配一个新的 bmap 作为溢出桶
newb := h.newoverflow(t, b)
// 在新桶的第一个 slot 插入
inserti = &newb.tophash[0]
insertk = add(unsafe.Pointer(newb), dataOffset)
elem = add(insertk, abi.OldMapBucketCount*uintptr(t.KeySize))
}
- 写入key,tophash,更新map的count计数,清除写标志位。返回value的地址由调用者写入value
// 写入 key
typedmemmove(t.Key, insertk, key)
// 写入tophash
*inserti = top
// 元素计数 +1
h.count++
done:
if h.flags&hashWriting == 0 {
fatal("concurrent map writes")
}
// 清除 hashWriting 标志位
h.flags &^= hashWriting
// 如果 value 是间接存储,elem 指向的是指针,需要解引用一次,返回指向 value 本身的指针
if t.IndirectElem() {
elem = *((*unsafe.Pointer)(elem))
}
// 返回 elem,调用者将通过此指针写入 value
return elem
间接存储
map 在存储 key 和 value 时,会根据其大小决定是直接存储还是间接存储
-
小对象(例如小于128字节):直接存储在桶
bmap结构体内(内联存储) -
大对象(例如大于128字节):在堆上分配内存,
bmap中只存储指向该内存的指针
为啥大对象要间接存储呢?
主要是减少内存拷贝的开销:当需要移动KV对时,例如如扩容、迁移 bucket:
-
如果大对象直接存储于bucket中,每次迁移都要拷贝整个大对象,开销较大
-
而间接存储只需拷贝指针(8 字节),实际数据在堆上不动,迁移成本极低。
间接存储也不是没有开销:读的时候需要多一次解引用才能找到具体的值,因为bucket中只存了指针
也就是用一点解引用的代价,减少内存拷贝的开销
并发检测
上面介绍的读写流程中,当检测到读写并发时,直接fatal让程序崩溃
现在问题来了,为啥是fatal而不是panic?
map 的底层结构是非线程安全的,一旦发生并发,状态可能已损坏。其的内部状态就可能进入不可预测状态
Go 的设计哲学是:宁可程序立即崩溃,也不要让它在错误的状态下继续运行
如果不fatal,只panic:
- 用户可能
recover它,然后继续使用这个已经损坏的 map - 后续操作可能返回错误数据,导致业务逻辑出错(如转账金额错误、权限判断失败)。
- 这种错误难以复现、难以调试,可能在生产环境造成灾难性后果
而采用fatal:
-
避免薛定谔的bug:不会出现有时正常,有时错的诡异行为
-
立即暴露问题:开发者在测试或压测时就能发现并发 bug
-
强制用户在写代码时就使用正确的方式:你必须使用
sync.Mutex、sync.RWMutex或sync.Map来解决并发问题