内存块

11 阅读10分钟

原文:go101.org/article/mem…

Go是一种支持自动内存管理的语言,例如自动内存分配和自动垃圾回收。因此,Go程序员在编程时无需处理底层繁琐的内存管理工作。这不仅带来了极大的便利,为Go程序员节省了大量时间,还有助于他们避免许多因疏忽导致的错误。

尽管Go程序员编写Go代码时,了解底层内存管理的实现细节并非必要,但理解标准Go编译器和运行时在内存管理实现中的一些概念并知晓一些事实,对编写高质量的Go代码非常有帮助。

本文将阐释标准Go编译器和运行时在内存块分配与垃圾回收实现中的一些概念,并列举相关事实。本文不会涉及内存管理中的其他方面,如内存申请和内存释放。 

内存块

内存块是运行时用于存放值部分的连续内存段。不同的内存块可能有不同大小,以存放不同的值部分。一个内存块可以同时存放多个值部分,但每个值部分只能存放在一个内存块内,无论该值部分的大小如何。换句话说,对于任何值部分,它永远不会跨越内存块。

一个内存块可能存放多个值部分,原因有很多,其中一些如下: 

  • 一个结构体值通常有几个字段。所以当为一个结构体值分配一个内存块时,该内存块也会存放这些字段值(的直接部分)。
  • 一个数组值通常有很多元素。所以当为一个数组值分配一个内存块时,该内存块也会存放数组元素值(的直接部分)。
  • 两个切片的底层元素序列可能存放在同一个内存块上,这两个元素序列甚至可能相互重叠。 

一个值引用存放其值部分的内存块。

我们已经知道一个值部分可以引用另一个值部分。在此,我们扩展引用的定义,即一个内存块会被它所承载的所有值部分引用。所以,如果一个值部分`v`被另一个值部分引用,那么另一个值也会间接引用承载`v`的内存块。 

内存块何时会被分配?

在Go语言中,内存块可能会在以下情况(但不限于这些情况)下被分配:

  • 显式调用内置函数`new`和`make`。一次`new`调用总是恰好分配一个内存块。一次`make`调用会分配多个内存块,用于存放所创建的切片、映射或通道值的直接部分和底层部分。 
  • 使用相应的字面量创建映射、切片和匿名函数。在每个过程中可能会分配多个内存块。 
  • 声明变量。 
  •  将非接口值赋给接口值(当非接口值不是指针值时)。 
  • 拼接非常量字符串。
  •  字符串与字节切片或符文切片之间的转换,反之亦然,某些特殊的编译器优化情况除外。 
  • 将整数转换为字符串。 
  • 调用内置的`append`函数(当基础切片的容量不够大时)。 
  • 向映射中添加新的键 - 元素对(当底层哈希表需要调整大小时)。 

内存块会分配在何处?

对于由官方标准Go编译器编译的每个Go程序,在运行时,每个goroutine都会维护一个栈,这是一个内存段。它充当一个内存池,供一些内存块从中分配。在Go工具链1.19之前,在Windows平台上栈的初始大小始终为8KiB,在其他平台上为2KiB。自Go工具链1.19起,初始大小变为自适应的。goroutine的栈会在其运行过程中根据需要增长和收缩。在Windows平台上最小栈大小为8KiB,在其他平台上为2KiB。 

(请注意,每个goroutine所能达到的栈大小存在全局限制。如果一个goroutine在扩展其栈时超过了这个限制,程序就会崩溃。截至Go工具链1.25.n,在64位系统上默认最大栈大小为1GB,在32位系统上为250MB。我们可以调用runtime/debug标准包中的SetMaxStack函数来更改这个大小。还要注意的是,按照当前官方标准Go编译器的实现,实际允许的最大栈大小是不大于MaxStack设置的2的最大幂次方。所以对于默认设置,在64位系统上实际允许的最大栈大小是512MiB,在32位系统上是128MiB。)

内存块可以在栈上分配。在一个goroutine的栈上分配的内存块只能在该goroutine内部使用(引用)。它们是goroutine本地资源。跨goroutine引用它们是不安全的。一个goroutine可以在不使用任何数据同步技术的情况下,访问或修改位于其栈上分配的内存块中的值部分。 

