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

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

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

内存块(memory block)

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

一个内存块可能承载若干值部的原因有很多,这里仅列出一部分:
  • 一个结构体值很可能由若干字段,所以当为此结构体值开辟了一个内存块时,此内存块同时也将承载此结构体值的各个字段值(的直接部分)。
  • 一个数组值常常包含很多元素,所以当为此数组值开辟了一个内存块时,此内存块同时也将承载此数组值的各个元素值(的直接部分)。
  • 两个切片的底层间接部分的元素序列可能承载在同一个内存块上,这两个间接值部甚至可能有部分重叠。

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

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

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

在Go中,在下列场合(不限于)将发生开辟内存块的操作:
  • 显式地调用newmake内置函数。 一个new函数调用总是只开辟一个内存块。 一个make函数调用有可能会开辟多个内存块来承载创建的切片/映射/通道值的直接和底层间接值部。
  • 使用字面量创建映射、切片或函数值。在此创建过程中,一个或多个内存块将被开辟出来。
  • 声明变量。
  • 将一个非接口值赋给一个接口值。(对于标准编译器来说,不包括将一个指针值赋给一个接口值的情况。)
  • 衔接非常量字符串。
  • 将字符串转换为字节切片或者码点切片,或者反之,除了一些编译器优化情形
  • 将一个整数转换为字符串。
  • 调用内置append函数并且基础切片的容量不足够大。
  • 向一个映射添加一个键值条目并且此映射底层内部的哈希表需要改变容量。

内存块将被开辟在何处?

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

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

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

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

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

事实上,栈对于Go程序来说并非必要。Go程序中所有的内存块都可以开辟在堆上。 支持栈只是为了让Go程序的运行效率更高。
  • 从栈上开辟内存块比在堆上快得多;
  • 开辟在栈上的内存块不需要被垃圾回收;
  • 开辟在栈上的内存块对CPU缓存更加友好。

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

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

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

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

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

一些事实:
  • 如果一个结构体值的一个字段逃逸到了堆上,则此整个结构体值也逃逸到了堆上。
  • 如果一个数组的某个元素逃逸到了堆上,则此整个数组也逃逸到了堆上。
  • 如果一个切片的某个元素逃逸到了堆上,则此切片中的所有元素都将逃逸到堆上,但此切片值的直接部分可能开辟在栈上。
  • 如果一个值部v被一个逃逸到了堆上的值部所引用,则此值部v也将逃逸到堆上。

使用内置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)。
}

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

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

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

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

  • 在每一轮(见下一节的解释)垃圾回收过程的开始,所有的内存块将被标记为白色。
  • 然后垃圾回收器将所有开辟在栈和全局内存区上的内存块标记为灰色,并把它们加入一个灰色内存块列表。
  • 循环下面两步直到灰色内存块列表为空:
    1. 从个灰色内存块列表中取出一个内存块,并把它标记为黑色。
    2. 然后扫描承载在此内存块上的指针值,并通过这些指针找到它们引用着的内存块。 如果一个引用着的内存块为白色的,则将其标记为灰色并加入灰色内存块列表;否则,忽略之。

(注意这里在算法中使用三色而不是两色的原因是此标记过程是并发的。在标记的过程中,很多其它普通用户协程也正在运行中。 在此标记过程中对指针的写入需要一些额外的开销,欲更深入了解此点,请以“write barrier golang”为关键字自行搜索以深入了解。 简而言之,当在某个用户协程中,一个已经标记为黑色的内存块在标记过程中被修改而使其新引用着的一个仍标记为白色的内存块时,此白色内存块需要被标记为灰色,否则此白色内存块有可能将被认为是垃圾而回收掉;除此之外的情况不做特殊处理。)

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

此垃圾回收算法不会移动内存块来整理内存碎片。

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

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

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

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

另一点需要注意的是,当前的官方Go运行时(v1.15)同时采取了另一个策略:一个Go程序的最大垃圾回收时间间隔为两分钟

以后的Go运行时版本可能会采取不同的垃圾回收策略。

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

文档更新时间: 2020-12-19 15:40   作者:kuteng