如果我实现这样的队列......
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"]
。由于切片的“开始”指针永远不会递减(AFAIK),因此永远不能访问这些元素。
Go的垃圾收集器是否足够智能以释放它们?
切片只是描述符(小型结构类数据结构),如果没有引用,将被正确地进行垃圾收集。
另一方面,切片的基础数组(描述符所指向的)在通过重新创建它的所有切片之间共享:引用来自Go Language Specification: Slice Types:
一旦初始化,切片始终与保存其元素的基础数组相关联。因此,切片与其阵列和同一阵列的其他切片共享存储;相比之下,不同的数组总是代表不同的存储。
因此,如果存在至少一个切片,或者保存数组的变量(如果通过切片数组创建切片),则不会对其进行垃圾回收。
关于此的正式声明:
博客文章Go Slices: usage and internals作者Andrew Gerrand清楚地说明了这种行为:
如前所述,重新切片切片不会复制底层数组。完整数组将保留在内存中,直到不再引用它为止。偶尔这会导致程序在只需要一小部分数据时将所有数据保存在内存中。
...
由于切片引用原始数组,只要切片保持在垃圾收集器周围就无法释放数组。
回到你的例子
虽然底层数组不会被释放,但请注意,如果向队列中添加新元素,内置的append
函数有时可能会分配一个新数组并将当前元素复制到新数组 - 但复制只会复制元素切片而不是整个底层数组!当发生这样的重新分配和复制时,如果不存在其他引用,则可以对“旧”数组进行垃圾收集。
另一个非常重要的事情是,如果从前面弹出一个元素,那么切片将被复制并且不包含对弹出元素的引用,但由于底层数组仍然包含该值,因此该值也将保留在内存中(不是只是数组)。建议每当从队列中弹出或删除元素(切片/数组)时,始终将其归零(切片中的相应元素),这样该值将不会不必要地保留在内存中。如果切片包含指向大数据结构的指针,这就变得更加重要。
func PopFront(q *[]string) string {
r := (*q)[0]
(*q)[0] = "" // Always zero the removed element!
*q = (*q)[1:len(*q)]
return r
}
Delete without preserving order
a[i] = a[len(a)-1] a = a[:len(a)-1]
注意如果元素的类型是一个指针或带有指针字段的结构,需要进行垃圾收集,
Cut
和Delete
的上述实现有一个潜在的内存泄漏问题:一些带有值的元素仍然被切片a
引用,因此无法收集。
简单的问题,简单的答案:没有。(但是如果你继续推动切片将在某个时刻溢出其底层数组,那么未使用的元素就可以被释放了。)
与我正在阅读的相反,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)不够智能,无法收集切片中底层数组的开头,即使它不可访问。
正如其他人在这里所提到的,一个切片(在引擎盖下)是一个完全由三件事构成的结构:一个指向其底层数组的指针,切片的长度(可以访问的值没有重新分析),以及切片的容量(可通过reslicing)。在Go go博客上,slice internals are discussed at length。这是另一篇我喜欢about Go memory layouts的文章。
当您重新切割并切断切片的尾端时,很明显(在了解内部结构时)底层数组,指向底层数组的指针和切片的容量都保持不变;只更新切片长度字段。当您重新切片并切断切片的开头时,您实际上正在更改指向基础数组的指针以及长度和容量。在这种情况下,通常不清楚(根据我的读数)为什么GC不清理底层数组的这个不可访问的部分,因为你无法重新切片数组以再次访问它。我的假设是从GC的角度来看底层数组被视为一块内存。如果您可以指向基础数组的任何部分,则整个事物不符合重新分配的条件。
我知道你在想什么......就像你真正的计算机科学家一样,你可能需要一些证据。我会放纵你:
https://goplay.space/#tDBQs1DfE2B
正如其他人所提到的并且如示例代码所示,使用append
可能会导致重新分配和复制底层数组,从而允许对旧的底层数组进行垃圾回收。