一些可能的内存泄漏场景

当使用一门支持自动垃圾回收的语言编程时,一般来说我们不需要关心内存泄露问题,因为程序的运行时会负责回收不再使用的内存。 但是,我们确实也需要知道一些特殊的可能会造成暂时性或永久性内存泄露的情形。 本文的余下部分将列出一些这样的情形。

子字符串造成的暂时性内存泄露

Go白皮书并没有说明一个子字符串表达式的结果(子)字符串和基础字符串是否应该共享一个承载底层字节序列内存块。 但标准编译器确实让它们共享一个内存块,而且很多标准库包的函数原型设计也默认了这一点。 这是一个好的设计,它不仅节省内存,而且还减少了CPU消耗。 但是有时候它会造成暂时性的内存泄露。

比如,当下面这段代码中的demo函数被调用之后,将会造成大约1M字节的暂时性内存泄露,直到包级变量s0的值在其它某处被重新修改为止。
var s0 string // 一个包级变量

// 一个演示目的的函数。
func f(s1 string) {
	s0 = s1[:50]
	// 目前,s0和s1共享着承载它们的字节序列的同一个内存块。
	// 虽然s1到这里已经不再被使用了,但是s0仍然在使用中,
	// 所以它们共享的内存块将不会被回收。虽然此内存块中
	// 只有50字节被真正使用,而其它字节却无法再被使用。
}

func demo() {
	s := createStringWithLengthOnHeap(1 << 20) // 1M bytes
	f(s)
}

为防止上面的f函数产生临时性内存泄露,我们可以将子字符串表达式的结果转换为一个字节切片,然后再转换回来。
func f(s1 string) {
	s0 = string([]byte(s1[:50]))
}

此种防止临时性内存泄露的方法不是很高效,因为在此过程中底层的字节序列被复制了两次,其中一次是不必要的。

我们可以利用官方Go编译器对字符串衔接所做的优化来防止一次不必要的复制,代价是有一个字节的浪费。
func f(s1 string) {
	s0 = (" " + s1[:50])[1:]
}

此第二种防止临时性内存泄露的方法有可能在将来会失效,并且它对于其它编译器来说很可能是无效的。

第三种防止临时性内存泄露的方法是使用在Go 1.10种引入的strings.Builder类型来防止一次不必要的复制。
import "strings"

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

此第三种方法的缺点是它的实现有些啰嗦(和前两种方法相比)。 一个好消息是,从Go 1.12开始,我们可以调用strings标准库包中的Repeat函数来克隆一个字符串。 从Go 1.12开始,此函数将利用strings.Builder来防止一次不必要的复制。

子切片造成的暂时性内存泄露

和子字符串情形类似,子切片也可能会造成暂时性的内存泄露。 在下面这段代码中,当函数g被调用之后,承载着切片s1的元素的内存块的开头大段内存将不再可用(假设没有其它值引用着此内存块)。 同时因为s0仍在引用着此内存块,所以此内存块得不到释放。
var s0 []int

func g(s1 []int) {
	// 假设s1的长度远大于30。
	s0 = s1[len(s1)-30:]
}

如果我们想防止这样的临时性内存泄露,我们必须在函数g中将30个元素均复制一份,使得切片s0s1不共享承载底层元素的内存块。
func g(s1 []int) {
	s0 = append(s1[:0:0], s1[len(s1)-30:]...)
	// 现在,如果再没有其它值引用着承载着s1元素的内存块,
	// 则此内存块可以被回收了。
}

因为未重置丢失的切片元素中的指针而造成的临时性内存泄露

在下面这段代码中,h函数调用之后,s的首尾两个元素将不再可用。
func h() []*int {
	s := []*int{new(int), new(int), new(int), new(int)}
	// 使用此s切片 ...

	return s[1:3:3]
}

只要h函数调用返回的切片仍在被使用中,它的各个元素就不会回收,包括首尾两个已经丢失的元素。 因此这两个已经丢失的元素引用着的两个int值也不会被回收,即使我们再也无法使用这两个int值。

为了防止这样的暂时性内存泄露,我们必须重置丢失的元素中的指针。
func h() []*int {
	s := []*int{new(int), new(int), new(int), new(int)}
	// 使用此s切片 ...

	s[0], s[len(s)-1] = nil, nil // 重置首尾元素指针
	return s[1:3:3]
}

我们经常需要在删除切片元素操作中重置一些切片元素中的指针值。

因为协程被永久阻塞而造成的永久性内存泄露

有时,一个程序中的某些协程会永久处于阻塞状态。 Go运行时并不会将处于永久阻塞状态的协程杀掉,因此永久处于阻塞状态的协程所占用的资源将永得不到释放。

Go运行时出于两个原因并不杀掉处于永久阻塞状态的协程。 一是有时候Go运行时很难分辨出一个处于阻塞状态的协程是永久阻塞还是暂时性阻塞;二是有时我们可能故意永久阻塞某些协程。

我们应该避免因为代码设计中的一些错误而导致一些协程处于永久阻塞状态。

因为没有停止不再使用的time.Ticker值而造成的永久性内存泄露

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

因为不正确地使用终结器(finalizer)而造成的永久性内存泄露

将一个终结器设置到一个循环引用值组中的一个值上可能导致被此值组中的值所引用的内存块永远得不到回收

比如,当下面这个函数被调用后,承载着xy的两个内存块将不保证会被逐渐回收。
func memoryLeaking() {
	type T struct {
		v [1<<20]int
		t *T
	}

	var finalizer = func(t *T) {
		 fmt.Println("finalizer called")
	}

	var x, y T

	// 此SetFinalizer函数调用将使x逃逸到堆上。
	runtime.SetFinalizer(&x, finalizer)

	// 下面这行将形成一个包含x和y的循环引用值组。
	// 这有可能造成x和y不可回收。
	x.t, y.t = &y, &x // y也逃逸到了堆上。
}

所以,不要为一个循环引用值组中的值设置终结器。

顺便说一下,我们不应该把终结器用做析构函数

延迟调用函数导致的临时性内存泄露

请阅读此文以获得详情。

Go语言101项目目前同时托管在GithubGitlab上。 欢迎各位在这两个项目中通过提交bug和PR的方式来改进完善Go语言101中的各篇文章。

本书微信公众号名称为"Go 101"。每个工作日此公众号将尽量发表一篇和Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

赞赏