前言

golang是自动内存管理和自动gc的,了解golang的内存细节不是必须的。但是如果明白golang内存方面的概念和编译时、运行时的内存管理细节对写出更高质量的代码是很有帮助的。

本文会介绍内存块申请(memory block allocation)的实现和原理,编译时和运行时的垃圾回收方面的内容。

内存块/存储块(memory blocks)

内存块是连续的内存段,用于在运行时托管值部分(value parts )。不同的内存块可能有不同的大小,以便寄存不同的value parts。一个内存块可能同时寄存多个的value parts,但是每个value part只能寄存在一个内存块中,不管这个value part有多大。

简单来说value parts就是一个值存放在不同内存块的部分(Later, we call the parts (being distributed on different memory blocks) of a value as value parts)。比如go channel底层就有3个队列实现。

一个内存块可能包含多个value parts有很多原因。其中几个:

  • 一个struct值通常有几个字段。所以当申请一个内存块用来给一个struct值用时,这个内存块也会用来存放这些字段值。
  • 一个数组通常会有很多的元素。所以当申请一个内存块用来给数组用时,这个内存块也会用来存放数据元素。
  • 两个slices的基础元素序列可以托管在同一存储块上,并且元素序列还可以相互交叠。

值-引用-承载其value parts的内存块

(A Value References the Memory Blocks Which Host Its Value Parts)

我们知道一个value part可以引用其它的value part。这里,我们延申这个定义:一个内存块被它所存储的所有的value parts引用。所以如果一个value part V 被另外的一个value part引用,则另一个值也将间接引用存放v的内存块。

内存块什么时候被申请的

下列是部分申请情形:

  • 显式地调用new和make内置函数。一个new调用总是只分配一个内存块。一个make调用将会分配多于一个的内存块,用来存放创建的slice,map或channel的直接部分和间接部分。
  • 创建maps, slices和匿名函数过程中会分配多于一个的内存块。
  • 声明变量(declare variables)
  • 将非接口值赋给接口值(assign non-interface values to interface values )。当非接口值不是一个指针值时。
  • 连接 非-常量字符串(concatenate non-constant strings)。
  • 将字符串转换为字节片或符文片,反之亦然,除了一些特殊的编译器优化情况。(convert strings to byte or rune slices, and vice versa)
  • 将整形转换成字符串。convert integers to strings。
  • 调用内置函数append (当对应的slice的容量(capacity)不够时)。
  • 给map插入新的键值对时(底下的hash table需要调用大小时)

在哪里分配内存块

对于由官方标准Go编译器编译的每个Go程序,在运行时,每个goroutine将维护一个堆栈,这是一个内存段。它充当一些内存块的存储池,以便从中分配内存。每个goroutine的初始堆栈大小很小(在64位系统上约为2k字节)。堆栈大小将在goroutine运行中根据需要增大和缩小。

(请注意,对于标准的Go编译器,每个goroutine可以拥有的堆栈大小是有限制的,标准的Go compiler 1.11其默认值,64位系统是1GB,32位系统是250MB。可以通过标准runtime/debug包中的SetMaxStack函数修改其值)

内存块可以在堆栈中分配。在一个goroutine中的堆栈分配的内存块只能被goroutine内部使用。它们是goroutine的本地资源。跨goroutines引用是不安全的。一个goroutine可以访问或修改托管在这个goroutine堆栈上分配的存储块上的value parts,而无需使用任何数据同步技术。

堆(Heap)是每个程序中的一个单例。这是一个虚拟的概念。如果一个内存块没有在任何的goroutine的堆栈上分配,然后我们说它是在堆上分配的。Value parts存放在堆上的内存块时是可以被多个goroutines使用的。换言之,是可以用于并发的。必要时应同步使用。

堆是一个候选的分配内存块的地方。如果编译器检测到一个内存块需要被跨goroutines引用或不好确定内存块在一个goroutine的堆栈中是否安全时,在运行时就会在堆上分配这个内存块。也就是说,有些值的堆栈上安全的,在堆上也是安全的。

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

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

如果在某处分配了内存块,我们也可以说这个内存块中的value parts是在同一个地方。(If a memory block is allocated somewhere, we can also say the value parts hosted on the memory block are allocated on the same place.)

如果函数中部分的本地变量的value parts在堆上分配,我们可以说值部分(和变量)转储到堆中。(If some value parts of a local variable declared in a function is allocated on heap, we can say the value parts (and the variable) escape to heap. )

借助Go工具链,我们可以通过运行

go build -gcflags -m

来检查哪些本地变量(value parts)会在运行时逃逸到椎上。如上所述,当前标准的Go编译器的逃逸分析器(escape analyzer)还不很完美,很多local value parts本可以存储在堆栈上即存到堆上。

一个在使用中的并在堆上分配的value part,必须至少被一个在堆栈上分配的value part所引用(An active value part allocated on heap still in use must be referenced by at least one value part allocated on a stack)。*如果一个声明为本地变量的值逃逸到椎上,同时假设它的类型是T,Go在运行时将在当前goroutine的堆栈上创建一个类型为类型T的隐含指针。*指针的值保存变量在堆上的内存块地址(也就是类型T的局部变量的地址)。Go编译器将在运行时用指针指向的值替换所有使用到这个变量的值。从稍后开始,堆栈上的 T指针值可能会被标记为无效,因此从它到堆上T值的引用关系将消失。从堆栈上的* T值到堆上的T值的引用关系在垃圾收集过程中起着重要作用,下面将对此进行描述。

简单来说,我们可以视package-level变量是在堆上的,被一个隐式指针引用的值是在全局内存区分配的。实际上,隐式指针引用了package-level变量的直接部分,而变量的直接部分则引用了其他一些value parts。

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

  • 如果一个struct值中的一个字段逃逸到堆上,那整个struct值都移到堆上。
  • 如果数组中一个元素逃逸到堆上,那整个数组也逃逸到堆上。slice也一样。
  • 如果值v被另外一个引用的值逃逸到堆上,它也将逃逸到堆上。

new函数创建的内存块可能在堆栈或堆上。

什么时候内存块被回收?

由package-level的变量申请的内存块永远不会被回收。

一个goroutine的堆栈在这个goroutine退出时回收。所以在stack上分配的内存块不用回收。stacks不是由垃圾回收器回收的。

heap上的内存块,只有在goroutine堆栈和全局存储区上分配的所有值部分都不再(直接或间接)引用它时,才可以安全地回收它。这种情况下,这些内存块也称为不再使用的内存块(unused memory blocks)。

这是一个示例,显示何时可以收集一些内存块:

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 als 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.

	// ...
}
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))
}

上面示例中,如果我们希望bs在fmt.Println调用前不被回收的话,可以调用runtime.KeepAlive告诉垃圾回收器不回收先。

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编译器(版本1.15)使用并发的三色标记清除垃圾收集器。

不再使用的内存块什么时候被检测?

垃圾回收不是一直进行的,是在一定的阈值下才触发。可以通过runtime/debug.SetGCPercent 函数来设置这个环境变量。小的阈值频率更高,负数是禁用自动垃圾回收。

也可以 runtime.GC 显式开启回收。

参考,好吧是部分翻译。。。

https://go101.org/article/memory-block.html