Golang基礎(chǔ)筆記七之指針,值類型和引用類型
本文首發(fā)于公眾號:Hunter后端
本篇筆記介紹 Golang 里的指針,值類型與引用類型相關(guān)的概念,以下是本篇筆記目錄:
- 指針
- 值類型與引用類型
- 內(nèi)存逃逸
- 減少內(nèi)存逃逸的幾種方案
1、指針
在計(jì)算機(jī)內(nèi)存中,每個變量都存儲在特定的內(nèi)存地址上,而指針是一種特殊的變量,它存儲的是一個變量的內(nèi)存地址。
我們可以通過指針訪問變量的內(nèi)存地址,也可以通過指針訪問或修改這個變量的內(nèi)存地址存儲的值。
1. 指針的聲明與初始化
使用 & 符號來獲取變量的內(nèi)存地址,使用 * 獲取指針指向的內(nèi)存地址的值:
var a int = 10
var a_ptr *int = &a
fmt.Println("a 的內(nèi)存地址是: ", &a)
fmt.Println("a_ptr 的值是: ", a_ptr)
fmt.Println("根據(jù)指針獲取的值是: ", *a_ptr)
2. 指針操作
使用 * 獲取變量指向的內(nèi)存地址的值后,可以直接使用,也可以對其進(jìn)行修改,在上面操作后,我們接著操作:
*a_ptr = 20
fmt.Println("修改后 a 的值是: ", a)
可以看到,通過指針修改后,a 的值已經(jīng)變成了 20。
3. 指針作為函數(shù)傳參
如果我們將指針作為函數(shù)的參數(shù)傳入,并且在函數(shù)內(nèi)部對其進(jìn)行了修改,那么會直接修改指針?biāo)赶虻淖兞康闹担旅媸且粋€示例:
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. 值類型與引用類型包括的數(shù)據(jù)類型
值類型包括整型、浮點(diǎn)型、布爾型、字符串、數(shù)組、結(jié)構(gòu)體等,值類型的變量直接存儲值,內(nèi)存通常分配在棧上。
引用類型包括切片、映射、通道等,引用類型的變量存儲的是一個引用(內(nèi)存地址),內(nèi)存通常分配在堆上。
2. 棧和堆
值類型的變量通常分配在棧上,引用類型的變量通常分配在堆上,注意,這里是通常,還會有特殊情況后面再介紹。
先來介紹一下棧和堆。
1) 棧
先介紹一下棧相關(guān)的信息:
- 棧內(nèi)存由編譯器自動管理,在函數(shù)調(diào)用時分配,函數(shù)返回后立即釋放,效率極高
- 棧上變量的生命周期嚴(yán)格限定在函數(shù)執(zhí)行期間。函數(shù)調(diào)用開始,變量被創(chuàng)建并分配內(nèi)存;函數(shù)調(diào)用結(jié)束,變量占用的內(nèi)存會被立即回收
2) 堆
- 堆用于存儲程序運(yùn)行期間動態(tài)分配的內(nèi)存,其分配和釋放不是由函數(shù)調(diào)用的生命周期決定,而是由程序員或垃圾回收機(jī)制來管理。
- 堆上的變量生命周期不依賴于函數(shù)調(diào)用的結(jié)束,變量可以在函數(shù)調(diào)用結(jié)束后仍然存在,直到?jīng)]有任何引用指向它,然后由垃圾回收機(jī)制進(jìn)行回收。
3. 值類型與引用類型的內(nèi)存分配
值類型變量通常具有明確的生命周期,通常與其所在的函數(shù)調(diào)用相關(guān),函數(shù)調(diào)用結(jié)束后,這些變量占用的內(nèi)存可以立即被回收,使用棧來存儲值類型可以充分利用棧的高效內(nèi)存管理機(jī)制。
而引用類型的變量需要動態(tài)分配內(nèi)存,并且其生命周期可能超出函數(shù)調(diào)用的范圍,比如切片可以動態(tài)調(diào)整大小,映射也可以增減鍵值對,這些操作需要在運(yùn)行時進(jìn)行內(nèi)存的分配和釋放,使用堆來存儲引用類型可以更好地支持這些動態(tài)特性。
前面介紹值類型通常會被分配到棧上,但是也有可能被分配到堆上,這種情況就是內(nèi)存逃逸。
內(nèi)存逃逸的內(nèi)容在下一個小節(jié)中再介紹。
4. 值類型和引用類型的復(fù)制
值類型的復(fù)制會復(fù)制整個數(shù)據(jù),是深拷貝的操作,副本的修改不會影響到原始數(shù)據(jù),比如下面的操作:
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
}
而引用類型的復(fù)制則復(fù)制的是其引用,屬于淺拷貝的操作,多個變量會共享底層數(shù)據(jù),修改其中一個副本會影響原始數(shù)據(jù),比如下面的操作:
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. 值類型和引用類型的函數(shù)傳參
值類型和引用類型的函數(shù)傳參和復(fù)制一樣,值類型傳遞的是變量的副本,在函數(shù)內(nèi)部修改不會影響原始變量,而引用類型傳遞的是原始數(shù)據(jù)的引用,函數(shù)內(nèi)部修改會影響外部變量。
下面是值類型的函數(shù)傳參的示例:
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]
}
對于函數(shù)傳參,還有兩點(diǎn)需要注意,一個是值類型函數(shù)傳參的性能問題,一個是引用類型涉及擴(kuò)容的問題。
1) 值類型函數(shù)傳參的性能問題
對于值類型變量,比如一個結(jié)構(gòu)體,擁有非常多的字段,當(dāng)其作為函數(shù)傳參,傳遞的會是變量的副本,也就是會將其值復(fù)制出來傳遞,那么當(dāng)這個變量非常大的時候可能就會涉及性能問題。
為了解決這個問題,有個方法就是傳遞其變量的指針,但是需要注意傳遞指針在函數(shù)內(nèi)部對其修改后,會影響到原始變量的值。
2) 引用類型函數(shù)傳參擴(kuò)容問題
當(dāng)引用類型作為函數(shù)傳參,如果在函數(shù)內(nèi)部修改涉及到擴(kuò)容,那么其地址就會更改,那么函數(shù)內(nèi)部的修改就不會反映到其原值上了,比如下面這個是切片在函數(shù)內(nèi)部修改的示例:
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、內(nèi)存逃逸
Golang 里編譯器決定內(nèi)存分配位置是在棧上還是在堆上,這個就是逃逸分析,這個過程發(fā)生在編譯階段。
1. 逃逸分析的方法
我們可以使用下面的命令來查看逃逸分析的結(jié)果:
go build -gcflags="-m" main.go
2. 內(nèi)存逃逸的場景
內(nèi)存逃逸可能會存在于以下這些情況,比如函數(shù)返回一個值類型變量的指針,或者閉包引用局部變量等。
1) 函數(shù)返回局部變量的指針
如果一個函數(shù)返回值是變量的指針,那么該局部變量會逃逸到堆上:
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) 閉包引用局部變量
如果閉包引用了函數(shù)的局部變量,這些局部變量會逃逸到堆上,因?yàn)殚]包可能在函數(shù)調(diào)用結(jié)束后繼續(xù)存在并訪問這些變量:
func counter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
_ = counter()
}
對此使用逃逸分析的命令,輸出結(jié)果如下:
# command-line-arguments
./main.go:14:2: moved to heap: count
./main.go:15:9: func literal escapes to heap
3) 向接口類型變量賦值
當(dāng)我們將值賦給接口類型的變量,因?yàn)榻涌陬愋托枰龠\(yùn)行時才能確定具體的類型,所以這個值也會逃逸到堆上,最常見的一個例子就是 fmt.Println():
func main() {
s := "hello world"
fmt.Println(s)
}
其逃逸分析結(jié)果如下:
# command-line-arguments
./main.go:25:13: ... argument does not escape
./main.go:25:14: s escapes to heap
除此之外,還有一些原因也可能造成內(nèi)存逃逸,比如大對象超出了棧容量限制,被強(qiáng)制分配到堆、發(fā)送變量到 channel 等。
3. 逃逸分析的意義
內(nèi)存逃逸就是原本分配在棧上的變量被分配到了堆上,而分配到堆上的變量在函數(shù)調(diào)用結(jié)束后仍然存在,直到?jīng)]有任何引用指向它,然后由垃圾回收機(jī)制進(jìn)行回收。
所以通過逃逸分析,我們可以減輕GC(垃圾回收)的壓力。
4、減少內(nèi)存逃逸的幾種方案
- 減少堆分配,避免函數(shù)不必要的指針返回,優(yōu)先通過返回值傳遞小對象
- 避免閉包引用局部變量
- 減少使用向接口類型賦值,如 fmt.Println() 這種
- 避免大對象超出棧容量限制

浙公網(wǎng)安備 33010602011771號