Go語言 內(nèi)存塊

2023-02-16 17:40 更新

Go是一門支持自動(dòng)內(nèi)存管理的語言,比如自動(dòng)內(nèi)存開辟和自動(dòng)垃圾回收。 所以Go程序員在編程時(shí)無須進(jìn)行各種紛繁的內(nèi)存管理操作。 這不僅給Go程序員提供了很多便利和節(jié)省了很多開發(fā)時(shí)間,而且也幫助Go程序員避免了很多因?yàn)槭韬龃笠舛斐傻腷ug。

在Go編程中,盡管我們無須知道底層的自動(dòng)內(nèi)存管理是如何實(shí)現(xiàn)的,但是知道自動(dòng)內(nèi)存管理實(shí)現(xiàn)中的一些概念和事實(shí)對(duì)我們寫出高質(zhì)量的Go代碼是非常有幫助的。

本文將解釋和列出標(biāo)準(zhǔn)編譯器和運(yùn)行時(shí)中內(nèi)存塊開辟和垃圾回收實(shí)現(xiàn)相關(guān)的一些概念和事實(shí)。 內(nèi)存管理的其它方面,比如內(nèi)存申請(qǐng)和內(nèi)存釋放,將不會(huì)在本文中探討。

內(nèi)存塊(memory block)

一個(gè)內(nèi)存塊是一段在運(yùn)行時(shí)刻承載著若干值部的連續(xù)內(nèi)存片段。 不同的內(nèi)存塊的大小可能不同,因它們所承載的值部的尺寸而定。 一個(gè)內(nèi)存塊同時(shí)可能承載著不同Go值的若干值部,但是一個(gè)值部在內(nèi)存中絕不會(huì)跨內(nèi)存塊存儲(chǔ),無論此值部的尺寸有多大。

一個(gè)內(nèi)存塊可能承載若干值部的原因有很多,這里僅列出一部分:

  • 一個(gè)結(jié)構(gòu)體值很可能有若干字段,所以當(dāng)為此結(jié)構(gòu)體值開辟了一個(gè)內(nèi)存塊時(shí),此內(nèi)存塊同時(shí)也將承載此結(jié)構(gòu)體值的各個(gè)字段值(的直接部分)。
  • 一個(gè)數(shù)組值常常包含很多元素,所以當(dāng)為此數(shù)組值開辟了一個(gè)內(nèi)存塊時(shí),此內(nèi)存塊同時(shí)也將承載此數(shù)組值的各個(gè)元素值(的直接部分)。
  • 兩個(gè)切片的底層間接部分的元素序列可能承載在同一個(gè)內(nèi)存塊上,這兩個(gè)間接值部甚至可能有部分重疊。

一個(gè)值引用著承載著它的值部的內(nèi)存塊

我們已經(jīng)知道,一個(gè)值部可能引用著另一個(gè)值部。這里,我們將引用的定義擴(kuò)展一下。 我們可以說一個(gè)內(nèi)存塊被它承載著的各個(gè)值部所引用著。 所以,當(dāng)一個(gè)值部v被另一個(gè)值部引用著時(shí),此另一個(gè)值部也(間接地)引用著承載著值部v的內(nèi)存塊。

什么時(shí)候需要開辟內(nèi)存塊?

在Go中,在下列場(chǎng)合(不限于)將發(fā)生開辟內(nèi)存塊的操作:

  • 顯式地調(diào)用newmake內(nèi)置函數(shù)。 一個(gè)new函數(shù)調(diào)用總是只開辟一個(gè)內(nèi)存塊。 一個(gè)make函數(shù)調(diào)用有可能會(huì)開辟多個(gè)內(nèi)存塊來承載創(chuàng)建的切片/映射/通道值的直接和底層間接值部。
  • 使用字面量創(chuàng)建映射、切片或函數(shù)值。在此創(chuàng)建過程中,一個(gè)或多個(gè)內(nèi)存塊將被開辟出來。
  • 聲明變量。
  • 將一個(gè)非接口值賦給一個(gè)接口值。(對(duì)于標(biāo)準(zhǔn)編譯器來說,不包括將一個(gè)指針值賦給一個(gè)接口值的情況。)
  • 銜接非常量字符串。
  • 將字符串轉(zhuǎn)換為字節(jié)切片或者碼點(diǎn)切片,或者反之,除了一些編譯器優(yōu)化情形
  • 將一個(gè)整數(shù)轉(zhuǎn)換為字符串。
  • 調(diào)用內(nèi)置append函數(shù)并且基礎(chǔ)切片的容量不足夠大。
  • 向一個(gè)映射添加一個(gè)鍵值條目并且此映射底層內(nèi)部的哈希表需要改變?nèi)萘俊?/li>

