Go语言之内存泄露

Memory Leak in Golang

Posted by alovn on July 12, 2021

有自动垃圾回收的语言一般来说我们不需要关心内存泄露的问题,因为程序运行时会负责回收不再使用的内存。但是在Golang中有一些特殊的场景可能会存在内存泄露,我们需要了解、使用的时候也需要注意。

字符串

Golang中的一个字符串和它的子字符串共享一个底层字符序列内存块,这样可以节省内存和CPU消耗。但有时也会造成内存泄露。

1
2
3
4
5
6
7
8
9
var s0 string
func demo(s1 string) {
    s0 = s1[0:50]
}

func main() {
    s := createString(1<<20) //在堆上的大字符串
    demo(s)
}

上面代码中s0是一个包级变量,s0和s1共享同一个内存块,s1在demo方法后面就不再被使用了,但是s0仍在使用中,所以它们共享使用的内存块不会被回收,只有50个字节被真正使用到了,而其它字节无法再被使用。

方式一

为了防止上面的函数出现内存泄露,可以将子字符串转换为一个字节切片,再转换回来:

1
2
3
func demo(s1 string) {
    s0 = string([]byte(s1[:50]))
}

这个方式不是很高效,过程中底层字节序列被复制了两次,其中一次是不必要的。

方式二

我们可以利用Go编译器对字符串连接所做的优化来防止一次不必要的复制,不过代价是有一个字节的浪费:

1
2
3
func demo(s1 string) {
    s0 = (" " + s1[:50])[1:]
}

这种方式是依赖于编译器实现的,不保证将来是否会失效。

方式三

第三种方式是使用Golang中的strings.Builder类型来防止一次不必要的赋值:

1
2
3
4
5
6
7
8
import "strings"

func demo(s1 string) {
    var b strings.Builder
    b.Grow(50)
    b.WriteString(s1[:50])
    s0 = b.String()
}

这种方式的缺点是写起来麻烦一些。我们还可以通过调用strings.Repeate函数来克隆一个字符串,它内部就是通过strings.Builder实现的。

切片

和字符串类似,子切片也可能会造成内存泄露。

1
2
3
4
5
var s0 []int

func demo(s1 []int) {
    s0 = s1[len(s1)-30:]
}

函数demo调用之后,切片s1开头的大段内存块不再被使用,但是s0仍然引用着此内存块,所以此内存块得不到释放。如果我们想要防止这样的内存泄露,就要将30个元素复制一份,使切片s0与s1不再共享底层元素。

1
2
3
4
func demo(s1 []int) {
    // 当slice中有两个冒号时,即slice[start:index:max],它的容量就是(max - start),
    s0 = append(s1[:0:0], s1[len(s1)-30:]...)
}

在下面这段代码种,demo函数调用之后,s的首尾两个元素不再可用:

1
2
3
4
func demo() []*int {
    s := []*int{new(int), new(int), new(int), new(int)}
    return s[1:3:3]
}

只要demo函数返回的切片仍然在被使用中,它的各个元素就不会被回收,包括首尾连个已经丢失的元素。这里的解决方式是:删除切片元素、重置丢失元素中的指针。

1
2
3
4
5
6
func demo() []*int {
    s := []*int{new(int), new(int), new(int), new(int)}
    s[0], s[len(s)-1] = nil, nil
    return s[1:3:3]
    
}

协程阻塞

有时候,程序中某些协程会永久处于阻塞状态,Go运行时不会将处于永久阻塞的协程杀掉,因为Go运行时很难判断一个协程是永久阻塞还是暂时阻塞或者我们故意永久阻塞的,阻塞协程占用的资源将得不到释放。我们应当避免代码设计中的一些错误而导致协程永久阻塞。

time.Ticker

当一个time.Ticker值不再使用,一段时间后它将被自动垃圾回收掉。但是前提是对于一个不再使用的time.Ticker值我们必须调用它的Stop方法以结束它,否则它将永远不会被回收。