去垃圾收集部分切片吗?

Does go garbage collect parts of slices?

如果我实现这样的队列...

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" 指针永远不会递减(AFAIK),因此永远无法访问这些元素。

Go 的垃圾收集器是否足够聪明以释放它们?

切片只是描述符(小型类似结构的数据结构),如果未被引用,将被正确地垃圾收集。

另一方面,切片的基础数组(描述符指向的)在通过重新切片创建的所有切片之间共享:引自Go Language Specification: Slice Types:

A slice, once initialized, is always associated with an underlying array that holds its elements. A slice therefore shares storage with its array and with other slices of the same array; by contrast, distinct arrays always represent distinct storage.

因此,如果至少存在一个切片,或者一个保存数组的变量(如果切片是通过切片数组创建的),它不会被垃圾回收。

官方对此声明:

Andrew Gerrand 的博客 post Go Slices: usage and internals 清楚地说明了这种行为:

As mentioned earlier, re-slicing a slice doesn't make a copy of the underlying array. The full array will be kept in memory until it is no longer referenced. Occasionally this can cause the program to hold all the data in memory when only a small piece of it is needed.

...

Since the slice references the original array, as long as the slice is kept around the garbage collector can't release the array.

回到你的例子

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

另外一个非常重要的事情是,如果一个元素从前面弹出,切片将被重新切片并且不包含对弹出元素的引用,但由于底层数组仍然包含该值,该值也将保留在内存中(不仅仅是数组)。建议无论何时从队列中弹出或删除一个元素 (slice/array),始终将其置零 (它在切片中的相应元素),这样该值就不会保留在不必要的记忆。如果您的切片包含指向大数据结构的指针,这就变得更加重要。

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 page:

Delete without preserving order

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

NOTE If the type of the element is a pointer or a struct with pointer fields, which need to be garbage collected, the above implementations of Cut and Delete have a potential memory leak problem: some elements with values are still referenced by slice a and thus can not be collected.

简单的问题,简单的答案:不。(但是如果你继续推动切片,它会在某个时候溢出它的底层数组,然后可以释放未使用的元素。)

与我正在阅读的内容相反,Golang 似乎肯定会垃圾收集至少未使用的切片起始部分。下面的测试用例提供了证据。

在第一种情况下,切片在每次迭代中都设置为切片[: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)

        }
    }
} 

。在撰写本文时,Go 垃圾收集器 (GC) 还不够智能,无法收集切片中底层数组的开头,即使它不可访问.

正如此处其他人所提到的,一个切片(在引擎盖下)是一个结构,它包含三件事:指向其底层数组的指针、切片的长度(无需重新切片即可访问的值)以及切片(可通过重新切片访问的值)。在 Go 博客上,slice internals are discussed at length. Here is another article I like about Go memory layouts.

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

我知道您在想什么...就像您是真正的计算机科学家一样,您可能需要一些证据。我会放纵你:

https://goplay.space/#tDBQs1DfE2B

正如其他人提到的和示例代码中所示,使用 append 会导致底层数组的重新分配和复制,从而允许对旧的底层数组进行垃圾回收。