內(nèi)存塊將被開辟在何處?

對(duì)每一個(gè)使用標(biāo)準(zhǔn)編譯器編譯的Go程序,在運(yùn)行時(shí)刻,每一個(gè)協(xié)程將維護(hù)一個(gè)棧(stack)。 一個(gè)棧是一個(gè)預(yù)申請(qǐng)的內(nèi)存段,它做為一個(gè)內(nèi)存池供某些內(nèi)存塊從中開辟。 在官方Go工具鏈1.19版本之前,一個(gè)棧的初始尺寸總是2KiB。 從1.19版本開始,棧的初始尺寸是自適應(yīng)的。 每個(gè)棧的尺寸在協(xié)程運(yùn)行的時(shí)候?qū)凑招枰鲩L(zhǎng)和收縮。 棧的最小尺寸為2KiB。

(注意:Go運(yùn)行時(shí)維護(hù)著一個(gè)協(xié)程棧的最大尺寸限制,此限制為全局的。 如果一個(gè)協(xié)程在增長(zhǎng)它的棧的時(shí)候超過了此限制,整個(gè)程序?qū)⒈罎ⅰ?對(duì)于目前的官方標(biāo)準(zhǔn)Go工具鏈1.19版本,此最大限制的默認(rèn)值在64位系統(tǒng)上為1GB,在32位系統(tǒng)上為250MB。 我們可以在運(yùn)行時(shí)刻調(diào)用runtime/debug標(biāo)準(zhǔn)庫(kù)包中的SetMaxStack來修改此值。 另外請(qǐng)注意,當(dāng)前的官方標(biāo)準(zhǔn)編譯器實(shí)現(xiàn)中,實(shí)際上允許的協(xié)程棧的最大尺寸為不超過最大尺寸限制的2的冪。 所以對(duì)于默認(rèn)設(shè)置,實(shí)際上允許的協(xié)程棧的最大尺寸在64位系統(tǒng)上為512MiB,在32位系統(tǒng)上為128MiB。)

內(nèi)存塊可以被開辟在棧上。開辟在一個(gè)協(xié)程維護(hù)的棧上的內(nèi)存塊只能在此協(xié)程內(nèi)部被使用(引用)。 其它協(xié)程是無法訪問到這些內(nèi)存塊的。 一個(gè)協(xié)程可以無需使用任何數(shù)據(jù)同步技術(shù)而使用開辟在它的棧上的內(nèi)存塊上的值部。

堆(heap)是一個(gè)虛擬的概念。每個(gè)程序只有一個(gè)堆。 一般地,如果一個(gè)內(nèi)存塊沒有開辟在任何一個(gè)棧上,則我們說它開辟在了堆上。 開辟在堆上的內(nèi)存塊可以被多個(gè)協(xié)程并發(fā)地訪問。 在需要的時(shí)候,對(duì)承載在它們之上的值部的訪問需要做同步。

如果編譯器覺察到一個(gè)內(nèi)存塊在運(yùn)行時(shí)將會(huì)被多個(gè)協(xié)程訪問,或者不能輕松地?cái)喽ù藘?nèi)存塊是否只會(huì)被一個(gè)協(xié)程訪問,則此內(nèi)存塊將會(huì)被開辟在堆上。 也就是說,編譯器將采取保守但安全的策略,使得某些可以安全地被開辟在棧上的內(nèi)存塊也有可能會(huì)被開辟在堆上。

