Go语言使用 map 时尽量不要在 big map 中保存指针

不知道你有没有听过这么一句:在使用 map 时尽量不要在 big map 中保存指针。好吧,你现在已经听过了:)为什么呢?原因在于 Go 语言的垃圾回收器会扫描标记 map 中的所有元素,GC 开销相当大,直接GG。

这两天在《Mastering Go》中看到 GC 这一章节里面对比 map 和 slice 在垃圾回收中的效率对比,书中只给出结论没有说明理由,这我是不能忍的,于是有了这篇学习笔记。扯那么多,Show Your Code

func MapWithPointer() {
    const N = 10000000
    m := make(map[string]string)
    for i := 0; i < N; i++ {
        n := strconv.Itoa(i)
        m[n] = n
    }
    now := time.Now()
    runtime.GC()      // 手动触发 GC
    fmt.Printf("With a map of strings, GC took: %s\n", time.Since(now))

    _ = m["0"]          // 引用一下防止被 GC 回收掉
}

func MapWithoutPointer() {
    const N = 10000000
    m := make(map[int]int)
    for i := 0; i < N; i++ {
        str := strconv.Itoa(i)
        // hash string to int
        n, _ := strconv.Atoi(str)
        m[n] = n
    }
    now := time.Now()
    runtime.GC()
    fmt.Printf("With a map of int, GC took: %s\n", time.Since(now))

    _ = m[0]
}

func TestMapWithPointer(t *testing.T) {
    MapWithPointer()
}

func TestMapWithoutPointer(t *testing.T) {
    MapWithoutPointer()
}
------------------------------------------------------------------------------------------
=== RUN   TestMapWithPointer
With a map of strings, GC took: 150.078ms
--- PASS: TestMapWithPointer (4.22s)
=== RUN   TestMapWithoutPointer
With a map of int, GC took: 4.9581ms
--- PASS: TestMapWithoutPointer (2.33s)
PASS

这是一个简单的测试程序,保存字符串的 map 和 保存整形的 map GC 的效率相差几十倍,是不是有同学会说明明保存的是 string 哪有指针?这个要说到 Go 语言中 string 的底层实现了,源码在 src/runtime/string.go里,可以看到 string 其实包含一个指向数据的指针和一个长度字段。注意这里的是否包含指针,包括底层的实现。

type stringStruct struct {
    str unsafe.Pointer    // 指针
    len int
}

Go 语言的 GC 会递归遍历并标记所有可触达的对象,标记完成之后将所有没有引用的对象进行清理。扫描到指针就会往下接着寻找,一直到结束。

Go 语言中 map 是基于数组和链表的数据结构实现的,通过优化的拉链法解决哈希冲突,每个 bucket 可以保存 8 对键值,在 8 个键值对数据后面有一个 overflow 指针,因为桶中最多只能装 8 个键值对,如果有多余的键值对落到了当前桶,那么就需要再构建一个桶(称为溢出桶),通过 overflow 指针链接起来。

因为 overflow 指针的缘故,所以无论 map 保存的是什么,GC 的时候就会把所有的 bmap 扫描一遍,带来巨大的 GC 开销。官方 issues 就有关于这个问题的讨论,runtime: Large maps cause significant GC pauses #9477

If we have a map[k]v where both k and v does not contain pointers and we want to improve scan performance, then we can do the following.
Add 'allOverflow []unsafe.Pointer' to hmap and store all overflow buckets in it. Then mark bmap as noScan. This will make scanning very fast, as we won't scan any user data.
In reality it will be somewhat more complex, because we will need to remove old overflow buckets from allOverflow. And also it will increase size of hmap, so it may require some reshuffling of data as well.

无脑机翻如下:
如果我们有一个map [k] v,其中k和v都不包含指针,并且我们想提高扫描性能,则可以执行以下操作。
将“ allOverflow [] unsafe.Pointer”添加到 hmap 并将所有溢出存储桶存储在其中。 然后将 bmap 标记为noScan。 这将使扫描非常快,因为我们不会扫描任何用户数据。
实际上,它将有些复杂,因为我们需要从allOverflow中删除旧的溢出桶。 而且它还会增加 hmap 的大小,因此也可能需要重新整理数据。

最终官方在 hmap 中增加了 overflow 相关字段完成了上面的优化,这是具体的 commit 地址。

下面看下具体是如何实现的,源码基于 go1.15,src/cmd/compile/internal/gc/reflect.go 中

// bmap makes the map bucket type given the type of the map.
func bmap(t *types.Type) *types.Type {
    ...

    // If keys and elems have no pointers, the map implementation
    // can keep a list of overflow pointers on the side so that
    // buckets can be marked as having no pointers.
    // Arrange for the bucket to have no pointers by changing
    // the type of the overflow field to uintptr in this case.
    // See comment on hmap.overflow in runtime/map.go.
    otyp := types.NewPtr(bucket)
    if !types.Haspointers(elemtype) && !types.Haspointers(keytype) {
        otyp = types.Types[TUINTPTR]
    }
    overflow := makefield("overflow", otyp)
    field = append(field, overflow)

    ...
    return bucket
}

通过注释可以看出,如果 map 中保存的键值都不包含指针(通过 Haspointers 判断),就使用一个 uintptr 类型代替 bucket 的指针用于溢出桶 overflow 字段,uintptr 类型在 GO 语言中就是个大小可以保存得下指针的整数,不是指针,就相当于实现了 将 bmap 标记为 noScan, GC 的时候就不会遍历完整个 map 了。随着不断的学习,愈发感慨 GO 语言中很多模块设计得太精妙了。
差不多说清楚了,能力有限,有不对的地方欢迎留言讨论,源码位置还是问的群里大佬_

你可能感兴趣的:(Go语言使用 map 时尽量不要在 big map 中保存指针)