(关于哪些内存块会在栈上分配,请阅读《Go优化101》一书中“栈与逃逸分析”章节。)

堆在每个程序中是唯一的,它是一个虚拟概念。如果一个内存块没有在任何goroutine的栈上分配,那么我们就说这个内存块是在堆上分配的。位于堆上分配的内存块中的值部分可以被多个goroutine使用。换句话说,它们可以被并发使用。必要时,对它们的使用应该进行同步。 

堆是分配内存块较为保守的地方。如果编译器检测到某个内存块会被跨协程引用,或者难以确定该内存块是否能安全地放置在某个协程的栈上,那么在运行时该内存块就会在堆上分配。这意味着一些本可以安全地在栈上分配的值,也可能会在堆上分配。

实际上,栈对于Go程序并非必不可少。Go编译器/运行时可以将所有内存块都分配在堆上。支持栈的使用只是为了让Go程序运行得更高效: 

  • 在栈上分配内存块比在堆上分配要快得多。 
  • 栈上分配的内存块无需进行垃圾回收。 
  • 栈内存块比堆内存块对CPU缓存更友好。 

如果一个内存块在某个地方被分配,我们也可以说存放在该内存块上的值部分也在同一地方被分配。

如果函数中声明的局部变量的某些值部分被分配在堆上,我们就可以说这些值部分(以及该变量)逃逸到了堆上。通过使用Go工具链,我们可以运行`go build -gcflags -m`来检查哪些局部值(值部分)在运行时会逃逸到堆上。如前所述,当前标准Go编译器中的逃逸分析器仍不完善,许多本可以安全地分配在栈上的局部值部分仍会逃逸到堆上。

分配在堆上且仍在使用的活动值部分,必须至少被分配在栈上的一个值部分所引用。如果逃逸到堆上的值是一个声明的局部变量,假设其类型为T,Go运行时会在当前goroutine的栈上创建一个类型为*T的隐式指针(及其内存块)。该指针的值存储为该变量在堆上分配的内存块的地址(也就是类型为T的局部变量的地址)。Go编译器还会在编译时将所有对该变量的使用替换为对指针值的解引用。栈上的*T指针值可能在之后的某个时间被标记为无效,这样从它到堆上T值的引用关系就会消失。栈上*T值到堆上T值的引用关系,在下面将要描述的垃圾回收过程中起着重要作用。 

类似地,我们可以认为每个包级变量都在堆上分配,并且该变量由一个分配在全局内存区域的隐式指针引用。实际上,这个隐式指针引用包级变量的直接部分,而变量的直接部分又引用其他一些值部分。

在堆上分配的一个内存块可能同时被分配在不同栈上的多个值部分引用。

以下是一些事实: 

  • - 如果结构体值的一个字段逃逸到堆上,那么整个结构体值也会逃逸到堆上。
  • 如果数组值的一个元素逃逸到堆上,那么整个数组值也会逃逸到堆上。
  • 如果切片值的一个元素逃逸到堆上,那么切片的所有元素也会逃逸到堆上。
  • 如果一个值(部分)v 被一个逃逸到堆上的值(部分)引用,那么值(部分)v 也会逃逸到堆上。

通过调用`new`函数创建的内存块可能分配在堆上或栈上。这与C++不同。

当一个goroutine的栈大小发生变化(增长或收缩)时,会为该栈分配一个新的内存段。因此,在栈上分配的内存块很可能会被移动,或者它们的地址会改变。结果,同样必定分配在栈上、指向这些内存块的指针也需要相应地修改。下面就是这样一个例子。 

package main

// The following directive is to prevent
// calls to the function f being inlined.
//go:noinline
func f(i int) byte {
	var a [1<<20]byte // make stack grow
	return a[i]
}

func main(){
	var x int
	println(&x)
	f(100)
	println(&x)
}

我们会发现(截至标准Go编译器v1.25.n ),两次打印出的地址是不同的。

内存块何时可以被回收?

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

当一个goroutine退出时,其栈会作为一个整体被回收。所以无需逐个单独回收栈上分配的内存块。栈不会被垃圾回收器回收。

