写在之前

在上篇文章《go 基础之 map - 写在前面(一)》介绍了 map 的数据结构,本篇会详细介绍 map 的增和改的代码实现,由于增和改的实现基本上差不多,所以就纳到一起分析了。如果想详细查看源码的注释,可以查看我的GitHub, 欢迎批评指正。我的打算是把一些常用的数据结构都分析一遍,如果有志同道合的人,可以联系我。

环境说明

我的具体调试环境在《go 基础之 map - 写在前面(一)》已经说明的非常仔细了,现在只讲我分析增和改的调试代码。

  1. package main
  2. import (
  3. "fmt"
  4. "strconv"
  5. )
  6. func main() {
  7. m1 := make(map[string]string, 9)
  8. fmt.Println(m1)
  9. for i := 0; i < 20; i++ {
  10. str := strconv.Itoa(i)
  11. m1[str] = str
  12. }
  13. }

老规矩编译一波,看看第 9 行的申明到底干了啥?

  1. go tool compile -N -l -S main.go > main.txt

编译结果有点多,我只列举重点代码:

  1. "".main STEXT size=394 args=0x0 locals=0x98
  2. 0x0000 00000 (main.go:8) TEXT "".main(SB), ABIInternal, $152-0
  3. ....
  4. 0x0036 00054 (main.go:9) LEAQ type.map[string]string(SB), AX
  5. 0x003d 00061 (main.go:9) PCDATA $0, $0
  6. 0x003d 00061 (main.go:9) MOVQ AX, (SP)
  7. 0x0041 00065 (main.go:9) MOVQ $9, 8(SP)
  8. 0x004a 00074 (main.go:9) MOVQ $0, 16(SP)
  9. 0x0053 00083 (main.go:9) CALL runtime.makemap(SB)
  10. ....
  11. 0x0107 00263 (main.go:13) PCDATA $0, $5
  12. 0x0107 00263 (main.go:13) LEAQ type.map[string]string(SB), DX
  13. 0x010e 00270 (main.go:13) PCDATA $0, $4
  14. 0x010e 00270 (main.go:13) MOVQ DX, (SP)
  15. 0x0112 00274 (main.go:13) PCDATA $0, $6
  16. 0x0112 00274 (main.go:13) MOVQ "".m1+56(SP), BX
  17. 0x0117 00279 (main.go:13) PCDATA $0, $4
  18. 0x0117 00279 (main.go:13) MOVQ BX, 8(SP)
  19. 0x011c 00284 (main.go:13) PCDATA $0, $0
  20. 0x011c 00284 (main.go:13) MOVQ CX, 16(SP)
  21. 0x0121 00289 (main.go:13) MOVQ AX, 24(SP)
  22. 0x0126 00294 (main.go:13) CALL runtime.mapassign_faststr(SB)
  23. ....
  • 第 9 行调用了runtime.makemap方法做一些初始化操作,我把 map 的初始容量设为大于 8 底层才会走该方法,否则会调用runtime.makemap_small 方法。
  • 第 22 行调用了runtime.mapassign_faststr 方法,该方法对应main.go第 13 行的赋值方法m1[str] = str

我们找到了方法,在后面就可以在$go_sdk_path/src/runtime/map.go$go_sdk_path/src/runtime/map_faststr.go
找到方法,然后断点调试即可。

makemap_small 和 makemap 的区别

makemap_small的代码如下:

  1. func makemap_small() *hmap {
  2. h := new(hmap)
  3. h.hash0 = fastrand()
  4. return h
  5. }

该代码的实现十分简单,就设置了一个 hash 种子,其他的例如申通桶内存的操作只有在真正赋值数据的时候才会创建桶。该方法在什么情况会被调用呢?如注释说说 “hint is known to be at most bucketCnt at compile time and the map needs to be allocated on the heap”,bucketCnt 就是 8 个,所以上面我的示例代码为何要设初始容量为 9 的原因就在这里。
我就直接略过这种情况,因为在实际应用场景下还是要指定容量, 避免后面因为频繁扩容造成性能损失,makemap的代码如下:

  1. func makemap(t *maptype, hint int, h *hmap) *hmap {
  2. mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
  3. if overflow || mem > maxAlloc {
  4. hint = 0
  5. }
  6. if h == nil {
  7. h = new(hmap)
  8. }
  9. h.hash0 = fastrand()
  10. B := uint8(0)
  11. for overLoadFactor(hint, B) {
  12. B++
  13. }
  14. h.B = B
  15. if h.B != 0 {
  16. var nextOverflow *bmap
  17. h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
  18. if nextOverflow != nil {
  19. h.extra = new(mapextra)
  20. h.extra.nextOverflow = nextOverflow
  21. }
  22. }
  23. return h
  24. }

看程序注释大概明白该代码的作用就是得到 B 值和申请桶,overLoadFactor方法是用了 6.5 的扩容因子去计算出最大的 B 值,保证你申请的容量 count 要大于 (1>> B) * 6.5, 这个扩容因子想必大家都不陌生,在 java 中是 0.75,为什么在 go 中是 0.65 呢?在runtime/map.go开头处有测试数据,综合考虑来说选择了 6.5。大家可能注意到maptype用来申请桶的内存块了,下面看看maptype的代码, 也有助于理解 map 的结构:

  1. type maptype struct {
  2. typ _type
  3. key *_type
  4. elem *_type
  5. bucket *_type
  6. hasher func(unsafe.Pointer, uintptr) uintptr
  7. keysize uint8
  8. elemsize uint8
  9. bucketsize uint16
  10. flags uint32
  11. }

makemap方法里面math.MulUintptr(uintptr(hint), t.bucket.size)用到了 bucket 的 size,这里这个 size 和maptype的 bucketsize 一模一样都是 272(《go 基础之 map - 写在前面(一)》有介绍为什么是 272),所以就能计算出需要分配的内存。仔细分析makemap的字段,可以发现定义了 map 的基本数据结构,后面代码用来申请桶的内存块的时候都使用了这个数据结构。
makemap第 36 行代码调用了方法makeBucketArray方法来申请内存,我们简单看看它里面的细节:

  1. func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
  2. base := bucketShift(b)
  3. nbuckets := base
  4. if b >= 4 {
  5. nbuckets += bucketShift(b - 4)
  6. sz := t.bucket.size * nbuckets
  7. up := roundupsize(sz)
  8. if up != sz {
  9. nbuckets = up / t.bucket.size
  10. }
  11. }
  12. if dirtyalloc == nil {
  13. buckets = newarray(t.bucket, int(nbuckets))
  14. b0 := (*dmap)(add(buckets, uintptr(0)*uintptr(t.bucketsize)))
  15. println(b0.debugOverflows)
  16. } else {
  17. buckets = dirtyalloc
  18. size := t.bucket.size * nbuckets
  19. if t.bucket.ptrdata != 0 {
  20. memclrHasPointers(buckets, size)
  21. } else {
  22. memclrNoHeapPointers(buckets, size)
  23. }
  24. }
  25. if base != nbuckets {
  26. nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))
  27. last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
  28. last.setoverflow(t, (*bmap)(buckets))
  29. }
  30. return buckets, nextOverflow
  31. }

注意 12 行处有个优化点,当 B 小于 4 的时候,也就是初始申请 map 的容量的时候的 count <(1>> B) * 6.5 的时候,很大的概率其实不会使用逸出桶。当 B 大于 4 的时候,程序就预估出一个逸出桶的个数,在 26 行处就一并申请总的桶的内存块。第 27 行的代码是在源码中没有的,我只是用来调试代码所用,这个在《go 基础之 map - 写在前面(一)》有介绍这个小技巧。在第 49 行就通过 bucketsize 计算出逸出桶的位置,并且在 51 到 53 行有个技巧,给最后一个桶的溢出桶指针设置了桶的起始地址,这个在后面系列的博客会介绍到为何这么使用。
ok,现在 map 的数据如下:
go基础之map-增和改(二)_shen的博客-CSDN博客 - 图1

只有 2 个桶,而且每个桶的 tophash 值都是默认值 0,由于此时 key 和 value 都为空,故没有展示出来。extra没有值是因为 B 小 4 没有溢出桶导致的。