事實(shí)上,棧對(duì)于Go程序來說并非必要。Go程序中所有的內(nèi)存塊都可以開辟在堆上。 支持棧只是為了讓Go程序的運(yùn)行效率更高。

  • 從棧上開辟內(nèi)存塊比在堆上快得多;
  • 開辟在棧上的內(nèi)存塊不需要被垃圾回收;
  • 開辟在棧上的內(nèi)存塊對(duì)CPU緩存更加友好。

如果一個(gè)內(nèi)存塊被開辟在某處(堆上或某個(gè)棧上),則我們也可以說承載在此內(nèi)存塊上的各個(gè)值部也開辟在此處。

如果一個(gè)局部聲明的變量的某些值部被開辟在堆上,則我們說這些值部以及此局部變量逃逸到了堆上。 我們可以運(yùn)行Go官方工具鏈中提供的go build -gcflags -m命令來查看代碼中哪些局部值的值部在運(yùn)行時(shí)刻會(huì)逃逸到堆上。 如上所述,目前官方Go標(biāo)準(zhǔn)編譯器中的逃逸分析器并不十分完美,因此某些可以安全地開辟在棧上的值也可能會(huì)逃逸到了堆上。

在運(yùn)行時(shí)刻,每一個(gè)仍在被使用中的逃逸到堆上的值部肯定被至少一個(gè)開辟在棧上的值部所引用著。 如果一個(gè)逃逸到堆上的值是一個(gè)被聲明為T類型的局部變量,則在運(yùn)行時(shí),一個(gè)*T類型的隱式指針將被創(chuàng)建在棧上。 此指針存儲(chǔ)著此T類型的局部變量的在堆上的地址,從而形成了一個(gè)從棧到堆的引用關(guān)系。 另外,編譯器還將所有對(duì)此局部變量的使用替換為對(duì)此指針的解引用。 此*T值可能從今后的某一時(shí)刻不再被使用從而使得此引用關(guān)系不再存在。 此引用關(guān)系在下面介紹的垃圾回收過程中發(fā)揮著重要的作用。

類似地,我們可以認(rèn)為每個(gè)包級(jí)變量(常稱全局變量)都被開辟在了堆上,并且它被一個(gè)開辟在一個(gè)全局內(nèi)存區(qū)上的隱式指針?biāo)弥?事實(shí)上,此指針引用著此包級(jí)變量的直接部分,此直接部分又引用著其它的值(部)。

一個(gè)開辟在堆上的內(nèi)存塊可能同時(shí)被開辟在若干不同棧上的值部所引用著。

一些事實(shí):

  • 如果一個(gè)結(jié)構(gòu)體值的一個(gè)字段逃逸到了堆上,則此整個(gè)結(jié)構(gòu)體值也逃逸到了堆上。
  • 如果一個(gè)數(shù)組的某個(gè)元素逃逸到了堆上,則此整個(gè)數(shù)組也逃逸到了堆上。
  • 如果一個(gè)切片的某個(gè)元素逃逸到了堆上,則此切片中的所有元素都將逃逸到堆上,但此切片值的直接部分可能開辟在棧上。
  • 如果一個(gè)值部v被一個(gè)逃逸到了堆上的值部所引用,則此值部v也將逃逸到堆上。

使用內(nèi)置new函數(shù)開辟的內(nèi)存可能開辟在堆上,也可能開辟在棧上。這是與C++不同的一點(diǎn)。

當(dāng)一個(gè)協(xié)程的棧的大?。ㄒ?yàn)闂T鲩L(zhǎng)或者收縮而)改變時(shí),一個(gè)新的內(nèi)存段將申請(qǐng)給此棧使用。 原先已經(jīng)開辟在老的內(nèi)存段上的內(nèi)存塊將很有可能被轉(zhuǎn)移到新的內(nèi)存段上,或者說這些內(nèi)存塊的地址將改變。 相應(yīng)地,引用著這些開辟在此棧上的內(nèi)存塊的指針(它們同樣開辟在此棧上)中存儲(chǔ)的地址也將得到刷新。 下面是一個(gè)展示開辟在棧上的值的地址改變的例子。

package main

func f(i int) byte {
	type _ int // 防止f被內(nèi)聯(lián)
	var a [1<<20]byte // 使棧增長(zhǎng)
	return a[i]
}

func main(){
	var x int
	println(&x)
	f(100)
	println(&x)
}

