go语言的垃圾回收是否涵盖了切片的部分?

29

如果我像这样实现一个队列...

package main

import(
    "fmt"
)

func PopFront(q *[]string) string {
    r := (*q)[0]
    *q = (*q)[1:len(*q)]
    return r
}

func PushBack(q *[]string, a string) {
    *q = append(*q, a)
}

func main() {
    q := make([]string, 0)

    PushBack(&q, "A")
    fmt.Println(q)
    PushBack(&q, "B")
    fmt.Println(q)
    PushBack(&q, "C")
    fmt.Println(q)

    PopFront(&q)
    fmt.Println(q)
    PopFront(&q)
    fmt.Println(q)      
}

我最终得到了一个数组["A", "B", "C"],它没有指向前两个元素的切片。由于切片的“start”指针永远不能被递减(据我所知),因此这些元素永远无法被访问。

Go语言的垃圾回收器是否足够智能,可以释放它们?

4个回答

38

切片(slice)只是描述符(类似小型结构体数据结构),如果没有被引用,它们将被正确地垃圾回收。

然而,与此相反的是,切片所指向的底层数组(即描述符指针所指向的数组)是共享的,这意味着所有通过对其进行重新切片创建的切片都会共享该数组。引用自Go语言规范:Slice类型

一旦初始化,切片总是关联一个持有其元素的底层数组。 因此,切片与其数组以及同一数组的其他切片共享存储; 相反,不同的数组始终表示不同的存储。

因此,只要至少存在一个切片,或者存在一个变量持有该数组(如果切片是通过对数组进行切片来创建的),它就不会被垃圾回收。

官方声明如下:

Andrew Gerrand在博客文章Go Slices: usage and internals中明确阐述了这种行为:

如前所述,重新对切片进行切片不会复制底层数组。 只要切片仍然存在,完整的数组将一直保存在内存中

...

由于切片引用了原始数组,只要切片存在,垃圾收集器就无法释放数组

回到您的例子

虽然底层数组不会被释放,但是请注意,如果向队列添加新元素,则内置的append函数偶尔可能会分配一个新数组并将当前元素复制到新数组中——但是复制只会复制切片的元素而不是整个底层数组!当发生这样的重新分配和复制时,“旧”数组可能会被垃圾回收,如果它没有其他引用存在的话。

此外,另一个非常重要的事情是,如果从前面弹出一个元素,切片将被重新切片并且不包含对已弹出元素的引用,但是由于底层数组仍然包含该值,该值也将保留在内存中(不仅仅是数组)。建议在从队列(切片/数组)中弹出或删除元素时,总是将其归零(在切片中的相应元素),以便该值不会不必要地保留在内存中。如果您的切片包含指向大型数据结构的指针,则这变得更加关键。

func PopFront(q *[]string) string {
    r := (*q)[0]
    (*q)[0] = ""  // Always zero the removed element!
    *q = (*q)[1:len(*q)]
    return r
}

这在Slice Tricks wiki页面中提到:

不保留顺序的删除

a[i] = a[len(a)-1] 
a = a[:len(a)-1]

注意:如果元素的类型是指针或包含需要进行垃圾回收的指针字段的结构体,则上述的CutDelete实现可能存在潜在的内存泄漏问题:某些具有值的元素仍由切片a引用,因此无法被回收。


11
补充icza的回答,针对您特定的队列情况:切片中不可达部分不会被垃圾回收,但是当您向已满的队列q中添加新项并且需要重新分配内存时,仅有可达元素将被复制,因此A、B和C不会被复制。q不会无限增长从而包含不可达元素。 - siritinga
@siritinga 没错,好建议。谢谢。我将其纳入答案,并加入了其他重要信息(例如如果删除元素则将“slot”归零)。 - icza
@icza,如果您有时间的话,能否请您对这个问题https://dev59.com/6q3la4cB1Zd3GeqPS9It提供一个详细的解答。 - Himanshu
我还会添加一个链接到"slice tricks"维基页面,特别是这一部分,处理由于重新切片而被"框出"的元素。 - kostix
1
@himanshu219 对的,只需要清除该值,如果它引用了存储在切片后备数组之外的内存,例如指针或字符串。 - icza
显示剩余2条评论

6

。截至目前为止,Go语言的垃圾回收器(GC)还不够智能,不能清理一个切片底层数组的开头,即使它是无法访问的

正如其他人在这里提到的那样,一个切片(在底层)实际上是一个结构体,由三个部分组成:指向其底层数组的指针、切片的长度(可通过重新切片访问的值)和切片的容量(可通过重新切片访问的值)。在Go博客上,详细讨论了切片的内部结构。这里有另一篇我喜欢的文章关于Go的内存布局

