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ì)在本文中探討。
一個(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)存塊可能承載若干值部的原因有很多,這里僅列出一部分:
我們已經(jīng)知道,一個(gè)值部可能引用著另一個(gè)值部。這里,我們將引用的定義擴(kuò)展一下。 我們可以說一個(gè)內(nèi)存塊被它承載著的各個(gè)值部所引用著。 所以,當(dāng)一個(gè)值部v
被另一個(gè)值部引用著時(shí),此另一個(gè)值部也(間接地)引用著承載著值部v
的內(nèi)存塊。
在Go中,在下列場(chǎng)合(不限于)將發(fā)生開辟內(nèi)存塊的操作:
new
和make
內(nèi)置函數(shù)。 一個(gè)new
函數(shù)調(diào)用總是只開辟一個(gè)內(nèi)存塊。 一個(gè)make
函數(shù)調(diào)用有可能會(huì)開辟多個(gè)內(nèi)存塊來承載創(chuàng)建的切片/映射/通道值的直接和底層間接值部。append
函數(shù)并且基礎(chǔ)切片的容量不足夠大。對(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)行效率更高。
如果一個(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í):
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編譯之)。
為包級(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)。
}
目前的官方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)不再使用了。
(注意這里在算法中使用三色而不是兩色的原因是此標(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)存碎片。
垃圾回收過程將消耗相當(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):
GOGC
和GOMEMLIMIT
環(huán)境變量(注意GOMEMLIMIT
環(huán)境變量從Go 1.19開始才支持);
更多建議: