内存块

Go是一门支持自动内存管理的语言,比如自动内存开辟和自动垃圾回收。 所以Go程序员在编程时无须进行各种纷繁的内存管理操作。 这不仅给Go程序员提供了很多便利和节省了很多开发时间,而且也帮助Go程序员避免了很多因为疏忽大意而造成的bug。

在Go编程中,尽管我们无须知道底层的自动内存管理是如何实现的,但是知道自动内存管理实现中的一些概念和事实对我们写出高质量的Go代码是非常有帮助的。

本文将解释和列出标准编译器和运行时中内存块开辟和垃圾回收实现相关的一些概念和事实。 内存管理的其它方面,比如内存申请和内存释放,将不会在本文中探讨。

内存块(memory block)

一个内存块是一段在运行时刻承载着若干值部的连续内存片段。 不同的内存块的大小可能不同,因它们所承载的值部的尺寸而定。 一个内存块同时可能承载着不同Go值的若干值部,但是一个值部在内存中绝不会跨内存块存储,无论此值部的尺寸有多大。

一个内存块可能承载若干值部的原因有很多,这里仅列出一部分:

一个值引用着承载着它的值部的内存块

我们已经知道,一个值部可能引用着另一个值部。这里,我们将引用的定义扩展到一下。 我们可以说一个内存块被它承载着各个值部所引用着。 所以,当一个值部v被另一个值部引用着时,此另一个值部也(间接地)引用着承载着值部v的内存块。

什么时候需要开辟内存块?

在Go中,在下列场合(不限于)将发生开辟内存块的操作:

内存块将被开辟在何处?

对每一个使用标准编译器编译的Go程序,在运行时刻,每一个协程将维护一个栈(stack)。 一个栈是一个预申请的内存段,它做为一个内存池供某些内存块从中开辟。 每个协程的初始栈大小比较小(在64位系统上大概2千字节)。 每个栈的大小在协程运行的时候将按照需要增长和收缩。

(注意:对于标准编译器来说,每个协程维护的栈的大小有一个最大限制。 对于Go SDK 1.11中编译器来说,此最大限制的默认值在64位系统上为1GB,在32位系统上为250MB。 我们可以在运行时刻调用runtime/debug标准库包中的SetMaxStack来修改此值。)

内存块可以被开辟在栈上。开辟在一个协程维护的栈上的内存块只能在此协程内部被使用(引用)。 其它协程是无法访问到这些内存块的。 一个协程可以无需使用任何数据同步技术而使用开辟在它的栈上的内存块上的值部。

堆(heap)是一个虚拟的概念。每个程序只有一个堆。 一般地,如果一个内存块没有开辟在任何一个栈上,则我们说它开辟在了堆上。 开辟在堆上的内存块可以被多个协程并发地访问。 在需要的时候,对承载在它们之上的值部的访问需要做同步。

如果编译器觉察到一个内存块在运行时将会被多个协程访问,或者不能轻松地断定此内存块是否只会被一个协程访问,则此内存块将会被开辟在堆上。 也就是说,编译器将采取保守但安全的策略,使得某些可以安全地被开辟在栈上的内存块也有可能会被开辟在堆上。

事实上,栈对于Go程序来说并非必要。Go程序中所有的内存块都可以开辟在堆上。 支持栈只是为了让Go程序的运行效率更高。

如果一个内存块被开辟在某处(堆上或某个栈上),则我们也可以说承载在此内存块上的各个值部也开辟在此处。

如果一个局部声明的变量的某些值部被开辟在堆上,则我们说这些值部以及此局部变量逃逸到了堆上。 我们可以运行官方Go SDK中提供的的go build -gcflags -m命令来查看代码中哪些局部值的值部在运行时刻会逃逸到堆上。 如上所述,目前官方Go编译器中的逃逸分析器并不十分完美,因此某些可以安全地开辟在栈上的值也可能会逃逸到了堆上。

在运行时刻,每一个仍在被使用中的逃逸到堆上的值部肯定被至少一个开辟在栈上的值部所引用着。 如果一个逃逸到堆上的值是一个被声明为T类型的局部变量,则在运行时,一个*T类型的隐式指针将被创建在栈上。 此指针存储着此T类型的局部变量的在堆上的地址,从而形成了一个从栈到堆的引用关系。 另外,编译器还将所有对此局部变量的使用替换为对此指针的解引用。 此*T值可能从今后的某一时刻不再被使用从而使得此引用关系不再存在。 此引用关系在下面介绍的垃圾回收过程中发挥着重要的作用。

类似地,我们可以认为每个包级变量(常称全局变量)都被开辟在了堆上,并且它被一个开辟在一个全局内存区上的隐式指针所引用着。 事实上,此指针引用着此包级变量的直接部分,此直接部分又引用着其它的值(部)。

一个开辟在堆上的内存块可能同时被开辟在若干不同栈上的值部所引用着。

一些事实:

使用内置new函数开辟的内存可能开辟在堆上,也可能开辟在栈上。这是与C++不同的一点。

一个内存块在什么条件下可以被回收?

为包级变量的直接部分开辟的内存块永远不会被回收。

每个协程的栈将在此协程退出之时被整体回收,此栈上开辟的各个内存块没必要被一个一个单独回收。 栈内存池并不由垃圾回收器回收。

对一个开在堆上的内存块,当它不再被任何开辟在协程栈的仍被使用中的,以及全局内存区上的,值部所(直接或者间接)地引用着,则此内存块可以被安全地垃圾回收了。 我们称这样的内存块为不再被使用的内存块。开辟在堆上的不再被使用的内存块将在以后某个时刻被垃圾回收器回收掉。

下面是一个展示了一些内存块在何时可以被垃圾回收的例子。
package main

var p *int

func main() {
	done := make(chan bool)
	// done数据通道将被使用在主协程和下面将要
	// 创建的新协程中,所以它将被开辟在堆上。

	go func() {
		x, y, z := 123, 456, 789
		_ = z  // z可以被安全地开辟在栈上。
		p = &x // 因为x和y都会将曾经被包级指针p所引用过,
		p = &y // 因此,它们都将开辟在堆上。

		// 到这里,x已经不再被任何其它值所引用。或者说承载
		// 它的内存块已经不再被使用。此内存块可以被回收了。

		p = nil
		// 到这里,y已经不再被任何其它值所引用。
		// 承载它的内存块可以被回收了。

		done <- true
	}()

	<-done
	// 到这里,done已经不再被任何其它值所引用。一个
	// 聪明的编译器将认为承载它的内存块可以被回收了。

	// ...
}

有时,聪明的编译器可能会做出一些出人意料的(但正确的)的优化。 比如在下面这个例子中,切片bs的底层间接值部在bs仍在使用之前就已经被标准编译器发觉已经不再被使用了。
package main

import "fmt"

func main() {
	// 假设此切片的长度很大,以至于它的元素
	// 将被开辟在堆上。
	bs := make([]byte, 1 << 31)

	// 一个聪明的编译器将觉察到bs的底层元素
	// 部分已经不会再被使用,而正确地认为bs的
	// 底层元素部分在此刻可以被安全地回收了。

	fmt.Println(len(bs))
}

关于切片值的内部实现结构,请参考值部一文。

顺便说一下,有时候出于种种原因,我们希望确保上例中的bs切片的底层间接值部不要在fmt.Println调用之前被垃圾回收。 这时,我们可以使用一个runtime.KeepAlive函数调用以便让垃圾回收器知晓在此调用之前切片bs和它所引用着的值部仍在被使用中。

一个例子:
package main

import "fmt"
import "runtime"

func main() {
	bs := make([]int, 1000000)

	fmt.Println(len(bs))
	runtime.KeepAlive(&bs)
	// 对于这个特定的例子,也可以调用
	// runtime.KeepAlive(bs)。
}

runtime.KeepAlive函数调用在使用非类型安全指针的时候常常是需要的。

如何判断一个内存块是否仍在被使用?

目前的官方Go标准运行时(1.12版本)使用一个并发三色(tri-color)标记清扫(mark-sweep)算法来实现垃圾回收。 这里仅会对此算法的原理做一个大致的描述。一个具体实现可能和此大致描述会有很多细节上的差别。

一个垃圾回收过程分为两个阶段:标记阶段和清扫阶段。

在标记阶段,垃圾回收器(实际上是一组协程)使用三色算法来分析哪些(开辟在堆上的)内存块已经不再使用了。

在清扫阶段,仍被标记为白色的内存块将被认为是不再使用的而被回收掉。

此垃圾回收算法不会移动内存块来整理内存碎片。换句话说,每个值的地址是不会变的。

不再被使用的内存块将在什么时候被回收?

开辟在堆上的不再使用的内存块将被Go运行时认为是垃圾而将被回收,以供以后重用或者释放(给操作系统)。 垃圾回收器并不是时刻都在运行着。它只是每隔一段时间因为某些条件达成之后才开始新的一轮垃圾回收过程。 所以,一个不再被使用的内存块不会在它不再使用后立即得到回收,而是将在一段时间后被逐步回收。

目前(Go 1.12),对于使用标准编译器编译的Go程序,一轮新的垃圾回收过程开启的默认条件是通过GOGC环境变量来控制的。 当从上一轮垃圾回收结束后新申请的内存块的内存总和占上一轮垃圾回收结束时仍在被使用的所有内存块的内存总和的百分比超过此值时,新的一轮垃圾回收过程将开始。 所以此值决定了垃圾回收过程的频率。 此环境变量的默认值为100。 此值也可以通过调用runtime/debug.SetGCPercent函数在运行时刻被动态地修改。 调用debug.SetGCPercent(-1)将关闭自动垃圾回收。

一轮新的垃圾回收过程也可以通过调用runtime.GC函数来手动开启。

一个不再被使用的内存块被回收后可能并不会立即释放给操作系统,这样Go运行时可以将其重新分配给其它值部使用。 不用担心,官方Go运行时的实现比大多数主流的Java运行时要消耗少得多的内存。

Go语言101项目目前同时托管在GithubGitlab上。 欢迎各位在这两个项目中通过提交bug和PR的方式来改进完善Go语言101中的各篇文章。

本书微信公众号名称为"Go 101"。每个工作日此公众号将尽量发表一篇和Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

赞赏