Go 中的垃圾收集和指针的正确使用

Posted

技术标签:

【中文标题】Go 中的垃圾收集和指针的正确使用【英文标题】:Garbage collection and correct usage of pointers in Go 【发布时间】:2019-01-28 07:08:21 【问题描述】:

我来自 Python/Ruby/javascript 背景。我了解指针的工作原理,但是,我不完全确定如何在以下情况下利用它们。

假设我们有一个虚构的 Web API,它搜索某个图像数据库并返回一个 JSON 描述在找到的每个图像中显示的内容:

[
    
        "url": "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg",
        "description": "Ocean islands",
        "tags": [
            "name":"ocean", "rank":1,
            "name":"water", "rank":2,
            "name":"blue", "rank":3,
            "name":"forest", "rank":4
        ]
    ,

    ...

    
        "url": "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg",
        "description": "Bridge over river",
        "tags": [
            "name":"bridge", "rank":1,
            "name":"river", "rank":2,
            "name":"water", "rank":3,
            "name":"forest", "rank":4
        ]
    
]

我的目标是在 Go 中创建一个数据结构,它将每个标签映射到如下所示的图像 URL 列表:


    "ocean": [
        "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg"
    ],
    "water": [
        "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg",
        "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
    ],
    "blue": [
        "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg"
    ],
    "forest":[
        "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg", 
        "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
    ],
    "bridge": [
        "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
    ],
    "river":[
        "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
    ]

如您所见,每个图片 URL 可以同时属于多个标签。如果我有数千张图像甚至更多标签,如果图像 URL 字符串按每个标签的值复制,则此数据结构可能会变得非常大。这就是我想利用指针的地方。

我可以用 Go 中的两个结构来表示 JSON API 响应,func searchImages() 模仿了假 API:

package main

import "fmt"


type Image struct 
    URL string
    Description string
    Tags []*Tag


type Tag struct 
    Name string
    Rank int


// this function mimics json.NewDecoder(resp.Body).Decode(&parsedJSON)
func searchImages() []*Image 
    parsedJSON := []*Image
        &Image 
            URL: "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg",
            Description: "Ocean islands",
            Tags: []*Tag
                &Tag"ocean", 1,
                &Tag"water", 2,
                &Tag"blue", 3,
                &Tag"forest", 4,
            , 
        ,
        &Image 
            URL: "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg",
            Description: "Bridge over river",
            Tags: []*Tag
                &Tag"bridge", 1,
                &Tag"river", 2,
                &Tag"water", 3,
                &Tag"forest", 4,
            , 
        ,
    
    return parsedJSON

现在,导致内存中数据结构非常大的次优映射函数可能如下所示:

func main() 
    result := searchImages()

    tagToUrlMap := make(map[string][]string)

    for _, image := range result 
        for _, tag := range image.Tags 
            // fmt.Println(image.URL, tag.Name)
            tagToUrlMap[tag.Name] = append(tagToUrlMap[tag.Name], image.URL)
        
    

    fmt.Println(tagToUrlMap)

我可以修改它以使用指向 Image struct URL 字段的指针,而不是按值复制它:

    // Version 1

    tagToUrlMap := make(map[string][]*string)

    for _, image := range result 
        for _, tag := range image.Tags 
            // fmt.Println(image.URL, tag.Name)
            tagToUrlMap[tag.Name] = append(tagToUrlMap[tag.Name], &image.URL)
        
    

它有效,我的第一个问题是,在我以这种方式构建映射后,result 数据结构会发生什么? Image URL 字符串字段是否会以某种方式留在内存中,而 result 的其余部分将被垃圾回收?或者result 数据结构是否会因为指向它的成员而保留在内存中直到程序结束?

另一种方法是将 URL 复制到中间变量并使用指向它的指针:

    // Version 2

    tagToUrlMap := make(map[string][]*string)

    for _, image := range result 
        imageUrl = image.URL
        for _, tag := range image.Tags 
            // fmt.Println(image.URL, tag.Name)    
            tagToUrlMap[tag.Name] = append(tagToUrlMap[tag.Name], &imageUrl)
        
    

这样更好吗? result 数据结构会被正确地垃圾回收吗?

或者我应该在 Image 结构中使用指向字符串的指针?

type Image struct 
    URL *string
    Description string
    Tags []*Tag

有没有更好的方法来做到这一点?我也很感激 Go 上的任何资源,这些资源深入描述了指针的各种用途。谢谢!

https://play.golang.org/p/VcKWUYLIpH7

更新:我担心最佳内存消耗和不会产生最不需要的垃圾。我的目标是尽可能使用最少的内存。

【问题讨论】:

这篇文章应该会有所帮助。 dave.cheney.net/2017/04/26/…。 Golang 使用指针复制。所以结果可以在循环之后被垃圾回收。 我喜欢你的问题,因为我经常问自己同样的问题。找出答案的唯一方法是编写测试和基准测试。在几乎每个 Go 版本都优化了垃圾收集器之后,对于所有 Go 版本都没有正确的答案。如果您关心内存消耗,我会选择第 2 版。但您需要测试是否确定。 【参考方案1】:

[...] 会被正确地收集起来吗?

是的。

您永远不必担心会收集仍在使用的东西,一旦不再使用,您就可以依赖收集的所有东西。

所以关于 GC 的问题永远不会是“它会被正确收集吗?”但是“我会产生不必要的垃圾吗?”。现在,这个实际问题与数据 结构 的关系并不大,而是取决于创建的 neu 对象的数量(在堆上)。所以这是一个关于如何使用数据结构的问题,更不用说结构本身了。使用基准测试并使用 -benchmem 运行 go test。

(高端性能还可能会考虑 GC 需要做多少工作:扫描指针可能需要时间。暂时忘掉吧。)

另一个相关问题是关于内存消耗。复制字符串只复制三个单词,而复制 *string 复制一个单词。所以这里使用 *string 并没有什么安全的地方。

不幸的是,相关问题(产生的垃圾量和总内存消耗)没有明确的答案。不要过度思考问题,使用适合您的目的,衡量和重构。

【讨论】:

【参考方案2】:

前言:我在github.com/icza/gox 库中发布了提供的字符串池,请参阅stringsx.Pool


首先是一些背景。 Go 中的string 值由一个类似结构的小型数据结构reflect.StringHeader 表示:

type StringHeader struct 
        Data uintptr
        Len  int

所以基本上传递/复制string 值传递/复制这个小结构值,无论string 的长度如何,它都只有2 个字。在 64 位架构上,它只有 16 个字节,即使 string 有一千个字符。

所以基本上string 值已经充当指针。引入像*string 这样的另一个指针只会使使用复杂化,而且您不会真正获得任何显着的内存。为了内存优化,忘记使用*string

它有效,我的第一个问题是,在我以这种方式构建映射后,结果数据结构会发生什么?图像 URL 字符串字段是否会以某种方式留在内存中,而其余结果将被垃圾收集?或者结果数据结构会因为某些东西指向它的成员而保留在内存中直到程序结束?

如果你有一个指针值指向一个结构体值的一个字段,那么整个结构体将被保存在内存中,它不能被垃圾回收。请注意,虽然可以释放为结构的其他字段保留的内存,但当前的 Go 运行时和垃圾收集器并没有这样做。因此,为了实现最佳内存使用,您应该忘记存储结构字段的地址(除非您还需要完整的结构值,但仍然需要小心存储字段地址和切片/数组元素地址)。

这样做的原因是因为结构值的内存被分配为一个连续的段,因此只保留一个引用的字段会强烈地分割可用/空闲内存,并且会使优化内存管理变得更加困难和更少高效的。对这些区域进行碎片整理还需要复制引用字段的内存区域,这将需要“实时更改”指针值(更改内存地址)。

因此,虽然使用指向 string 值的指针可能会为您节省一些小内存,但增加的复杂性和额外的间接性使其不值得。

那该怎么办呢?

“最佳”解决方案

所以最干净的方法是继续使用string 值。

还有一项我们之前没有谈到的优化。

您可以通过解组 JSON API 响应来获得结果。这意味着如果在 JSON 响应中多次包含相同的 URL 或标记值,则会为它们创建不同的 string 值。

这是什么意思?如果您在 JSON 响应中有两次相同的 URL,则在解组后,您将有 2 个不同的 string 值,其中将包含 2 个不同的指针,指向 2 个不同的分配字节序列(否则字符串内容将相同)。 encoding/json 包不做string interning

这里有一个小应用程序可以证明这一点:

var s []string
err := json.Unmarshal([]byte(`["abc", "abc", "abc"]`), &s)
if err != nil 
    panic(err)


for i := range s 
    hdr := (*reflect.StringHeader)(unsafe.Pointer(&s[i]))
    fmt.Println(hdr.Data)

上面的输出(在Go Playground上试试):

273760312
273760315
273760320

我们看到 3 个不同的指针。它们可能相同,因为 string 值是不可变的。

json 包不会检测到重复的string 值,因为检测会增加内存和计算开销,这显然是不需要的。但在我们的例子中,我们追求最佳的内存使用,因此“初始”的额外计算确实值得大量的内存增益。