当你重新切片并截断切片的尾部时,很明显(在了解内部结构后),底层数组、指向底层数组的指针和切片的容量都没有改变;只有切片的长度字段被更新了。当你重新切片并截断切片的开头时,你实际上是改变了指向底层数组的指针以及长度和容量。在这种情况下,通常不清楚(根据我的阅读)为什么GC不清理这个无法访问的底层数组的一部分,因为您无法重新切片数组以再次访问它。我猜测,从GC的角度来看,底层数组被视为一块内存块。如果你可以指向底层数组的任何一部分,整个数组都不符合释放条件。

我知道你在想什么...像你这样的真正的计算机科学家,你可能需要一些证明。我会满足你:

https://goplay.space/#tDBQs1DfE2B

正如其他人所述并在示例代码中显示的那样,使用append可能会导致底层数组的重新分配和复制,这允许旧的底层数组被垃圾回收。


2
简单的问题,简单的答案:不会。(但是如果你继续推进切片,它将在某个点上溢出其底层数组,然后未使用的元素变得可用以被释放。)

-1

与我所读到的相反,Golang似乎确实会垃圾回收至少未使用的切片起始部分。以下测试用例提供了证据。

在第一种情况下,每次迭代都将切片设置为slice[:1]。在比较案例中,它跳过了这一步骤。

第二种情况使得内存消耗超过了第一种情况。但是为什么呢?

func TestArrayShiftMem(t *testing.T) {
    slice := [][1024]byte{}

    mem := runtime.MemStats{}
    mem2 := runtime.MemStats{}
    runtime.GC()
    runtime.ReadMemStats(&mem)

    for i := 0; i < 1024*1024*1024*1024; i++ {
        slice = append(slice, [1024]byte{})
        slice = slice[1:]
        runtime.GC()

        if i%(1024) == 0 {
            runtime.ReadMemStats(&mem2)
            fmt.Println(mem2.HeapInuse - mem.HeapInuse)
            fmt.Println(mem2.StackInuse - mem.StackInuse)
            fmt.Println(mem2.HeapAlloc - mem.HeapAlloc)

        }
    }
}


func TestArrayShiftMem3(t *testing.T) {
    slice := [][1024]byte{}

    mem := runtime.MemStats{}
    mem2 := runtime.MemStats{}
    runtime.GC()
    runtime.ReadMemStats(&mem)

    for i := 0; i < 1024*1024*1024*1024; i++ {
        slice = append(slice, [1024]byte{})
        // slice = slice[1:]
        runtime.GC()

        if i%(1024) == 0 {
            runtime.ReadMemStats(&mem2)
            fmt.Println(mem2.HeapInuse - mem.HeapInuse)
            fmt.Println(mem2.StackInuse - mem.StackInuse)
            fmt.Println(mem2.HeapAlloc - mem.HeapAlloc)

        }
    }
}

输出测试1:

go test -run=.Mem -v .
...
0
393216
21472
^CFAIL  github.com/ds0nt/cs-mind-grind/arrays   1.931s

输出测试3:

go test -run=.Mem3 -v .
...
19193856
393216
19213888
^CFAIL  github.com/ds0nt/cs-mind-grind/arrays   2.175s

如果在第一次测试中禁用垃圾回收,内存确实会飙升。生成的代码如下:

func TestArrayShiftMem2(t *testing.T) {
    debug.SetGCPercent(-1)

    slice := [][1024]byte{}

    mem := runtime.MemStats{}
    mem2 := runtime.MemStats{}
    runtime.GC()
    runtime.ReadMemStats(&mem)
    // 1kb per

    for i := 0; i < 1024*1024*1024*1024; i++ {
        slice = append(slice, [1024]byte{})
        slice = slice[1:]
        // runtime.GC()

        if i%(1024) == 0 {
            fmt.Println("len, cap:", len(slice), cap(slice))
            runtime.ReadMemStats(&mem2)
            fmt.Println(mem2.HeapInuse - mem.HeapInuse)
            fmt.Println(mem2.StackInuse - mem.StackInuse)
            fmt.Println(mem2.HeapAlloc - mem.HeapAlloc)

        }
    }
} 

1
append() 有时会分配一个全新的数组,所以我认为这并不显示你认为它显示的内容。 - Timmmm
这是因为像@Timmmm所提到的那样,append将根据需要分配一个新数组。只有来自切片的元素将被复制,并且GC会收集旧数组。icza在他的答案中涵盖了这一点。 - jsageryd

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接