本文首發於公眾號:Hunter後端
原文鏈接:Golang基礎筆記七之指針,值類型和引用類型
本篇筆記介紹 Golang 裏的指針,值類型與引用類型相關的概念,以下是本篇筆記目錄:
- 指針
- 值類型與引用類型
- 內存逃逸
- 減少內存逃逸的幾種方案
1、指針
在計算機內存中,每個變量都存儲在特定的內存地址上,而指針是一種特殊的變量,它存儲的是一個變量的內存地址。
我們可以通過指針訪問變量的內存地址,也可以通過指針訪問或修改這個變量的內存地址存儲的值。
1. 指針的聲明與初始化
使用 & 符號來獲取變量的內存地址,使用 * 獲取指針指向的內存地址的值:
var a int = 10
var a_ptr *int = &a
fmt.Println("a 的內存地址是: ", &a)
fmt.Println("a_ptr 的值是: ", a_ptr)
fmt.Println("根據指針獲取的值是: ", *a_ptr)
2. 指針操作
使用 * 獲取變量指向的內存地址的值後,可以直接使用,也可以對其進行修改,在上面操作後,我們接着操作:
*a_ptr = 20
fmt.Println("修改後 a 的值是: ", a)
可以看到,通過指針修改後,a 的值已經變成了 20。
3. 指針作為函數傳參
如果我們將指針作為函數的參數傳入,並且在函數內部對其進行了修改,那麼會直接修改指針所指向的變量的值,下面是一個示例:
func ModityValue(ptr *int) {
*ptr = 20
}
func main() {
var a int = 10
fmt.Println("修改前, a 的值是:", a) // 修改前, a 的值是: 10
ModityValue(&a)
fmt.Println("修改後, a 的值是:", a) // 修改後, a 的值是: 20
}
2、值類型與引用類型
1. 值類型與引用類型包括的數據類型
值類型包括整型、浮點型、布爾型、字符串、數組、結構體等,值類型的變量直接存儲值,內存通常分配在棧上。
引用類型包括切片、映射、通道等,引用類型的變量存儲的是一個引用(內存地址),內存通常分配在堆上。
2. 棧和堆
值類型的變量通常分配在棧上,引用類型的變量通常分配在堆上,注意,這裏是通常,還會有特殊情況後面再介紹。
先來介紹一下棧和堆。
1) 棧
先介紹一下棧相關的信息:
- 棧內存由編譯器自動管理,在函數調用時分配,函數返回後立即釋放,效率極高
- 棧上變量的生命週期嚴格限定在函數執行期間。函數調用開始,變量被創建並分配內存;函數調用結束,變量佔用的內存會被立即回收
2) 堆
- 堆用於存儲程序運行期間動態分配的內存,其分配和釋放不是由函數調用的生命週期決定,而是由程序員或垃圾回收機制來管理。
- 堆上的變量生命週期不依賴於函數調用的結束,變量可以在函數調用結束後仍然存在,直到沒有任何引用指向它,然後由垃圾回收機制進行回收。
3. 值類型與引用類型的內存分配
值類型變量通常具有明確的生命週期,通常與其所在的函數調用相關,函數調用結束後,這些變量佔用的內存可以立即被回收,使用棧來存儲值類型可以充分利用棧的高效內存管理機制。
而引用類型的變量需要動態分配內存,並且其生命週期可能超出函數調用的範圍,比如切片可以動態調整大小,映射也可以增減鍵值對,這些操作需要在運行時進行內存的分配和釋放,使用堆來存儲引用類型可以更好地支持這些動態特性。
前面介紹值類型通常會被分配到棧上,但是也有可能被分配到堆上,這種情況就是內存逃逸。
內存逃逸的內容在下一個小節中再介紹。
4. 值類型和引用類型的複製
值類型的複製會複製整個數據,是深拷貝的操作,副本的修改不會影響到原始數據,比如下面的操作:
type Person struct {
Name string
Age int
}
func main() {
p := Person{Name: "Hunter", Age: 18}
p2 := p
p2.Name = "Tom"
fmt.Printf("p1 name is:%s, p2 name is:%s \n", p.Name, p2.Name)
// p1 name is:Hunter, p2 name is:Tom
}
而引用類型的複製則複製的是其引用,屬於淺拷貝的操作,多個變量會共享底層數據,修改其中一個副本會影響原始數據,比如下面的操作:
s := []int{1, 2, 3}
s2 := s
s2[1] = 8
fmt.Println("s:", s) // s: [1 8 3]
fmt.Println("s2:", s2) // s2: [1 8 3]
5. 值類型和引用類型的函數傳參
值類型和引用類型的函數傳參和複製一樣,值類型傳遞的是變量的副本,在函數內部修改不會影響原始變量,而引用類型傳遞的是原始數據的引用,函數內部修改會影響外部變量。
下面是值類型的函數傳參的示例:
func ChangePerson(p Person) {
p.Name = "Tom"
fmt.Println("inner func p.Name is:", p.Name)
// inner func p.Name is: Tom
}
func main() {
p := Person{Name: "Hunter", Age: 18}
ChangePerson(p)
fmt.Println("outer func p.Name is:", p.Name)
// outer func p.Name is: Hunter
}
以下是引用類型傳參的示例:
func ChangeSlice(s []int) {
s[2] = 9
fmt.Println("inner func slice is:", s)
// inner func slice is: [1 2 9]
}
func main() {
s := []int{1, 2, 3}
ChangeSlice(s)
fmt.Println("outer func slice is:", s)
// outer func slice is: [1 2 9]
}
對於函數傳參,還有兩點需要注意,一個是值類型函數傳參的性能問題,一個是引用類型涉及擴容的問題。
1) 值類型函數傳參的性能問題
對於值類型變量,比如一個結構體,擁有非常多的字段,當其作為函數傳參,傳遞的會是變量的副本,也就是會將其值複製出來傳遞,那麼當這個變量非常大的時候可能就會涉及性能問題。
為了解決這個問題,有個方法就是傳遞其變量的指針,但是需要注意傳遞指針在函數內部對其修改後,會影響到原始變量的值。
2) 引用類型函數傳參擴容問題
當引用類型作為函數傳參,如果在函數內部修改涉及到擴容,那麼其地址就會更改,那麼函數內部的修改就不會反映到其原值上了,比如下面這個是切片在函數內部修改的示例:
func ChangeSlice(s []int) {
s = append(s, []int{4, 5, 6}...)
fmt.Println("inner func slice is:", s)
// inner func slice is: [1 2 3 4 5 6]
}
func main() {
s := []int{1, 2, 3}
ChangeSlice(s)
fmt.Println("outer func slice is:", s)
// outer func slice is: [1 2 3]
}
3、內存逃逸
Golang 裏編譯器決定內存分配位置是在棧上還是在堆上,這個就是逃逸分析,這個過程發生在編譯階段。
1. 逃逸分析的方法
我們可以使用下面的命令來查看逃逸分析的結果:
go build -gcflags="-m" main.go
2. 內存逃逸的場景
內存逃逸可能會存在於以下這些情況,比如函數返回一個值類型變量的指針,或者閉包引用局部變量等。
1) 函數返回局部變量的指針
如果一個函數返回值是變量的指針,那麼該局部變量會逃逸到堆上:
func CreateInt() *int {
x := 1
return &x
}
func main() {
_ = CreateInt()
}
使用逃逸分析的命令:
go build -gcflags="-m" main.go
可以看到輸出如下:
# command-line-arguments
./main.go:14:2: moved to heap: x
説明 x 這個變量會逃逸到堆上。
2) 閉包引用局部變量
如果閉包引用了函數的局部變量,這些局部變量會逃逸到堆上,因為閉包可能在函數調用結束後繼續存在並訪問這些變量:
func counter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
_ = counter()
}
對此使用逃逸分析的命令,輸出結果如下:
# command-line-arguments
./main.go:14:2: moved to heap: count
./main.go:15:9: func literal escapes to heap
3) 向接口類型變量賦值
當我們將值賦給接口類型的變量,因為接口類型需要再運行時才能確定具體的類型,所以這個值也會逃逸到堆上,最常見的一個例子就是 fmt.Println():
func main() {
s := "hello world"
fmt.Println(s)
}
其逃逸分析結果如下:
# command-line-arguments
./main.go:25:13: ... argument does not escape
./main.go:25:14: s escapes to heap
除此之外,還有一些原因也可能造成內存逃逸,比如大對象超出了棧容量限制,被強制分配到堆、發送變量到 channel 等。
3. 逃逸分析的意義
內存逃逸就是原本分配在棧上的變量被分配到了堆上,而分配到堆上的變量在函數調用結束後仍然存在,直到沒有任何引用指向它,然後由垃圾回收機制進行回收。
所以通過逃逸分析,我們可以減輕GC(垃圾回收)的壓力。
4、減少內存逃逸的幾種方案
- 減少堆分配,避免函數不必要的指針返回,優先通過返回值傳遞小對象
- 避免閉包引用局部變量
- 減少使用向接口類型賦值,如 fmt.Println() 這種
- 避免大對象超出棧容量限制