对于在堆上分配的内存块,只有当它不再被所有分配在goroutine栈和全局内存区域上的值部分(直接或间接)引用时,它才能被安全回收。我们将这样的内存块称为未使用的内存块。堆上未使用的内存块将由垃圾回收器回收。

这里有一个示例,展示某些内存块何时可以被回收: 

package main

var p *int

func main() {
	done := make(chan bool)
	// "done" will be used in main and the following
	// new goroutine, so it will be allocated on heap.

	go func() {
		x, y, z := 123, 456, 789
		_ = z  // z can be allocated on stack safely.
		p = &x // For x and y are both ever referenced
		p = &y // by the global p, so they will be both
		       // allocated on heap.

		// Now, x is not referenced by anyone, so
		// its memory block can be collected now.

		p = nil
		// Now, y is also not referenced by anyone,
		// so its memory block can be collected now.

		done <- true
	}()

	<-done
	// Now the above goroutine exits, the done channel
	// is not used any more, a smart compiler may
	// think it can be collected now.

	// ...
}

有时,像标准Go编译器这样的智能编译器可能会进行一些优化,使得某些引用比我们预期的更早被移除。下面就是这样一个例子。

package main

import "fmt"

func main() {
	// Assume the length of the slice is so large
	// that its elements must be allocated on heap.
	bs := make([]byte, 1 << 31)

	// A smart compiler can detect that the
	// underlying part of the slice bs will never be
	// used later, so that the underlying part of the
	// slice bs can be garbage collected safely now.

	fmt.Println(len(bs))
}

请阅读“值部分”以了解切片值的内部结构。

顺便说一下,有时我们可能希望确保在调用`fmt.Println`之前,切片`bs`不会被垃圾回收,那么我们可以使用`runtime.KeepAlive`函数调用来告知垃圾回收器,切片`bs`及其引用的值部分仍在使用中。

例如, 

package main

import "fmt"
import "runtime"

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

	fmt.Println(len(bs))

	// A runtime.KeepAlive(bs) call is also
	// okay for this specified example.
	runtime.KeepAlive(&bs)
}

未使用的内存块是如何被检测到的呢?

当前的标准Go编译器(v1.25.n)使用的是一种并发的、三色标记 - 清除垃圾回收器。本文仅对该算法进行简单解释。 一次垃圾回收(GC)过程分为两个阶段:标记阶段和清除阶段。在标记阶段,垃圾回收器(实际上是一组goroutine)使用三色算法来分析哪些内存块未被使用。

以下内容引用自一篇Go博客文章,并稍作修改以使表述更清晰。

在一次GC周期开始时,所有堆内存块均为白色。GC会访问所有根对象,这些根对象是应用程序可直接访问的对象,例如全局变量和栈上的对象,并将它们标记为灰色。然后,GC选择一个灰色对象,将其标记为黑色,接着扫描该对象以查找指向其他对象的指针。当扫描发现指向白色内存块的指针时,它会将该对象标记为灰色。这个过程不断重复,直到没有灰色对象为止。此时,白色(堆)内存块被认定为不可达,可以被重新使用。

(关于为什么该算法使用三种颜色而不是两种颜色,请搜索“write barrier golang”获取详细信息。这里仅提供两个参考内容:消除STW栈重新扫描以及mbarrier.go。)

在清除阶段,被标记为未使用的内存块将被回收。

一个未使用的内存块在被回收后,可能不会立即释放给操作系统,以便它可以重新用于存储新的值部分。不过不用担心,官方Go运行时对内存的占用远低于大多数Java运行时。

该GC算法是非压缩的,所以它不会移动内存块来重新排列它们。 

新的垃圾回收过程何时会启动?

垃圾回收过程会消耗大量的CPU资源和部分内存资源。因此,并非一直都有垃圾回收过程在运行。只有当某些运行时指标达到特定条件时,才会触发新的垃圾回收过程。如何定义这些条件属于垃圾回收调优(garbage collection pacer)问题。

官方标准Go运行时的垃圾回收调优实现会随着版本不断改进。所以很难既精确描述其实现,又同时保证描述内容始终是最新的。在此,我仅列出一些关于该主题的参考文章: