Go語言 一些可能的內(nèi)存泄露場景

2023-02-16 17:40 更新

當(dāng)使用一門支持自動垃圾回收的語言編程時,一般來說我們不需要關(guān)心內(nèi)存泄露問題,因為程序的運行時會負(fù)責(zé)回收不再使用的內(nèi)存。 但是,我們確實也需要知道一些特殊的可能會造成暫時性或永久性內(nèi)存泄露的情形。 本文的余下部分將列出一些這樣的情形。

子字符串造成的暫時性內(nèi)存泄露

Go白皮書并沒有說明一個子字符串表達式的結(jié)果(子)字符串和基礎(chǔ)字符串是否應(yīng)該共享一個承載底層字節(jié)序列內(nèi)存塊。 但標(biāo)準(zhǔn)編譯器確實讓它們共享一個內(nèi)存塊,而且很多標(biāo)準(zhǔn)庫包的函數(shù)原型設(shè)計也默認(rèn)了這一點。 這是一個好的設(shè)計,它不僅節(jié)省內(nèi)存,而且還減少了CPU消耗。 但是有時候它會造成暫時性的內(nèi)存泄露。

比如,當(dāng)下面這段代碼中的demo函數(shù)被調(diào)用之后,將會造成大約1M字節(jié)的暫時性內(nèi)存泄露,直到包級變量s0的值在其它某處被重新修改為止。

var s0 string // 一個包級變量

// 一個演示目的函數(shù)。
func f(s1 string) {
	s0 = s1[:50]
	// 目前,s0和s1共享著承載它們的字節(jié)序列的同一個內(nèi)存塊。
	// 雖然s1到這里已經(jīng)不再被使用了,但是s0仍然在使用中,
	// 所以它們共享的內(nèi)存塊將不會被回收。雖然此內(nèi)存塊中
	// 只有50字節(jié)被真正使用,而其它字節(jié)卻無法再被使用。
}

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

為防止上面的f函數(shù)產(chǎn)生臨時性內(nèi)存泄露,我們可以將子字符串表達式的結(jié)果轉(zhuǎn)換為一個字節(jié)切片,然后再轉(zhuǎn)換回來。

func f(s1 string) {
	s0 = string([]byte(s1[:50]))
}

此種防止臨時性內(nèi)存泄露的方法不是很高效,因為在此過程中底層的字節(jié)序列被復(fù)制了兩次,其中一次是不必要的。

我們可以利用官方Go標(biāo)準(zhǔn)編譯器對字符串銜接所做的優(yōu)化來防止一次不必要的復(fù)制,代價是有一個字節(jié)的浪費。

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

此第二種防止臨時性內(nèi)存泄露的方法有可能在將來會失效,并且它對于其它編譯器來說很可能是無效的。

第三種防止臨時性內(nèi)存泄露的方法是使用在Go 1.10種引入的strings.Builder類型來防止一次不必要的復(fù)制。

import "strings"

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

此第三種方法的缺點是它的實現(xiàn)有些啰嗦(和前兩種方法相比)。 一個好消息是,從Go 1.12開始,我們可以調(diào)用strings標(biāo)準(zhǔn)庫包中的Repeat函數(shù)來克隆一個字符串。 從Go 1.12開始,此函數(shù)將利用strings.Builder來防止一次不必要的復(fù)制。

從Go 1.18開始,strings標(biāo)準(zhǔn)庫包中引入了一個Clone函數(shù)。 調(diào)用此函數(shù)為克隆一個字符串的最佳實現(xiàn)方式。

子切片造成的暫時性內(nèi)存泄露

和子字符串情形類似,子切片也可能會造成暫時性的內(nèi)存泄露。 在下面這段代碼中,當(dāng)函數(shù)g被調(diào)用之后,承載著切片s1的元素的內(nèi)存塊的開頭大段內(nèi)存將不再可用(假設(shè)沒有其它值引用著此內(nèi)存塊)。 同時因為s0仍在引用著此內(nèi)存塊,所以此內(nèi)存塊得不到釋放。