我們可以看到,上例打引出的兩個(gè)地址不一樣(如果使用官方標(biāo)準(zhǔn)編譯器v1.19編譯之)。

一個(gè)內(nèi)存塊在什么條件下可以被回收?

為包級(jí)變量的直接部分開辟的內(nèi)存塊永遠(yuǎn)不會(huì)被回收。

每個(gè)協(xié)程的棧將在此協(xié)程退出之時(shí)被整體回收,此棧上開辟的各個(gè)內(nèi)存塊沒必要被一個(gè)一個(gè)單獨(dú)回收。 棧內(nèi)存池并不由垃圾回收器回收。

對(duì)一個(gè)開在堆上的內(nèi)存塊,當(dāng)它不再被任何開辟在協(xié)程棧的仍被使用中的,以及全局內(nèi)存區(qū)上的,值部所(直接或者間接)地引用著,則此內(nèi)存塊可以被安全地垃圾回收了。 我們稱這樣的內(nèi)存塊為不再被使用的內(nèi)存塊。開辟在堆上的不再被使用的內(nèi)存塊將在以后某個(gè)時(shí)刻被垃圾回收器回收掉。

下面是一個(gè)展示了一些內(nèi)存塊在何時(shí)可以被垃圾回收的例子。

package main

var p *int

func main() {
	done := make(chan bool)
	// done通道將被使用在主協(xié)程和下面將要
	// 創(chuàng)建的新協(xié)程中,所以它將被開辟在堆上。

	go func() {
		x, y, z := 123, 456, 789
		_ = z  // z可以被安全地開辟在棧上。
		p = &x // 因?yàn)閤和y都會(huì)將曾經(jīng)被包級(jí)指針p所引用過,
		p = &y // 因此,它們都將開辟在堆上。

		// 到這里,x已經(jīng)不再被任何其它值所引用?;蛘哒f承載
		// 它的內(nèi)存塊已經(jīng)不再被使用。此內(nèi)存塊可以被回收了。

		p = nil
		// 到這里,y已經(jīng)不再被任何其它值所引用。
		// 承載它的內(nèi)存塊可以被回收了。

		done <- true
	}()

	<-done
	// 到這里,done已經(jīng)不再被任何其它值所引用。一個(gè)
	// 聰明的編譯器將認(rèn)為承載它的內(nèi)存塊可以被回收了。

	// ...
}

有時(shí),聰明的編譯器可能會(huì)做出一些出人意料的(但正確的)的優(yōu)化。 比如在下面這個(gè)例子中,切片bs的底層間接值部在bs仍在使用之前就已經(jīng)被標(biāo)準(zhǔn)編譯器發(fā)覺已經(jīng)不再被使用了。

package main

import "fmt"

func main() {
	// 假設(shè)此切片的長(zhǎng)度很大,以至于它的元素
	// 將被開辟在堆上。
	bs := make([]byte, 1 << 31)

	// 一個(gè)聰明的編譯器將覺察到bs的底層元素
	// 部分已經(jīng)不會(huì)再被使用,而正確地認(rèn)為bs的
	// 底層元素部分在此刻可以被安全地回收了。

	fmt.Println(len(bs))
}

關(guān)于切片值的內(nèi)部實(shí)現(xiàn)結(jié)構(gòu),請(qǐng)參考值部一文。

順便說一下,有時(shí)候出于種種原因,我們希望確保上例中的bs切片的底層間接值部不要在fmt.Println調(diào)用之前被垃圾回收。 這時(shí),我們可以使用一個(gè)runtime.KeepAlive函數(shù)調(diào)用以便讓垃圾回收器知曉在此調(diào)用之前切片bs和它所引用著的值部仍在被使用中。

一個(gè)例子:

package main

import "fmt"
import "runtime"

func main() {
	bs := make([]int, 1000000)

	fmt.Println(len(bs))
	runtime.KeepAlive(&bs)
	// 對(duì)于這個(gè)特定的例子,也可以調(diào)用
	// runtime.KeepAlive(bs)。
}

如何判斷一個(gè)堆內(nèi)存塊是否仍在被使用?

