Go map 完全指南(一)

104 阅读19分钟

b146c2c86276ad71e35869515360aa75.png

前言

go的map是一个高频使用的数据结构,了解其底层原理不管对面试还是工程上使用都很有帮助

本文是第一篇,介绍map的数据结构初始化逻辑

未来第二篇将介绍map的删除for range遍历扩容的相关的流程

本文使用源码版本:go1.21


数据结构

go的map是一个runtime.hmap结构

image.png

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 个 bucket
    • B = 3 -> 2^3 = 8 个 buckets
    • B = 10 -> 2^10 = 1024 个 buckets
  • hash0:随机生成的哈希种子,用于打乱 key 的哈希值
    • 每次new一个map, hash0 都是随机的,使得 key 的分布随机化,增强安全性。避免攻击者构造大量哈希值相同的 key,使 map 退化为链表,导致性能急剧下降(从O(1) 退化成 O(n)
  • 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

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 的特殊状态。后文遇到时再详细介绍

image.png


虽然 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的设计有以下优势:

gojava
内存分配开销低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 lineNode 是独立分配的对象,内存不连续
遍历链表时,每次访问 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)) 分配 nbucketsbmap 的连续内存。

如果预分配了溢出桶,设置预分配溢出桶链表

// 如果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方法:

  1. 如果 h == nil(例如未初始化的 map)或 h.count == 0(空 map),直接返回 (零值指针, false)
if h == nil || h.count == 0 {
   // ...
   return unsafe.Pointer(&zeroVal[0]), false
}

因此: 即使读一个未初始化的ma也不会panic,而是返回 (零值, false)


  1. 并发检测:如果h.flags 设置了 hashWriting 标志位(写的时候会设置),这里又在读,表示产生了并发读写。则触发fatal,程序崩溃

至于为什么检测到并发时选择fatal而不是panic,在下文介绍写流程时详细分析

if h.flags&hashWriting != 0 {
   fatal("concurrent map read and map write")
}

  1. 根据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)))

  1. 处理扩容过程中,要根据是否迁移,决定从老桶还是新桶读

关于扩容期间对读写流程的影响,下一篇文章详细分析

// 如果正在扩容过程中
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
   }
}

  1. 遍历循环

    1. 外层循环:遍历 bucket 链表(主 bucket + 溢出 bucket)
    2. 内层循环:遍历当前 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
          }
       }
    }

  1. 未找到,返回零值
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方法:

  1. 向nil map写,直接panic
if h == nil {
   panic(plainError("assignment to entry in nil map"))
}

  1. 检测到并发写,触发fatal
if h.flags&hashWriting != 0 {
   fatal("concurrent map writes")
}

  1. 计算key的hash值,设置写标志位
// 计算key的hash值
hash := t.Hasher(key, uintptr(h.hash0))

// 设置写标志位 hashWriting
h.flags ^= hashWriting

  1. 如果正在扩容,确保当前key对应的老桶已完成迁移。并额外迁移一个别的桶
// 计算 key 应该落入的 bucket 索引
bucket := hash & bucketMask(h.B)
// 判断 map 是否正在扩容(oldbuckets != nil)
if h.growing() {
   // 在插入前,先迁移 bucket 和其对应的旧 bucket 中的部分数据
   // 这是 渐进式扩容(incremental growing) 的体现
   // 每次写操作都顺带迁移一点数据,避免一次性迁移导致停顿
   growWork(t, h, bucket)
}

  1. 两层for循环,计算应该在哪个位置更新或插入value
    1. 第一层for:遍历桶和溢出桶
    2. 第二层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
    }

  1. 如果是新增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))
}

  1. 写入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.Mutexsync.RWMutexsync.Map 来解决并发问题