var s0 []int

func g(s1 []int) {
	// 假設(shè)s1的長度遠大于30。
	s0 = s1[len(s1)-30:]
}

如果我們想防止這樣的臨時性內(nèi)存泄露,我們必須在函數(shù)g中將30個元素均復(fù)制一份,使得切片s0s1不共享承載底層元素的內(nèi)存塊。

func g(s1 []int) {
	s0 = make([]int, 30)
	copy(s0, s1[len(s1)-30:])
	// 現(xiàn)在,如果再沒有其它值引用著承載著s1元素的內(nèi)存塊,
	// 則此內(nèi)存塊可以被回收了。
}

因為未重置丟失的切片元素中的指針而造成的臨時性內(nèi)存泄露

在下面這段代碼中,h函數(shù)調(diào)用之后,s的首尾兩個元素將不再可用。

func h() []*int {
	s := []*int{new(int), new(int), new(int), new(int)}
	// 使用此s切片 ...

	return s[1:3:3]
}

只要h函數(shù)調(diào)用返回的切片仍在被使用中,它的各個元素就不會回收,包括首尾兩個已經(jīng)丟失的元素。 因此這兩個已經(jīng)丟失的元素引用著的兩個int值也不會被回收,即使我們再也無法使用這兩個int值。

為了防止這樣的暫時性內(nèi)存泄露,我們必須重置丟失的元素中的指針。

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]
}

我們經(jīng)常需要在刪除切片元素操作中重置一些切片元素中的指針值。

因為協(xié)程被永久阻塞而造成的永久性內(nèi)存泄露

有時,一個程序中的某些協(xié)程會永久處于阻塞狀態(tài)。 Go運行時并不會將處于永久阻塞狀態(tài)的協(xié)程殺掉,因此永久處于阻塞狀態(tài)的協(xié)程所占用的資源將永得不到釋放。

Go運行時出于兩個原因并不殺掉處于永久阻塞狀態(tài)的協(xié)程。 一是有時候Go運行時很難分辨出一個處于阻塞狀態(tài)的協(xié)程是永久阻塞還是暫時性阻塞;二是有時我們可能故意永久阻塞某些協(xié)程。

我們應(yīng)該避免因為代碼設(shè)計中的一些錯誤而導(dǎo)致一些協(xié)程處于永久阻塞狀態(tài)。

因為沒有停止不再使用的time.Ticker值而造成的永久性內(nèi)存泄露

當(dāng)一個time.Timer值不再被使用,一段時間后它將被自動垃圾回收掉。 但對于一個不再使用的time.Ticker值,我們必須調(diào)用它的Stop方法結(jié)束它,否則它將永遠不會得到回收。

因為不正確地使用終結(jié)器(finalizer)而造成的永久性內(nèi)存泄露

將一個終結(jié)器設(shè)置到一個循環(huán)引用值組中的一個值上可能導(dǎo)致被此值組中的值所引用的內(nèi)存塊永遠得不到回收。

比如,當(dāng)下面這個函數(shù)被調(diào)用后,承載著xy的兩個內(nèi)存塊將不保證會被逐漸回收。

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函數(shù)調(diào)用將使x逃逸到堆上。
	runtime.SetFinalizer(&x, finalizer)

	// 下面這行將形成一個包含x和y的循環(huán)引用值組。
	// 這有可能造成x和y不可回收。
	x.t, y.t = &y, &x // y也逃逸到了堆上。
}

所以,不要為一個循環(huán)引用值組中的值設(shè)置終結(jié)器。

順便說一下,我們不應(yīng)該把終結(jié)器用做析構(gòu)函數(shù)。

延遲調(diào)用函數(shù)導(dǎo)致的臨時性內(nèi)存泄露

請閱讀此文以獲得詳情。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號