變量內(nèi)存分配與回收
Go 程序會(huì)在兩個(gè)地方為變量分配內(nèi)存,一個(gè)是全局的堆上,另一個(gè)是函數(shù)調(diào)用棧,Go 語言有垃圾回收機(jī)制,在Go中變量分配在堆還是棧上是由編譯器決定的,因此開發(fā)者無需過多關(guān)注變量是分配在棧上還是堆上。但如果想寫出高質(zhì)量的代碼,了解語言背后的實(shí)現(xiàn)是有必要的,變量在棧上分配和在堆上分配底層實(shí)現(xiàn)的機(jī)制完全不同,變量的分配與回收流程不同,性能差異是非常大的。
堆與棧的區(qū)別
堆
程序運(yùn)行時(shí)動(dòng)態(tài)分配的內(nèi)存都位于堆中,這部分內(nèi)存由內(nèi)存分配器負(fù)責(zé)管理,該區(qū)域的大小會(huì)隨著程序的運(yùn)行而變化,即當(dāng)我們向堆請求分配內(nèi)存但分配器發(fā)現(xiàn)堆中的內(nèi)存不足時(shí),它會(huì)向操作系統(tǒng)內(nèi)核申請向高地址方向擴(kuò)展堆的大小,而當(dāng)我們釋放內(nèi)存把它歸還給堆時(shí)如果內(nèi)存分配器發(fā)現(xiàn)剩余空閑內(nèi)存太多則又會(huì)向操作系統(tǒng)請求向低地址方向收縮堆的大小,從內(nèi)存申請和釋放流程可以看出,從堆上分配的內(nèi)存用完之后必須歸還給堆,否則內(nèi)存分配器可能會(huì)反復(fù)向操作系統(tǒng)申請擴(kuò)展堆的大小從而導(dǎo)致堆內(nèi)存越用越多,最后出現(xiàn)內(nèi)存不足,這就是所謂的內(nèi)存泄漏。值的一提的是傳統(tǒng)的 c/c++ 代碼需要手動(dòng)處理內(nèi)存的分配和釋放,而在 Go 語言中,有垃圾回收器來回收堆上的內(nèi)存,所以程序員只管申請內(nèi)存,而不用管內(nèi)存的釋放,大大降低了程序員的心智負(fù)擔(dān),這不光是提高了程序員的生產(chǎn)力,更重要的是還會(huì)減少很多bug的產(chǎn)生。
棧
函數(shù)調(diào)用棧簡稱棧,在程序運(yùn)行過程中,不管是函數(shù)的執(zhí)行還是函數(shù)調(diào)用,棧都起著非常重要的作用,它主要被用來:
- 保存函數(shù)的局部變量;
- 向被調(diào)用函數(shù)傳遞參數(shù);
- 返回函數(shù)的返回值;
- 保存函數(shù)的返回地址,返回地址是指從被調(diào)用函數(shù)返回后調(diào)用者應(yīng)該繼續(xù)執(zhí)行的指令地址;
每個(gè)函數(shù)在執(zhí)行過程中都需要使用一塊棧內(nèi)存用來保存上述這些值,我們稱這塊棧內(nèi)存為某函數(shù)的棧幀(stack frame)。當(dāng)發(fā)生函數(shù)調(diào)用時(shí),因?yàn)檎{(diào)用者還沒有執(zhí)行完,其棧內(nèi)存中保存的數(shù)據(jù)還有用,所以被調(diào)用函數(shù)不能覆蓋調(diào)用者的棧幀,只能把被調(diào)用函數(shù)的棧幀“push”到棧上,等被調(diào)函數(shù)執(zhí)行完成后再把其棧幀從棧上“pop”出去,這樣,棧的大小就會(huì)隨函數(shù)調(diào)用層級的增加而生長,隨函數(shù)的返回而縮小,也就是說函數(shù)調(diào)用層級越深,消耗的棧空間就越大。棧的生長和收縮都是自動(dòng)的,由編譯器插入的代碼自動(dòng)完成,因此位于棧內(nèi)存中的函數(shù)局部變量所使用的內(nèi)存隨函數(shù)的調(diào)用而分配,隨函數(shù)的返回而自動(dòng)釋放,所以程序員不管是使用有垃圾回收還是沒有垃圾回收的高級編程語言都不需要自己釋放局部變量所使用的內(nèi)存,這一點(diǎn)與堆上分配的內(nèi)存截然不同。
進(jìn)程是操作系統(tǒng)資源分配的基本單位,每個(gè)進(jìn)程在啟動(dòng)時(shí)操作系統(tǒng)會(huì)進(jìn)程的棧分配固定大小的內(nèi)存,Linux 中進(jìn)程默認(rèn)棧的大小可以通過 ulimit -s
查看,當(dāng)函數(shù)退出時(shí)分配在棧上的內(nèi)存通過修改寄存器指針的偏移量會(huì)自動(dòng)進(jìn)行回收,進(jìn)程在運(yùn)行時(shí)堆中內(nèi)存的大小都需要向操作系統(tǒng)申請,進(jìn)程堆可用內(nèi)存的大小也取決于當(dāng)前操作系統(tǒng)可用內(nèi)存的量。
那么在 Go 中變量分配在堆上與棧上編譯器是如何決定的?
變量內(nèi)存分配逃逸分析
上文已經(jīng)提到 Go 中變量分配在堆還是棧上是由編譯器決定的,這種由編譯器決定內(nèi)存分配位置的方式稱之為逃逸分析(escape analysis)。Go 中聲明一個(gè)函數(shù)內(nèi)局部變量時(shí),當(dāng)編譯器發(fā)現(xiàn)變量的作用域沒有逃出函數(shù)范圍時(shí),就會(huì)在棧上分配內(nèi)存,反之則分配在堆上,逃逸分析由編譯器完成,作用于編譯階段。
檢查該變量是在棧上分配還是堆上分配
有兩種方式可以確定變量是在堆還是在棧上分配內(nèi)存:
- 通過編譯后生成的匯編函數(shù)來確認(rèn),在堆上分配內(nèi)存的變量都會(huì)調(diào)用 runtime 包的
newobject
函數(shù); - 編譯時(shí)通過指定選項(xiàng)顯示編譯優(yōu)化信息,編譯器會(huì)輸出逃逸的變量;
通過以上兩種方式來分析以下代碼示例中的變量是否存在逃逸:
package main
type demo struct {
Msg string
}
func example() *demo {
d := &demo{}
return d
}
func main() {
example()
}
1、通過匯編來確認(rèn)變量內(nèi)存分配是否有逃逸
$ go tool compile -S main.go
go tool compile -S main.go
"".example STEXT size=72 args=0x8 locals=0x18
0x0000 00000 (main.go:7) TEXT "".example(SB), ABIInternal, $24-8
0x0000 00000 (main.go:7) MOVQ (TLS), CX
0x0009 00009 (main.go:7) CMPQ SP, 16(CX)
0x000d 00013 (main.go:7) PCDATA $0, $-2
0x000d 00013 (main.go:7) JLS 65
0x000f 00015 (main.go:7) PCDATA $0, $-1
0x000f 00015 (main.go:7) SUBQ $24, SP
0x0013 00019 (main.go:7) MOVQ BP, 16(SP)
0x0018 00024 (main.go:7) LEAQ 16(SP), BP
0x001d 00029 (main.go:7) PCDATA $0, $-2
0x001d 00029 (main.go:7) PCDATA $1, $-2
0x001d 00029 (main.go:7) FUNCDATA $0, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB)
0x001d 00029 (main.go:7) FUNCDATA $1, gclocals·69c1753bd5f81501d95132d08af04464(SB)
0x001d 00029 (main.go:7) FUNCDATA $2, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB)
0x001d 00029 (main.go:8) PCDATA $0, $1
0x001d 00029 (main.go:8) PCDATA $1, $0
0x001d 00029 (main.go:8) LEAQ type."".demo(SB), AX
0x0024 00036 (main.go:8) PCDATA $0, $0
0x0024 00036 (main.go:8) MOVQ AX, (SP)
0x0028 00040 (main.go:8) CALL runtime.newobject(SB) // 調(diào)用 runtime.newobject 函數(shù)
0x002d 00045 (main.go:8) PCDATA $0, $1
0x002d 00045 (main.go:8) MOVQ 8(SP), AX
0x0032 00050 (main.go:9) PCDATA $0, $0
0x0032 00050 (main.go:9) PCDATA $1, $1
0x0032 00050 (main.go:9) MOVQ AX, "".~r0+32(SP)
0x0037 00055 (main.go:9) MOVQ 16(SP), BP
0x003c 00060 (main.go:9) ADDQ $24, SP
0x0040 00064 (main.go:9) RET
0x0041 00065 (main.go:9) NOP
0x0041 00065 (main.go:7) PCDATA $1, $-1
0x0041 00065 (main.go:7) PCDATA $0, $-2
0x0041 00065 (main.go:7) CALL runtime.morestack_noctxt(SB)
0x0046 00070 (main.go:7) PCDATA $0, $-1
0x0046 00070 (main.go:7) JMP 0
以上僅僅列出了 example 函數(shù)編譯后的匯編代碼,可以看到在程序的第8行調(diào)用了 runtime.newobject 函數(shù)。
2、通過編譯選項(xiàng)檢查
執(zhí)行 go tool compile -l -m -m main.go 或者 go build -gcflags "-m -m -l" main.go
$ go build -gcflags "-m -l" main.go
# command-line-arguments
./main.go:8:7: &demo literal escapes to heap:
./main.go:8:7: flow: d = &{storage for &demo literal}:
./main.go:8:7: from &demo literal (spill) at ./main.go:8:7
./main.go:8:7: from d := &demo literal (assign) at ./main.go:8:4
./main.go:8:7: flow: ~r0 = d:
./main.go:8:7: from return d (return) at ./main.go:9:2
./main.go:8:7: &demo literal escapes to heap
$ go tool compile -l -m -m main.go
main.go:8:7: &demo literal escapes to heap:
main.go:8:7: flow: d = &{storage for &demo literal}:
main.go:8:7: from &demo literal (spill) at main.go:8:7
main.go:8:7: from d := &demo literal (assign) at main.go:8:4
main.go:8:7: flow: ~r0 = d:
main.go:8:7: from return d (return) at main.go:9:2
main.go:8:7: &demo literal escapes to heap
可以使用 go tool compile --help
查看幾個(gè)選項(xiàng)的含義。
Go 官方 faq 文檔 stack_or_heap 一節(jié)也說了如何知道一個(gè)變量是在堆上還是在粘上分配內(nèi)存的,文檔描述的比較簡單,下面再看幾個(gè)特定類型的示例。
函數(shù)內(nèi)變量在堆上分配的一些 case
1、指針類型的變量,指針逃逸
代碼示例,和上節(jié)示例一致:
package main
type demo struct {
Msg string
}
func example() *demo {
d := &demo{}
return d
}
func main() {
example()
}
$ go tool compile -l -m main.go
main.go:8:7: &demo literal escapes to heap
2、棧空間不足
package main
func generate8191() {
nums := make([]int, 8191) // < 64KB
for i := 0; i < 8191; i++ {
nums[i] = i
}
}
func generate8192() {
nums := make([]int, 8192) // = 64KB
for i := 0; i < 8192; i++ {
nums[i] = i
}
}
func generate(n int) {
nums := make([]int, n) // 不確定大小
for i := 0; i < n; i++ {
nums[i] = i
}
}
func main() {
generate8191()
generate8192()
generate(1)
}
$ go tool compile -l -m main.go
main.go:4:14: make([]int, 8191) does not escape
main.go:9:14: make([]int, 8192) escapes to heap
main.go:14:14: make([]int, n) escapes to heap
在 Go 編譯器代碼中可以看到,對于有聲明類型的變量大小超過 10M 會(huì)被分配到堆上,隱式變量默認(rèn)超過64KB 會(huì)被分配在堆上。
var (
// maximum size variable which we will allocate on the stack.
// This limit is for explicit variable declarations like "var x T" or "x := ...".
// Note: the flag smallframes can update this value.
maxStackVarSize = int64(10 * 1024 * 1024)
// maximum size of implicit variables that we will allocate on the stack.
// p := new(T) allocating T on the stack
// p := &T{} allocating T on the stack
// s := make([]T, n) allocating [n]T on the stack
// s := []byte("...") allocating [n]byte on the stack
// Note: the flag smallframes can update this value.
maxImplicitStackVarSize = int64(64 * 1024)
)
3、動(dòng)態(tài)類型,interface{} 動(dòng)態(tài)類型逃逸
package main
type Demo struct {
Name string
}
func main() {
_ = example()
}
func example() interface{} {
return Demo{}
}
$ go tool compile -l -m main.go
main.go:12:13: Demo literal escapes to heap
4、閉包引用對象
package main
import "fmt"
func increase(x int) func() int {
return func() int {
x++
return x
}
}
func main() {
x := 0
in := increase(x)
fmt.Println(in())
fmt.Println(in())
}
$ go tool compile -l -m main.go
main.go:5:15: moved to heap: x
main.go:6:9: func literal escapes to heap
main.go:15:13: ... argument does not escape
main.go:15:16: in() escapes to heap
main.go:16:13: ... argument does not escape
main.go:16:16: in() escapes to heap
函數(shù)使用值與指針返回時(shí)性能的差異
上文介紹了 Go 中變量內(nèi)存分配方式,通過上文可以知道在函數(shù)中定義變量并使用值返回時(shí),該變量會(huì)在棧上分配內(nèi)存,函數(shù)返回時(shí)會(huì)拷貝整個(gè)對象,使用指針返回時(shí)變量在分配內(nèi)存時(shí)會(huì)逃逸到堆中,返回時(shí)只會(huì)拷貝指針地址,最終變量會(huì)通過 Go 的垃圾回收機(jī)制回收掉。
那在函數(shù)中返回時(shí)是使用值還是指針,哪種效率更高呢,雖然值有拷貝操作,但是返回指針會(huì)將變量分配在堆上,堆上變量的分配以及回收也會(huì)有較大的開銷。對于該問題,跟返回的對象和平臺(tái)也有一定的關(guān)系,不同的平臺(tái)需要通過基準(zhǔn)測試才能得到一個(gè)比較準(zhǔn)確的結(jié)果。
return_value_or_pointer.go
package main
import "fmt"
const bigSize = 200000
type bigStruct struct {
nums [bigSize]int
}
func newBigStruct() bigStruct {
var a bigStruct
for i := 0; i < bigSize; i++ {
a.nums[i] = i
}
return a
}
func newBigStructPtr() *bigStruct {
var a bigStruct
for i := 0; i < bigSize; i++ {
a.nums[i] = i
}
return &a
}
func main() {
a := newBigStruct()
b := newBigStructPtr()
fmt.Println(a, b)
}
benchmark_test.go
package main
import "testing"
func BenchmarkStructReturnValue(b *testing.B) {
b.ReportAllocs()
t := 0
for i := 0; i < b.N; i++ {
v := newBigStruct()
t += v.nums[0]
}
}
func BenchmarkStructReturnPointer(b *testing.B) {
b.ReportAllocs()
t := 0
for i := 0; i < b.N; i++ {
v := newBigStructPtr()
t += v.nums[0]
}
}
$ go test -bench .
goos: darwin
goarch: amd64
BenchmarkStructReturnValue-12 4215 278542 ns/op 0 B/op 0 allocs/op
BenchmarkStructReturnPointer-12 4556 267253 ns/op 1605634 B/op 1 allocs/op
PASS
ok _/Users/tianfeiyu/golang-dev/test 3.670s
在我本地測試中,200000 個(gè) int 類型的結(jié)構(gòu)體返回值更快些,小于 200000 時(shí)返回指針會(huì)更快。 如果對于代碼有更高的性能要求,需要在實(shí)際平臺(tái)上進(jìn)行基準(zhǔn)測試來得出結(jié)論。
其他的一些使用經(jīng)驗(yàn)
1、有狀態(tài)的對象必須使用指針返回,如系統(tǒng)內(nèi)置的 sync.WaitGroup、sync.Pool 之類的值,在 Go 中有些結(jié)構(gòu)體中會(huì)顯式存在 noCopy 字段提醒不能進(jìn)行值拷貝;
// A WaitGroup must not be copied after first use.
type WaitGroup struct {
noCopy noCopy
......
}
2、生命周期短的對象使用值返回,如果對象的生命周期存在比較久或者對象比較大,可以使用指針返回;
3、大對象推薦使用指針返回,對象大小臨界值需要在具體平臺(tái)進(jìn)行基準(zhǔn)測試得出數(shù)據(jù);
4、參考一些大的開源項(xiàng)目中的使用方式,比如 kubernetes、docker 等;
總結(jié)
本文通過分析在 Go 函數(shù)中使用變量時(shí)的一些問題,變量在分配內(nèi)存時(shí)會(huì)在堆和棧兩個(gè)地方存在,在堆和棧上分配內(nèi)存的不同,以及何時(shí)需要在堆上分配內(nèi)存的變量。
參考:
https://mojotv.cn/go/bad-go-pointer-returns
https://github.com/eastany/eastany.github.com/issues/61
https://mp.weixin.qq.com/s/PXGCqxK97U8mLGxW07ZTqw
https://golang.design/under-the-hood/zh-cn/part1basic/ch01basic/asm/