目前的官方Go標(biāo)準(zhǔn)運(yùn)行時(shí)(1.19版本)使用一個(gè)并發(fā)三色(tri-color)標(biāo)記清掃(mark-sweep)算法來實(shí)現(xiàn)垃圾回收。 這里僅會(huì)對(duì)此算法的原理做一個(gè)大致的描述。一個(gè)具體實(shí)現(xiàn)可能和此大致描述會(huì)有很多細(xì)節(jié)上的差別。

一個(gè)垃圾回收過程分為兩個(gè)階段:標(biāo)記階段和清掃階段。

在標(biāo)記階段,垃圾回收器(實(shí)際上是一組協(xié)程)使用三色算法來分析哪些(開辟在堆上的)內(nèi)存塊已經(jīng)不再使用了。

  • 在每一輪(見下一節(jié)的解釋)垃圾回收過程的開始,所有的堆內(nèi)存塊將被標(biāo)記為白色。
  • 然后垃圾回收器將所有開辟在棧和全局內(nèi)存區(qū)上的內(nèi)存塊標(biāo)記為灰色,并把它們加入一個(gè)灰色內(nèi)存塊列表。
  • 循環(huán)下面兩步直到灰色內(nèi)存塊列表為空:
    1. 從個(gè)灰色內(nèi)存塊列表中取出一個(gè)內(nèi)存塊,并把它標(biāo)記為黑色。
    2. 然后掃描承載在此內(nèi)存塊上的指針值,并通過這些指針找到它們引用著的內(nèi)存塊。 如果一個(gè)引用著的內(nèi)存塊為白色的,則將其標(biāo)記為灰色并加入灰色內(nèi)存塊列表;否則,忽略之。

(注意這里在算法中使用三色而不是兩色的原因是此標(biāo)記過程是并發(fā)的。在標(biāo)記的過程中,很多其它普通用戶協(xié)程也正在運(yùn)行中。 在此標(biāo)記過程中對(duì)指針的寫入需要一些額外的開銷,欲更深入了解此點(diǎn),請(qǐng)以“write barrier golang”為關(guān)鍵字自行搜索以深入了解。 簡(jiǎn)而言之,當(dāng)在某個(gè)用戶協(xié)程中,一個(gè)已經(jīng)標(biāo)記為黑色的內(nèi)存塊在標(biāo)記過程中被修改而使其新引用著的一個(gè)仍標(biāo)記為白色的內(nèi)存塊時(shí),此白色內(nèi)存塊需要被標(biāo)記為灰色,否則此白色內(nèi)存塊有可能將被認(rèn)為是垃圾而回收掉;除此之外的情況不做特殊處理。)

在清掃階段,仍被標(biāo)記為白色的內(nèi)存塊將被認(rèn)為不再使用而被回收掉。

一個(gè)不再被使用的內(nèi)存塊被回收后可能并不會(huì)立即釋放給操作系統(tǒng),這樣Go運(yùn)行時(shí)可以將其重新分配給其它值部使用。 不用擔(dān)心,官方Go運(yùn)行時(shí)的實(shí)現(xiàn)比大多數(shù)主流的Java運(yùn)行時(shí)要消耗少得多的內(nèi)存。

此垃圾回收算法不會(huì)移動(dòng)內(nèi)存塊來整理內(nèi)存碎片。

不再被使用的內(nèi)存塊將在什么時(shí)候被回收?

垃圾回收過程將消耗相當(dāng)?shù)腃PU資源和一些內(nèi)存資源。所以垃圾回收過程并非總在運(yùn)行。 一個(gè)新的垃圾回收過程將在程序運(yùn)行中的某些實(shí)時(shí)指標(biāo)達(dá)到某些條件時(shí)才會(huì)被觸發(fā)。 這些條件怎么定義的是一個(gè)垃圾回收調(diào)度問題。

官方標(biāo)準(zhǔn)運(yùn)行時(shí)(runtime)的垃圾回收調(diào)度實(shí)現(xiàn)仍在隨著版本遞增而在不斷地改善中。 所以很難精確地描述此實(shí)現(xiàn)并同時(shí)保證描述的長(zhǎng)期有效性。這里,我僅僅列出一些相關(guān)的參考文獻(xiàn):


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

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)