添加元素(没触发扩容的情况)

m1[str] = str我上面分析了是对应的map_faststr.go里面的mapassign_faststr方法。第一次添加的 key 和 value 都是 string 类型。

  1. func mapassign_faststr(t *maptype, h *hmap, s string) unsafe.Pointer {
  2. if h.flags&hashWriting != 0 {
  3. throw("concurrent map writes")
  4. }
  5. key := stringStructOf(&s)
  6. hash := t.hasher(noescape(unsafe.Pointer(&s)), uintptr(h.hash0))
  7. h.flags ^= hashWriting
  8. if h.buckets == nil {
  9. h.buckets = newobject(t.bucket)
  10. }
  11. again:
  12. mask := bucketMask(h.B)
  13. bucket := hash & mask
  14. if h.growing() {
  15. growWork_faststr(t, h, bucket)
  16. }
  17. b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize)))
  18. top := tophash(hash)
  19. var insertb *bmap
  20. var inserti uintptr
  21. var insertk unsafe.Pointer
  22. bucketloop:
  23. for {
  24. for i := uintptr(0); i < bucketCnt; i++ {
  25. if b.tophash[i] != top {
  26. if isEmpty(b.tophash[i]) && insertb == nil {
  27. insertb = b
  28. inserti = i
  29. }
  30. if b.tophash[i] == emptyRest {
  31. break bucketloop
  32. }
  33. continue
  34. }
  35. k := (*stringStruct)(add(unsafe.Pointer(b), dataOffset+i*2*sys.PtrSize))
  36. if k.len != key.len {
  37. continue
  38. }
  39. if k.str != key.str && !memequal(k.str, key.str, uintptr(key.len)) {
  40. continue
  41. }
  42. inserti = i
  43. insertb = b
  44. goto done
  45. }
  46. ovf := b.overflow(t)
  47. if ovf == nil {
  48. break
  49. }
  50. b = ovf
  51. }
  52. if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
  53. hashGrow(t, h)
  54. goto again
  55. }
  56. if insertb == nil {
  57. insertb = h.newoverflow(t, b)
  58. inserti = 0
  59. }
  60. insertb.tophash[inserti&(bucketCnt-1)] = top
  61. insertk = add(unsafe.Pointer(insertb), dataOffset+inserti*2*sys.PtrSize)
  62. *((*stringStruct)(insertk)) = *key
  63. h.count++
  64. done:
  65. elem := add(unsafe.Pointer(insertb), dataOffset+bucketCnt*2*sys.PtrSize+inserti*uintptr(t.elemsize))
  66. if h.flags&hashWriting == 0 {
  67. throw("concurrent map writes")
  68. }
  69. h.flags &^= hashWriting
  70. return elem
  71. }
  1. 2 到 21 行是我的调试代码,打印下桶里面有哪些键值对。
  2. 23 行和 32 行都是对写标志位的操作,可见,map 不支持多个 goroutine 写操作。
  3. 26 行把 key 转成stringStructOf类型,后面方便用stringStructOf里面的len和具体的 string 的字符数组值str,这也是个优化点,少了后面通过len(str)的计算,提高效率。
  4. 28 行noescape防止 key 被逃逸分析,计算出 key 的 hash。
  5. 38 行到 54 行的again的代码块主要计算式 key 应该落在哪个 buket,为什么作为一个代码块操作呢?是因为在触发扩容的时候,会重新计算落到哪个 bucket。40 行计算出 bucket 掩码,这里二进制值是10,41 行和 hash 做与运算,得到的值就是要把 key 应该存的桶号。这里也是个优化操作,通过二进制运算提高效率。第 50 行计算得到的值正是放到 bucket 里面的前 8 个 hash 槽里面。先忽略掉 251 行的扩容情况。
  6. 57 行到 123 行的bucketloop代码块主要作用是找到 key 和 value 存取的位置,并把 key 放到 bucket 所在的内存位置。里面有 2 个 for 循环,外循环轮询 bucket 以及 bucket 的逸出桶,里循环轮询桶的 8 个 tophash 槽,如果找到空的 tophash 槽 (66 行和 67 行) 就执行到done语句块。71 行之后就是 key 的高 8 位 hash 码相等了,那么就有可能 bucket 已经存在了这个 key,所以就先比 key 的长度,再比较内存。102~105 行先忽略。107-110 会申请一个逸出桶然后把 key 存到逸出桶的第一个位置。113 行把 tophash 值放到 hash 槽里面。
  7. 至于第 66 行为什么要比较 hash 槽等于emptyRest才算找到了呢?这个在后面的系列会介绍到。

done代码块的代码比较清晰,就是得到 value 放的内存位置,并且把状态设置为写完成。

  1. done:
  2. elem := add(unsafe.Pointer(insertb), dataOffset+bucketCnt*2*sys.PtrSize+inserti*uintptr(t.elemsize))
  3. if h.flags&hashWriting == 0 {
  4. throw("concurrent map writes")
  5. }
  6. h.flags &^= hashWriting
  7. return elem
  8. }

现在的 map 的内存结构是什么样的呢?
go基础之map-增和改(二)_shen的博客-CSDN博客 - 图2

一直到在发生扩容前的 map 内存结构是怎样的呢

go基础之map-增和改(二)_shen的博客-CSDN博客 - 图3

为啥明明 2 个桶都没填充完就要马上扩容了呢?这是因为扩容因子作用了:

  1. func overLoadFactor(count int, B uint8) bool {
  2. return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
  3. }

count此时值是 13,13 * (2 / 2) = 13,但是在下次的 13 的 key 放进来的时候就会发生扩容了。

发生扩容

上面说到 key 为 13 的时候发生扩容,下面具体分析如何扩容的:

  1. if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
  2. d := (*dmap)(unsafe.Pointer(uintptr(h.buckets)))
  3. bucketD := uintptr(0)
  4. for bucketD < bucketShift(h.B)+3 {
  5. flag := false
  6. for i, debugKey := range d.debugKeys {
  7. if debugKey == "" {
  8. continue
  9. }
  10. println(d.tophash[i])
  11. if flag == false {
  12. print("bucket:")
  13. println(bucketD)
  14. }
  15. print("key:")
  16. println(debugKey)
  17. flag = true
  18. }
  19. bucketD++
  20. d = (*dmap)(unsafe.Pointer(uintptr(h.buckets) + bucketD*uintptr(t.bucketsize)))
  21. }
  22. println()
  23. hashGrow(t, h)
  24. goto again
  25. }

在上面的 2 层 for 循环里面虽然找到了 bucket 还有剩余位置,但是第 7 行的overLoadFactor(h.count+1, h.B)计算出要发生扩容。8~28 行是我的调试代码,用来打印出此时 map 的内存结构。hashGrow会做具体的扩容操作,然后执行again从新计算落入哪个 bucket。
看看hashGrow干了嘛:

  1. func hashGrow(t *maptype, h *hmap) {
  2. bigger := uint8(1)
  3. if !overLoadFactor(h.count+1, h.B) {
  4. bigger = 0
  5. h.flags |= sameSizeGrow
  6. }
  7. oldbuckets := h.buckets
  8. newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
  9. flags := h.flags &^ (iterator | oldIterator)
  10. if h.flags&iterator != 0 {
  11. flags |= oldIterator
  12. }
  13. h.B += bigger
  14. h.flags = flags
  15. h.oldbuckets = oldbuckets
  16. h.buckets = newbuckets
  17. h.nevacuate = 0
  18. h.noverflow = 0
  19. if h.extra != nil && h.extra.overflow != nil {
  20. if h.extra.oldoverflow != nil {
  21. throw("oldoverflow is not nil")
  22. }
  23. h.extra.oldoverflow = h.extra.overflow
  24. h.extra.overflow = nil
  25. }
  26. if nextOverflow != nil {
  27. if h.extra == nil {
  28. h.extra = new(mapextra)
  29. }
  30. h.extra.nextOverflow = nextOverflow
  31. }
  32. }
  1. 当不是因为逸出桶太多导致的扩容,那么就扩容 2 倍,桶是原来的桶的 2 倍。
  2. 第 13 行会申请内存,这里没有nextOverflow,下面会分析makeBucketArray方法。
  3. 由于 hmap 的 extra 是 nil,所以后面的代码基本忽略。
  4. 将 hmap 的 oldbuckets 的指针指向原来的 buckets,将 hmap 的 buckets 指针指向新申请的桶。oldbuckets 是用于将老的桶里面的数据逐步拷贝到新申请的桶里面。
    我画一下此时的 map 的内存结构, 方便对比上面没扩容时候的内存结构:
    go基础之map-增和改(二)_shen的博客-CSDN博客 - 图4

然后会走到agian代码块里面的这段代码:

  1. if h.growing() {
  2. growWork_faststr(t, h, bucket)
  3. }

growing方法就是判断是否有 oldbuckets,此时会走向growWork_faststr方法去复制数据到新桶里面。此时bucket的值是 0-3 其中的一个值,此时我调试的值是 0,该值就是该 key 即将落入新申请桶的编号。

  1. func growWork_faststr(t *maptype, h *hmap, bucket uintptr) {
  2. mask := h.oldbucketmask()
  3. oldbucket := bucket & mask
  4. evacuate_faststr(t, h, oldbucket)
  5. if h.growing() {
  6. evacuate_faststr(t, h, h.nevacuate)
  7. }
  8. }

第 6 行的与操作肯定得到的桶号是在老桶的序号内,即将要迁移的就是该桶。evacuate_faststr就是迁移逻辑。12~15 行意思就是如果还有待迁移的桶,那么就继续迁移。
接下来就到了扩容里面真正重点的代码了:

  1. func evacuate_faststr(t *maptype, h *hmap, oldbucket uintptr) {
  2. b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
  3. newbit := h.noldbuckets()
  4. if !evacuated(b) {
  5. var xy [2]evacDst
  6. x := &xy[0]
  7. x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
  8. x.k = add(unsafe.Pointer(x.b), dataOffset)
  9. x.e = add(x.k, bucketCnt*2*sys.PtrSize)
  10. if !h.sameSizeGrow() {
  11. y := &xy[1]
  12. y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
  13. y.k = add(unsafe.Pointer(y.b), dataOffset)
  14. y.e = add(y.k, bucketCnt*2*sys.PtrSize)
  15. }
  16. for ; b != nil; b = b.overflow(t) {
  17. k := add(unsafe.Pointer(b), dataOffset)
  18. e := add(k, bucketCnt*2*sys.PtrSize)
  19. for i := 0; i < bucketCnt; i, k, e = i+1, add(k, 2*sys.PtrSize), add(e, uintptr(t.elemsize)) {
  20. top := b.tophash[i]
  21. if isEmpty(top) {
  22. b.tophash[i] = evacuatedEmpty
  23. continue
  24. }
  25. if top < minTopHash {
  26. throw("bad map state")
  27. }
  28. var useY uint8
  29. if !h.sameSizeGrow() {
  30. hash := t.hasher(k, uintptr(h.hash0))
  31. if hash&newbit != 0 {
  32. useY = 1
  33. }
  34. }
  35. b.tophash[i] = evacuatedX + useY
  36. dst := &xy[useY]
  37. if dst.i == bucketCnt {
  38. dst.b = h.newoverflow(t, dst.b)
  39. dst.i = 0
  40. dst.k = add(unsafe.Pointer(dst.b), dataOffset)
  41. dst.e = add(dst.k, bucketCnt*2*sys.PtrSize)
  42. }
  43. dst.b.tophash[dst.i&(bucketCnt-1)] = top
  44. *(*string)(dst.k) = *(*string)(k)
  45. typedmemmove(t.elem, dst.e, e)
  46. dst.i++
  47. dst.k = add(dst.k, 2*sys.PtrSize)
  48. dst.e = add(dst.e, uintptr(t.elemsize))
  49. }
  50. }
  51. if h.flags&oldIterator == 0 && t.bucket.ptrdata != 0 {
  52. b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
  53. ptr := add(b, dataOffset)
  54. n := uintptr(t.bucketsize) - dataOffset
  55. memclrHasPointers(ptr, n)
  56. }
  57. }
  58. if oldbucket == h.nevacuate {
  59. advanceEvacuationMark(h, t, newbit)
  60. }
  61. }
  1. 第 3 行得到待迁移的老桶地址。
  2. 第 5 行得到偏移的桶数,该值等于老桶数。这样的用意是把老桶的数据按照 hash 分散在新桶的里面。画图说明下:go基础之map-增和改(二)_shen的博客-CSDN博客 - 图5
    8~95 行的意思和上图差不多一致,有兴趣对照我的注释看一看。
  3. 96~109 行会去释放掉刚刚迁移完成的老桶,需要注意下,当有迭代老桶的时候就先不释放数据,当 key 和 value 都不是指针的时候也先不释放,到后面会解释到何时释放。
  4. 580~586 行意思就是去标记释放了多少个桶被释放了,此时oldbucketh.nevacuate都是 0,所以会进去标记。下面进去看看里面是怎么标记的。
  1. func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) {
  2. h.nevacuate++
  3. stop := h.nevacuate + 1024
  4. if stop > newbit {
  5. stop = newbit
  6. }
  7. for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
  8. h.nevacuate++
  9. }
  10. if h.nevacuate == newbit {
  11. h.oldbuckets = nil
  12. if h.extra != nil {
  13. h.extra.oldoverflow = nil
  14. }
  15. h.flags &^= sameSizeGrow
  16. }
  17. }
  1. 3 行就标记已经释放完一个桶了。
  2. 8 行算是一个权衡,最多往后面查看 1024 个桶,为什么不去查完呢?我理解的是到了 1024 个桶其实已经算一个比较大的 map 了,如果桶的真的很多的话,那就会影响效率,注意我们上面分析的扩容其实一直在添加 13 这个 key 的过程中,如果耗费太多时间的话那就不太好了。而且 go map 的扩容设计就不是想一次性把所有老桶的数据一下搬过来,一次搬不过来,那在下次添加 key 的时候继续搬。
  3. 12~15 行就是去往后面查看桶是否已经被迁移完,迁移完就 + 1。
  4. 17 行判断是否已经迁移完成,把oldbuckets的指针设为 nil
  5. 21~27 行就是判断是有 hmap 的extral是否为空,这里着重说明下:
    ①、extral为空,那能说明该 hamp 是没有逸出桶的,这个推断在这里不重要;但是它也能说明extraoverflowoldoverflow也为空,这个推断就比较重要了。当桶里面的 key 和 value 都不是指针的时候,那么这里overflowoldoverflow就不会为空了(如果后面有时间的话我再续写一篇博客说明下,因为我该系列的博客都是规划的 map[string][string],key 和 value 都是指针,所以不存在该情况),所以此时会把oldoverflow设为空指针。
    ②、还记得上面释放桶的时候这段代码吗?t.bucket.ptrdata != 0。当桶的 key 和 value 都不是指针的时候,这里就是 0 了,所以上面就不会释放桶里的数据,但是在这里把oldoverflow设置为 nil 指针,就能释放桶了。
  6. 再回到上面growWork_faststr方法,因为还有另外一个桶需要迁移,故会继续执行evacuate_faststr迁移。这次就会判断if h.nevacuate == newbit成功,并且释放 oldbuckets,ok,这里一次添加的 key 的过程当中就迁移完成了。

最后就回到again代码块了,下面就是重新寻桶,寻桶的位置,继续不断的放 key,直到下次发生扩容。很遗憾,我在这里没有打印出所有桶的数据,不能画出此时 map 的内存结构。大致内存结构是老桶的数据会按照 hash 分散到新的 4 个桶里面。

总结

上面分析了 map 的增加 key 的代码,改的代码和增的情况 99.9% 像,详细看我的分析或者我的源码注释,应该都明白了。如果有什么疑惑的地方在下面给我留言我们一起探讨吧。
go基础之map-增和改(二)_shen的博客-CSDN博客 - 图6
https://blog.csdn.net/u010927340/article/details/110194541