一個奇怪的golang等值判斷問題
問題場景
分析一下,下面程式碼的輸出是什麼(判斷a==c)的部分
package main import ( "fmt" "runtime" ) type obj struct{} func main() { a := &obj{} fmt.Printf("%p\n", a) c := &obj{} fmt.Printf("%p\n", c) fmt.Println(a == c) }
很多人可能一看,a和c完全是2個不同的物件例項,便認為a和c具備不同的記憶體地址,故而判斷a==c的結果為false。我也是一樣。我們看一下實際輸出:
0x1181f88 0x1181f88 true
問題分析
要分析上面的問題,就需要了解一些Golang記憶體分配,以及變數在記憶體逃逸的知識。上面的程式碼,有列印a和c的記憶體地址。倘若我們去掉任意一個(或者將列印記憶體的地址都去掉也一樣),則 a==c 的判斷輸出,就是 false。再看一下程式碼:
package main import ( "fmt" ) type obj struct{} func main() { a := &obj{} //fmt.Printf("%p\n", a) c := &obj{} fmt.Printf("%p\n", c) fmt.Println(a == c) }
輸出:
0x1181f88 false
那麼,可以看出,是 fmt.Printf 影響了最終結果的判斷。好吧,我們看一下,上面程式碼的記憶體逃逸情況分析:
go run -gcflags '-m -l' main.go
# command-line-arguments ./main.go:13:16: c escapes to heap ./main.go:12:10: &obj literal escapes to heap ./main.go:14:19: a == c escapes to heap ./main.go:10:10: main &obj literal does not escape ./main.go:13:15: main ... argument does not escape ./main.go:14:16: main ... argument does not escape 0x1181f88 false
可以看到,變數c從棧記憶體,逃逸到了堆記憶體上。而變數a沒有逃逸(注意:上面程式碼中,有 fmt.Printf("%p\n", c),沒有 fmt.Printf("%p\n", a) )。由此可以簡單判斷,是 fmt.Printf 導致變數產生了記憶體由棧向堆的逃逸。
回到最開始的問題上。
如果程式碼中,即列印 a,也列印b 的變數記憶體地址。則會導致 a 和 c,都逃逸到堆記憶體上。所以,我們的問題就來了。
- 為什麼 fmt.Printf 會導致變數的記憶體逃逸?
- 為什麼逃逸到了堆記憶體,2個變數就一樣了?
問題1:為什麼 fmt.Printf 會導致變數的記憶體逃逸?
其實,fmt.Printf 第二個引數,是一個 interface 型別。而 fmt.Printf 的內部實現,使用了反射 reflect,正是由於 reflect 才導致變數從棧向堆記憶體的逃逸成為可能(注意,並非所有reflect操作都會導致記憶體逃逸,具體還得看怎麼使用reflect的)。我們簡單總結為:
使用 fmt.Printf 由於其函式第二個引數是介面型別,而函式內部最終實現使用了 reflect 機制,導致變數從棧逃逸到堆記憶體。
問題2:為什麼變數 a 和 c 逃逸到堆記憶體後,記憶體地址就一樣了?
這是因為,堆上記憶體分配呼叫了 runtime 包的 newobject 函式。而 newobject 函式其實本質上會呼叫 runtime 包內的 mallocgc 函式。這個函式有點特別:
// Allocate an object of size bytes. // Small objects are allocated from the per-P cache's free lists. // Large objects (> 32 kB) are allocated straight from the heap. func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { if gcphase == _GCmarktermination { throw("mallocgc called with gcphase == _GCmarktermination") } // 關鍵部分,如果要分配記憶體的變數不佔用實際記憶體,則直接用 golang 的全域性變數 zerobase 的地址。 if size == 0 { return unsafe.Pointer(&zerobase) } // ... }
函式比較長,我做了擷取。這函式內有一個判斷。 如果要分配記憶體的變數不佔用實際記憶體,則直接用 golang 的全域性變數 zerobase 的地址。而我們的變數 a 和 變數 c 有一個共同特點,就是它們是“空 struct”,空 struct 是不佔用記憶體空間的。
所以,a 和 c 是空 struct,再做記憶體分配的時候,使用了 golang 內部全域性私有變數 zerobase 的記憶體地址。
如何驗證 a 和 c 都使用的是 runtime包內的 zerobase 記憶體地址?
改一下 runtime 包中,mallocgc 函式所在的檔案 runtime/malloc.go 增加一個函式 GetZeroBasePtr ,這個函式,專門用於返回 zerobase 的地址,如下:
// base address for all 0-byte allocations var zerobase uintptr func GetZeroBasePtr() unsafe.Pointer { return unsafe.Pointer(&zerobase) }
好了,我們回過頭再改一下測試程式碼:
package main import ( "fmt" "runtime" ) type obj struct{} func main() { a := &obj{} // 列印 a 的地址 fmt.Printf("%p\n", a) c := &obj{} // 列印 c 的地址 fmt.Printf("%p\n", c) fmt.Println(a == c) // 列印 runtime 包內的 zerobase 的地址 ptr := runtime.GetZeroBasePtr() fmt.Printf("golang inner zerobase ptr: %p\n", ptr) }
重新編譯:
// 注意,改了 golang 的原始碼,再編譯的話,必須加 -a 引數 go build -a
結果輸出如下:
0x1181f88 0x1181f88 true golang inner zerobase ptr: 0x1181f88
問題得證。
參考:
- https://studygolang.com/topics/8655\#reply0
- https://golang.org/src/runtime/malloc.go
- https://studygolang.com/articles/5790
- http://legendtkl.com/2017/04/02/golang-alloc/
- http://reusee.github.io/post/escape_analysis/
歡迎關注“海角之南”公眾號獲取更新動態

haijiaozhinan.png