《Go语言四十二章经》第二十四章 指针和内存

作者:李骁

24.1 指针

一个指针变量可以指向任何一个值的内存地址。它指向那个值的内存地址,在 32 位机器上占用 4 个字节,在 64 位机器上占用 8 个字节,并且与它所指向的值的大小无关。当然,可以声明指针指向任何类型的值来表明它的原始性或结构性;你可以在指针类型前面加上*号(前缀)来获取指针所指向的内容,这里的*号是一个类型更改器。使用一个指针引用一个值被称为间接引用。

当一个指针被定义后没有分配到任何变量时,它的值为 nil。

一个指针变量通常缩写为 ptr。

符号 “*“ 可以放在一个指针前,如 “*intP”,那么它将得到这个指针指向地址上所存储的值;这被称为反引用(或者内容或者间接引用)操作符;另一种说法是指针转移。

对于任何一个变量 var, 如下表达式都是正确的:var == *(&var)

注意事项:

你不能得到一个数字或常量的地址,下面的写法是错误的。

例如:

  1. const i = 5
  2. ptr := &i // error: cannot take the address of i
  3. ptr2 := &10 // error: cannot take the address of 10

所以说,Go 语言和 C、C++ 以及 D 语言这些低级(系统)语言一样,都有指针的概念。

但是对于经常导致 C 语言内存泄漏继而程序崩溃的指针运算(所谓的指针算法,如:pointer+2,移动指针指向字符串的字节数或数组的某个位置)是不被允许的。

Go 语言中的指针保证了内存安全,更像是 Java、C# 和 VB.NET 中的引用。

因此 c = *p++ 在 Go 语言的代码中是不合法的。

指针的一个高级应用是你可以传递一个变量的引用(如函数的参数),这样不会传递变量的拷贝。指针传递是很廉价的,只占用 4 个或 8 个字节。当程序在工作中需要占用大量的内存,或很多变量,或者两者都有,使用指针会减少内存占用和提高效率。被指向的变量也保存在内存中,直到没有任何指针指向它们,所以从它们被创建开始就具有相互独立的生命周期。

另一方面(虽然不太可能),由于一个指针导致的间接引用(一个进程执行了另一个地址),指针的过度频繁使用也会导致性能下降。

指针也可以指向另一个指针,并且可以进行任意深度的嵌套,导致你可以有多级的间接引用,但在大多数情况这会使你的代码结构不清晰。

如我们所见,在大多数情况下 Go 语言可以使程序员轻松创建指针,并且隐藏间接引用,如:自动反向引用。

对一个空指针的反向引用是不合法的,并且会使程序崩溃:

  1. package main
  2. func main() {
  3. var p *int = nil
  4. *p = 0
  5. }

panic: runtime error: invalid memory address or nil pointer dereference

指针的使用方法:

  • 定义指针变量;

  • 为指针变量赋值;

  • 访问指针变量中指向地址的值;

  • 在指针类型前面加上*号来获取指针所指向的内容。

  1. package main
  2. import "fmt"
  3. func main() {
  4. var a, b int = 20, 30 // 声明实际变量
  5. var ptra *int // 声明指针变量
  6. var ptrb *int = &b
  7. ptra = &a // 指针变量的存储地址
  8. fmt.Printf("a 变量的地址是: %x\n", &a)
  9. fmt.Printf("b 变量的地址是: %x\n", &b)
  10. // 指针变量的存储地址
  11. fmt.Printf("ptra 变量的存储地址: %x\n", ptra)
  12. fmt.Printf("ptrb 变量的存储地址: %x\n", ptrb)
  13. // 使用指针访问值
  14. fmt.Printf("*ptra 变量的值: %d\n", *ptra)
  15. fmt.Printf("*ptrb 变量的值: %d\n", *ptrb)
  16. }

24.2 new() 和 make() 的区别

看起来二者没有什么区别,都在堆上分配内存,但是它们的行为不同,适用于不同的类型。

  • new(T) 为每个新的类型T分配一片内存,初始化为 0 并且返回类型*T的内存地址:这种方法 返回一个指向类型为 T,值为 0 的地址的指针,它适用于值类型如数组和结构体;它相当于 &T{}。

  • make(T) 返回一个类型为 T 的初始值,它只适用于3种内建的引用类型:切片、map 和 channel。

你并不总是知道变量是分配到栈还是堆上。在C++中,使用new创建的变量总是在堆上。在Go中,即使是使用 new() 或者 make() 函数来分配,变量的位置还是由编译器决定。编译器根据变量的大小和泄露分析的结果来决定其位置。这也意味着在局部变量上返回引用是没问题的,而这在C或者C++这样的语言中是不行的。

如果你想知道变量分配的位置,在”go build”或”go run”上传入”-m” “-gcflags”(即,go run -gcflags -m app.go)。

  1. go run -gcflags -m main.go
  2. # command-line-arguments
  3. .\main.go:12:31: m.Alloc / 1024 escapes to heap
  4. .\main.go:11:23: main &m does not escape
  5. .\main.go:12:12: main ... argument does not escape

24.3 垃圾回收和 SetFinalizer

Go 开发者不需要写代码来释放程序中不再使用的变量和结构占用的内存,在 Go 运行时中有一个独立的进程,即垃圾收集器(GC),会处理这些事情,它搜索不再使用的变量然后释放它们的内存。可以通过 runtime 包访问 GC 进程。

通过调用 runtime.GC() 函数可以显式的触发 GC,但这只在某些罕见的场景下才有用,比如当内存资源不足时调用 runtime.GC(),它会在此函数执行的点上立即释放一大片内存,此时程序可能会有短时的性能下降(因为 GC 进程在执行)。

如果想知道当前的内存状态,可以使用:

  1. var m runtime.MemStats
  2. runtime.ReadMemStats(&m)
  3. fmt.Printf("%d Kb\n", m.Alloc / 1024)

上面的程序会给出已分配内存的总量,单位是 Kb。进一步的测量参考文档页面。

如果需要在一个对象 obj 被从内存移除前执行一些特殊操作,比如写到日志文件中,可以通过如下方式调用函数来实现:

  1. runtime.SetFinalizer(obj, func(obj *typeObj))

func(obj *typeObj) 需要一个 typeObj 类型的指针参数 obj,特殊操作会在它上面执行。func 也可以是一个匿名函数。

在对象被 GC 进程选中并从内存中移除以前,SetFinalizer 都不会执行,即使程序正常结束或者发生错误。