所以让我们自己进行字符串实习。该怎么做?

在解组 JSON 结果后,在构建 tagToUrlMap 映射期间,让我们跟踪我们遇到的 string 值,如果之前已经看到后续的 string 值,只需使用那个较早的值(它字符串描述符)。

这是一个非常简单的字符串内部实现:

var cache = map[string]string

func interned(s string) string 
    if s2, ok := cache[s]; ok 
        return s2
    
    // New string, store it
    cache[s] = s
    return s

让我们在上面的示例代码中测试这个“内部人员”:

var s []string
err := json.Unmarshal([]byte(`["abc", "abc", "abc"]`), &s)
if err != nil 
    panic(err)


for i := range s 
    hdr := (*reflect.StringHeader)(unsafe.Pointer(&s[i]))
    fmt.Println(hdr.Data, s[i])


for i := range s 
    s[i] = interned(s[i])


for i := range s 
    hdr := (*reflect.StringHeader)(unsafe.Pointer(&s[i]))
    fmt.Println(hdr.Data, s[i])

上面的输出(在Go Playground上试试):

273760312 abc
273760315 abc
273760320 abc
273760312 abc
273760312 abc
273760312 abc

太棒了!如我们所见,在使用我们的interned() 函数后,我们的数据结构中只使用了"abc" 字符串的一个实例(实际上是第一次出现)。这意味着所有其他实例(假设没有其他人使用它们)可以并且将被正确地垃圾收集(由垃圾收集器,在未来的某个时间)。

这里不要忘记一件事:字符串内部使用一个cache 字典,它存储所有以前遇到的字符串值。所以要让这些字符串消失,你也应该“清除”这个缓存映射,最简单的方法是为它分配一个nil 值。

事不宜迟,让我们看看我们的解决方案:

result := searchImages()

tagToUrlMap := make(map[string][]string)

for _, image := range result 
    imageURL := interned(image.URL)

    for _, tag := range image.Tags 
        tagName := interned(tag.Name)
        tagToUrlMap[tagName] = append(tagToUrlMap[tagName], imageURL)
    


// Clear the interner cache:
cache = nil

验证结果:

enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", "  ")
if err := enc.Encode(tagToUrlMap); err != nil 
    panic(err)

输出是(在Go Playground 上试试):


  "blue": [
    "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg"
  ],
  "bridge": [
    "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
  ],
  "forest": [
    "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg",
    "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
  ],
  "ocean": [
    "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg"
  ],
  "river": [
    "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
  ],
  "water": [
    "https://c8.staticflickr.com/4/3707/11603200203_87810ddb43_o.jpg",
    "https://c3.staticflickr.com/1/48/164626048_edeca27ed7_o.jpg"
  ]

进一步的内存优化:

我们使用内置的 append() 函数将新的图像 URL 添加到标签。 append() 可能(并且通常确实)分配比需要更大的切片(考虑到未来的增长)。在我们的“构建”过程之后,我们可能会检查我们的 tagToUrlMap 映射并将这些切片“修剪”到所需的最小值。

这是可以做到的:

for tagName, urls := range tagToUrlMap 
    if cap(urls) > len(urls) 
        urls2 := make([]string, len(urls))
        copy(urls2, urls)
        tagToUrlMap[tagName] = urls2
    

【讨论】:

我认为值得一提的两个挑剔:1)OP应该注意,由于cache是一个全局变量,它不能被多个goroutine同时使用;如果他们要同时获取/处理某些东西,则代码的结构必须使每个工作 goroutine 都有自己的 cache 变量。 2) 答案最终展示的修剪过程会导致内存使用量激增(几乎是urls 占用空间的两倍——取决于cap/len 的比率)——直到 GC 实际收集源切片。通常没什么大不了的,但值得考虑。 @kostix 是的,很好的提示。关于cache 是全局的:如果这个tagToUrlMap 需要同时从多个goroutines 构造,最简单的是将cache 变量移动到本地;或者如果在不同的地方使用,值得为它创建自己的类型和方法(并在需要的地方创建它的值)。 感谢您提供彻底、深入的回答。我对字符串指针的理解是错误的。

以上是关于Go 中的垃圾收集和指针的正确使用的主要内容,如果未能解决你的问题,请参考以下文章

Go 中结构的堆栈与堆分配,以及它们与垃圾收集的关系

C# 和 Java 中的垃圾收集

如果允许JVM在垃圾收集期间移动堆内存,那么垃圾收集如何不因移动指针而导致JNI爆炸?

JVM垃圾回收机制

JVM垃圾回收机制

六:垃圾收集器G1&ZGC详解