博客 / 詳情

返回

Golang基礎筆記七之指針,值類型和引用類型

本文首發於公眾號:Hunter後端

原文鏈接:Golang基礎筆記七之指針,值類型和引用類型

本篇筆記介紹 Golang 裏的指針,值類型與引用類型相關的概念,以下是本篇筆記目錄:

  1. 指針
  2. 值類型與引用類型
  3. 內存逃逸
  4. 減少內存逃逸的幾種方案

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) 棧

先介紹一下棧相關的信息:

  1. 棧內存由編譯器自動管理,在函數調用時分配,函數返回後立即釋放,效率極高
  2. 棧上變量的生命週期嚴格限定在函數執行期間。函數調用開始,變量被創建並分配內存;函數調用結束,變量佔用的內存會被立即回收
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、減少內存逃逸的幾種方案

  1. 減少堆分配,避免函數不必要的指針返回,優先通過返回值傳遞小對象
  2. 避免閉包引用局部變量
  3. 減少使用向接口類型賦值,如 fmt.Println() 這種
  4. 避免大對象超出棧容量限制
user avatar libo96 頭像 jzuc 頭像 abai_681266b7f0de8 頭像
3 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.