6、指针

指针

Go语言为程序员提供了控制数据结构指针的能力,但是,并不能进行指针运算,Go语言允许你控制特定集合的数据结构、分配的数量以及内存访问模式。

指针(pointer)在Go语言中可以被拆分为两个核心概念:

受益于这样的约束和拆分,Go语言的指针类型变量即拥有指针高效访问的特点,又不会发生指针偏移,从而避免了非法修改关键性数据的问题。同时,垃圾回收也比较容易对不会发生偏移的指针进行检索和回收。

切片比原始指针具备更强大的特性,而且更为安全。切片在发生越界时,运行时会报出宕机,并打出堆栈,而原始指针只会崩溃。

指针也是一种变量,只不过是一个是一种特殊的变量,指针变量对应的值是指针所指向的内存地址。

image-20240126095446989

声明

一个指针变量可以指向任何一个值的内存地址,它所指向的值的内存地址在 32 和 64 位机器上分别占用 4 或 8 个字节,占用字节的大小与所指向的值的大小无关当一个指针被定义后没有分配到任何变量时,它的默认值为 nil。指针变量通常缩写为 ptr。

var ptr *type = &v
//指针变量也可以进行自动类型推导
ptr := &v
package main

import (
	"fmt"
)

func main() {
	a := 10
	s := "hello"

	var ptr1 *int = &a
	ptr2 := &s

	fmt.Printf("%p\n", ptr1) //0xc000014098
	fmt.Printf("%p\n", ptr2) //0xc000088220
}

通过指针获取指向的值(解引用)

当使用&操作符对普通变量进行取地址操作并得到变量的指针后,可以对指针使用*操作符,也就是指针取值

package main

import (
	"fmt"
)

func main() {
	a := 10

	ptr := &a

	fmt.Printf("%p\n", ptr)  //0xc000014098
	fmt.Printf("%d\n", *ptr) //10
}

使用指针修改值

package main

import (
	"fmt"
)

func main() {
	a := 10
	b := 20
	changeByPoint(&a, &b)
	fmt.Println(a, b) // 20 10
}

func changeByPoint(a, b *int) {
	c := *a
	*a = *b
	*b = c
}

使用指针获取命令行参数

package main

import (
	"flag"
	"fmt"
)

func main() {
	//定义命令行参数
	//方法三个参数分别为:参数名称、默认值、用法,返回值为*string
	na := flag.String("name", "lucy", "your name")
	//解析命令行参数
	flag.Parse()
	fmt.Println(*na)
}

new

由于在Go中,引用数据类型使用前必须手动分配内存空间,指针也是引用数据类型,指针在未分配数据内存空间时为nil,所以必须使用new方法进行分配后,才可以操作指针。

package main

import (
	"fmt"
)

func main() {
	var a *int
	fmt.Println(a, a == nil)
	// 未分配内存的指针,无法操作
	*a = 10
	fmt.Println(a)
}

/*
	<nil> true
	panic: runtime error: invalid memory address or nil pointer dereference
	[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x10420ac0c]
*/

new使用方法

var ptr *type = new(type)
ptr := new(type)

new() 函数可以创建一个对应类型的指针,创建过程会分配内存,被创建的指针指向对应类型的默认值(零值)。

package main

import (
	"fmt"
)

func main() {
	// 声明*int类型的变量,并分配内存
	var a *int = new(int)
	fmt.Println(a, a == nil)
	fmt.Println(*a)
	*a = 10
	fmt.Println(*a)
}

/*
	0x1400000e0b8 false
	0
	10
*/

内存分析

栈(Stack)是一种拥有特殊规则的线性表数据结构。

栈只允许从线性表的同一端放入和取出数据,按照后进先出(LIFO,Last In First Out)的顺序,如下图所示

img

往栈中放入元素的过程叫做入栈。入栈会增加栈的元素数量,最后放入的元素总是位于栈的顶部,最先放入的元素总是位于栈的底部。

从栈中取出元素时,只能从栈顶部取出。取出元素后,栈的元素数量会变少。最先放入的元素总是最后被取出,最后放入的元素总是最先被取出。不允许从栈底获取数据,也不允许对栈成员(除了栈顶部的成员)进行任何查看和修改操作

变量和栈的关系

func calc(a, b int) int {
    var c int
    c = a * b
    var x int
    x = c * 10
    return x
}

上面的代码在没有任何优化的情况下,会进行变量 c 和 x 的分配过程。Go语言默认情况下会将 c 和 x 分配在栈上,这两个变量在 calc() 函数退出时就不再使用,函数结束时,保存 c 和 x 的栈内存再出栈释放内存,整个分配内存的过程通过栈的分配和回收都会非常迅速。

堆(heap)在内存分配中类似于往一个房间里摆放各种家具,家具的尺寸有大有小,分配内存时,需要找一块足够装下家具的空间再摆放家具。经过反复摆放和腾空家具后,房间里的空间会变得乱七八糟,此时再往这个空间里摆放家具会发现虽然有足够的空间,但各个空间分布在不同的区域,没有一段连续的空间来摆放家具。此时,内存分配器就需要对这些空间进行调整优化

img

堆分配内存和栈分配内存相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。

变量逃逸分析(Escape Analysis)

Go语言中的变量逃逸指的是编译器自动将一个变量从局部(函数内部)变量变为堆上分配的全局变量的情况。

堆和栈各有优缺点,该怎么在编程中处理这个问题呢?在 C/C++语言中,需要开发者自己学习如何进行内存分配,选用怎样的内存分配方式来适应不同的算法需求。比如,函数局部变量尽量使用栈;全局变量、结构体成员使用堆分配等。

Go语言将这个过程整合到了编译器中,命名为“变量逃逸分析”。通过编译器分析代码的特征和代码的生命周期,决定应该使用堆还是栈来进行内存分配。

逃逸分析

go run -gcflags "-m -l" main.go

-gcflags 参数是编译参数。其中 -m 表示进行内存分配分析,-l 表示避免程序内联,也就是避免进行程序优化。

常见逃逸

函数返回指针

当函数返回一个局部变量的指针时,编译器将不得不将该变量分配到堆上,以便在函数返回后仍然可以访问它。

package main

import "fmt"

func dummy() *int {
	a := 10
	return &a
}

func main() {
	b := dummy()
	fmt.Println(*b)
}
go run -gcflags "-m -l" main.go
# command-line-arguments
./main.go:6:2: moved to heap: a
./main.go:12:13: ... argument does not escape
./main.go:12:14: *b escapes to heap # 变量b逃逸到堆
10
闭包

如果一个局部变量被一个闭包函数引用,那么编译器可能会将该变量逃逸到堆上,以确保闭包可以继续访问它,即使函数返回了。

package main

import "fmt"

func dummy() func() {
	a := 10
	return func() {
		fmt.Println(a)
	}
}

func main() {
	dummy()()
}
go run -gcflags "-m -l" main.go
# command-line-arguments
./main.go:7:9: func literal escapes to heap
./main.go:8:14: ... argument does not escape
./main.go:8:15: a escapes to heap # 变量a逃逸到堆
10
长寿命变量

如果一个局部变量的生命周期比函数的生命周期长,编译器可能会将它逃逸到堆上。这通常发生在使用go关键字启动的 goroutine 中,因为 goroutine 可能会在函数返回后继续执行。

package main

import "fmt"

func dummy() {
	a := 10
	go func() {
		fmt.Println(a)
	}()
}

func main() {
	dummy()
}
go run -gcflags "-m -l" main.go
# command-line-arguments
./main.go:7:5: func literal escapes to heap
./main.go:8:14: ... argument does not escape
./main.go:8:15: a escapes to heap # 变量a逃逸到堆

逃逸分析怎么完成的

Go逃逸分析最基本的原则是**:如果一个函数返回对一个变量的引用,那么它就会发生逃逸。**

简单来说,编译器会分析代码的特征和代码生命周期,Go中的变量只有在编译器可以证明在函数返回后不会再被引用的,才分配到栈上,其他情况下都是分配到堆上。

Go语言里没有一个关键字或者函数可以直接让变量被编译器分配到堆上,相反,编译器通过分析代码来决定将变量分配到何处。

对一个变量取地址,可能会被分配到堆上。但是编译器进行逃逸分析后,如果考察到在函数返回后,此变量不会被引用,那么还是会被分配到栈上。

简单来说,编译器会根据变量是否被外部引用来决定是否逃逸:

  1. 如果函数外部没有引用,则优先放到栈中;
  2. 如果函数外部存在引用,则必定放到堆中;

针对第一条,可能放到堆上的情形:定义了一个很大的数组,需要申请的内存过大,超过了栈的存储能力。

避免逃逸

变量逃逸的主要影响是性能。堆分配和垃圾收集通常比栈上分配更昂贵,所以尽量避免变量逃逸是一种优化 Go 代码的方式。在编写 Go 代码时,可以使用以下技巧来减少变量逃逸:

  1. 避免返回指针:如果可能的话,避免从函数返回局部变量的指针。相反,尽量返回局部变量的值。
  2. 限制闭包的作用范围:尽量将闭包函数内部引用的变量限制在局部范围,以减少逃逸。
  3. 减小对象生命周期:确保局部变量的生命周期不超出需要的范围。如果一个变量只在函数内部使用,那么应该在函数内部定义它,而不是将它逃逸到堆上。