Go map 源码详解【1】—— 创建 map

81 阅读5分钟

本文是针对** go1.24 版本**之前 map 的源码解读。1.24 版本开始,go map 使用了不同的结构swiss map。

我认为直接上来阅读源码的难度十分困难,最好是先看下别的文章中对于 map 的总体讲解。大概了解 map 的数据结构以及原理,再深入阅读源码就会相对轻松一些。

好了,让我们开始吧!

原始代码

package main

func main() {
    _ = make(map[string]string, 10)
}

汇编代码

使用go build -gcflags="-S -l -N" main.go 2> main.s进行编译

在 go 1.17+之后,使用寄存器传参。之前使用的是栈传参

如果不懂汇编代码,可以看克里斯叮大佬的文章juejin.cn/post/731948…

main.main STEXT size=71 args=0x0 locals=0x50 funcid=0x0 align=0x0
    0x0000 00000 (/Users/awesomeProject1/main.go:3)    TEXT   main.main(SB), ABIInternal, $80-0
    0x0000 00000 (/Users/awesomeProject1/main.go:3)    CMPQ   SP, 16(R14)
    0x0004 00004 (/Users/awesomeProject1/main.go:3)    PCDATA $0, $-2
    0x0004 00004 (/Users/awesomeProject1/main.go:3)    JLS    60
    0x0006 00006 (/Users/awesomeProject1/main.go:3)    PCDATA $0, $-1
    0x0006 00006 (/Users/awesomeProject1/main.go:3)    PUSHQ  BP
    0x0007 00007 (/Users/awesomeProject1/main.go:3)    MOVQ   SP, BP
    0x000a 00010 (/Users/awesomeProject1/main.go:3)    SUBQ   $72, SP
    0x000e 00014 (/Users/awesomeProject1/main.go:3)    FUNCDATA   $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
    0x000e 00014 (/Users/awesomeProject1/main.go:3)    FUNCDATA   $1, gclocals·/ydTHfVJHvKeH/UP4dRKSQ==(SB)
    0x000e 00014 (/Users/awesomeProject1/main.go:3)    FUNCDATA   $2, main.main.stkobj(SB)
    0x000e 00014 (/Users/awesomeProject1/main.go:4)    MOVUPS X15,   main..autotmp_0+24(SP) //清零 hmap 的临时空间
    0x0014 00020 (/Users/awesomeProject1/main.go:4)    MOVUPS X15, main..autotmp_0+40(SP)
    0x001a 00026 (/Users/awesomeProject1/main.go:4)    MOVUPS X15, main..autotmp_0+56(SP)
    0x0020 00032 (/Users/awesomeProject1/main.go:4)    LEAQ   type:map[string]string(SB), AX //传入*maptype
    0x0027 00039 (/Users/awesomeProject1/main.go:4)    MOVL   $10, BX //传入 hint
    0x002c 00044 (/Users/awesomeProject1/main.go:4)    LEAQ   main..autotmp_0+24(SP), CX //取栈顶向栈基移动 24 个字节后的地址。传入*hmap,即 map对象占用的内存空间
    0x0031 00049 (/Users/awesomeProject1/main.go:4)    PCDATA $1, $0
    0x0031 00049 (/Users/awesomeProject1/main.go:4)    CALL   runtime.makemap(SB) //调用 makemap 函数
    0x0036 00054 (/Users/awesomeProject1/main.go:5)    ADDQ   $72, SP
    0x003a 00058 (/Users/awesomeProject1/main.go:5)    POPQ   BP
    0x003b 00059 (/Users/awesomeProject1/main.go:5)    RET
    0x003c 00060 (/Users/awesomeProject1/main.go:5)    NOP
    0x003c 00060 (/Users/awesomeProject1/main.go:3)    PCDATA $1, $-1
    0x003c 00060 (/Users/awesomeProject1/main.go:3)    PCDATA $0, $-2
    0x003c 00060 (/Users/awesomeProject1/main.go:3)    NOP
    0x0040 00064 (/Users/awesomeProject1/main.go:3)    CALL   runtime.morestack_noctxt(SB)
    0x0045 00069 (/Users/awesomeProject1/main.go:3)    PCDATA $0, $-1
    0x0045 00069 (/Users/awesomeProject1/main.go:3)    JMP    0

可以看到 map 的创建是调用了 makemap 函数,通过寄存器传入了三个参数,分别是 type:map[string]string、10、以及main..autotmp_0+24(SP)。

makeup源码 makeup 为创建 map 的调用函数,接收 map 类型,容量以及 hmap 指针三个参数。其中 map type 用于获取 map 的 key val 类型,容量用来计算初始的 bucket 数量,hmap为创建 map 的内存地址。

下面是创建map 的步骤

  1. 在传入的 hmap 内存地址上初始化 hmap 结构体
  2. 根据 hint 容量计算出 bucket数量。
  3. 在连续内存中初始化 bucket 数组,在此过程中会在基础的 bucket 之后添加 overflow bucket 用于应对hash 冲突。
  4. 将初始化后的 bucket 数组保存在 hmap 中。
  5. 返回 hmap
func makemap(t *maptype, hint int, h *hmap) *hmap {
    mem, overflow := math.MulUintptr(uintptr(hint), t.Bucket.Size_) //hint 为预估的元素个数,考虑到最坏的情况,每个桶里面仅有一个元素。所以使用 hint*BucktSize 来计算内存是否溢出。
    //如果内存溢出了,直接为设置 hint 为 0,使用懒加载。
    if overflow || mem > maxAlloc {
       hint = 0
    }

    // initialize Hmap
    if h == nil {
       h = new(hmap)
    }
    h.hash0 = uint32(rand())

    // Find the size parameter B which will hold the requested # of elements.
    // For hint < 0 overLoadFactor returns false since hint < bucketCnt.
    B := uint8(0)
    //根据 80% 的负载,每个桶里有 8 个元素。计算出满足当前 hint 容量的桶数量 B
    for overLoadFactor(hint, B) {
       B++
    }
    h.B = B

    // allocate initial hash table
    // if B == 0, the buckets field is allocated lazily later (in mapassign) 懒加载
    // If hint is large zeroing this memory could take a while.
    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
}

makeBucketArray

改函数为创建 bucket 数组函数。bucket 在内存中是连续的,这有利于通过偏移量的快速访问。在极多 key 的情况下可以减少 cpu 内存的数据页置换。(这个问题反而在 go1.24 使用 swiss map 之后会凸显。)

当桶的数量大于等于 4 的时候设置 overflow bucket,其数量为 1<<(bucket 数量-4)。 将最后一个 overflow bucket 桶的 overflow 标志位指向第一个桶,以说明后续没有可用的 overflow bucket 桶。

这段代码中高强度的使用了内存地址运算!细细品味

func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
    base := bucketShift(b) //根据 b 计算桶的数量
    nbuckets := base
    // For small b, overflow buckets are unlikely.
    // Avoid the overhead of the calculation.
    if b >= 4 {
       // Add on the estimated number of overflow buckets
       // required to insert the median number of elements
       // used with this value of b.
       nbuckets += bucketShift(b - 4)
       sz := t.Bucket.Size_ * nbuckets //根据编译时候的 Size 来计算内存空间
       up := roundupsize(sz, !t.Bucket.Pointers())
       if up != sz {
          nbuckets = up / t.Bucket.Size_
       }
    }
    //是否能够有 dirtyalloc可以复用 
    if dirtyalloc == nil {
       buckets = newarray(t.Bucket, int(nbuckets))
    } else {
       // dirtyalloc was previously generated by
       // the above newarray(t.Bucket, int(nbuckets))
       // but may not be empty.
       buckets = dirtyalloc
       size := t.Bucket.Size_ * nbuckets
       if t.Bucket.Pointers() {
          memclrHasPointers(buckets, size)
       } else {
          memclrNoHeapPointers(buckets, size)
       }
    }
    //溢出桶设置
    if base != nbuckets {
       // We preallocated some overflow buckets.
       // To keep the overhead of tracking these overflow buckets to a minimum,
       // we use the convention that if a preallocated overflow bucket's overflow
       // pointer is nil, then there are more available by bumping the pointer.
       // We need a safe non-nil pointer for the last overflow bucket; just use buckets.
       //将最后一个溢出桶的overflow设置为第一个桶,
       //如果桶的 overflow 为 nil,可以直接移动指针访问下一个桶。当非 nil 的时候说明已经在最后一个溢出桶了。
       nextOverflow = (*bmap)(add(buckets, base*uintptr(t.BucketSize)))
       last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.BucketSize)))
       last.setoverflow(t, (*bmap)(buckets))
    }
    return buckets, nextOverflow
}

bmap

我一开始看 bmap 的时候有些疑问,为什么 bmap 里面只有 tophash 它的 key 和 val 存放在了哪里?

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 [abi.MapBucketCount]uint8
    // Followed by bucketCnt keys and then bucketCnt elems.
    // NOTE: packing all the keys together and then all the elems together makes the
    // code a bit more complicated than alternating key/elem/key/elem/... but it allows
    // us to eliminate padding which would be needed for, e.g., map[int64]int8.
    // Followed by an overflow pointer.
}

实际的bmap 结构如下所示(TODO 添加图)

编译的时候 go 会根据存储的类型计算 bucketSize 的大小,可以看到只有 tophash 的内存大小是固定的。存储的 key与 val 的内存大小会随着其类型变化。

这里真的很巧妙,利用 bucketSize 以及指针就实现所有 map 类型。(这就是泛型吗?)

bucketSize = unsafe.Sizeof(bmap{}) + // tophash 大小 
             bucketCnt * keySize + // 所有键的大小 
             bucketCnt * valueSize + // 所有值的大小 
             unsafe.Sizeof(uintptr(0)) // overflow 指针大小

反思

既然已经存储了 key,为什么需要hashtop?

8 位的比较肯定比字符串的更快。
hashtop 中还可以存放一些约定好的状态,可以不遍历key 内存空间。获知 bucket 的状态。(可以看第二张map 的插入)