当程序运行时,它们会把对象写入内存。在某些时候,当这些对象不在被需要的时候,应该将其移除。这个过程叫做内存管理。这篇文章旨在内存管理的概述,然后深入了解在Go中,是如何通过垃圾回收实现的。在过去几年,可以看到Go很多内存管理的改变,而且在未来也许能看到更多。如果你正在阅读这篇文章,并且正在使用Go v1.16之后的版本,本文中的一些信息可能已经过时了。
手动管理内存
在像C这样的语言中,开发人员会调用如malloc或calloc这样的函数去把对象写入内存。这些函数返回一个指向该对象在堆内存中位置的指针。当该对象不在需要,开发人员调用free函数去再次使用这个内存。这种内存管理的方式被称为显式解除分配,(功能)非常强大。它让开发人员更好操控正在使用内存,这允许(开发人员)做一些类型的简单优化,特别是在低内存环境中。但是,它会导致两类编程错误。
一是过早的调用free函数,创建了一个悬挂指针。悬挂指针是指那些不再指向内存中合法对象的指针。这非常糟糕,因为程序期望着指针指向一个已定义的值。当这个指针随后被访问,无法保证它所指向的值是否存在于对应的内存地址,那里可能什么都没有,或者完全是一些其他的值。二是没有完成释放内存。如果开发人员忘记释放一个对象,可能会造成内存泄漏,因为内存会被越来越多的对象填满。如果过度使用内存,可能导致程序变慢或者崩溃。当内存被显式管理可能会引入无法预测的bug到程序中。
自动管理内存
这是为什么像Go这样的语言会提供自动化的动态内存管理,或者简单来说,垃圾回收。使用带垃圾回收的语言有以下优点:
-
安全性提升
-
更好的跨平台移植性
-
编写更少的代码
-
代码的运行时检测
-
数据边界检查
垃圾回收有性能上的开销,但不像人们想象中的那么多。这样做的好处在于,开发人员能够专注于他们程序业务逻辑并且确保它符合需求,而不需要关注内存的管理。
一个运行的程序会将对象存储在两个内存位置,堆和栈。垃圾回收在堆上面操作,而不是栈。栈是一种存储函数值的LIFO数据结构。在一个函数内调用另一个函数,会将一个新的函数帧推到栈中,这个栈会包含函数的值等一些其他信息。当调用的函数返回,它的栈帧会从栈中弹出。你可能比较熟悉来自你正在调试的一个崩解程序中的栈。大多数语言的编译器都会返回栈的轨迹信息来帮助调试,这些轨迹信息展示了到这个点之前所有被调用的函数。
相对栈而言,堆包含了那些函数外被引用的值。例如,程序启动时定义的常量,或者更复杂的对象,如Go中的struct。当开发者在堆中的定义里一个对象,将会分配所需的内存并且返回一个指向内存的指针。堆是一个图,其中对象表示为节点,这些节点在代码中或由堆中的其他对象引用。当一个程序启动,除非清理堆,否则堆将会随着对象的增加越来越大。
Go中的垃圾回收
Go倾向于在栈上分配内存,因此大多数内存分(的过程)配会在那里结束。这意味着在Go中每一个goroutine拥有一个栈,且当可能的情况下,Go会把变量分配给这个栈。Go的编译器企图通过逃逸分析去判断这个对象是否逃离了这个函数,来证明在函数外部不需要这个变量。如果编译器能够决定一个变量的生命周期,它就会被分配给一个栈。如果,这个变量的生命周期无法预知,它讲被分配到堆上。总体来看,如果一个Go程序中存在一个指向一个对象的指针,那么这个对象将存储在堆上。看一下这个代码示例:
type myStruct struct {
value int
}
var testStruct = myStruct{value: 0}
func addTwoNumbers(a int, b int) int {
return a + b
}
func myFunction() {
testVar1 := 123
testVar2 := 456
testStruct.value = addTwoNumbers(testVar1, testVar2)
}
func someOtherFunction() {
// some other code
myFunction()
// some more code
}
为了理解该示例的目的,让我们想象这段代码是一个正在运行程序中的一部分,因为如果这是整段程序,Go的编译器将会优化,把变量分配到栈上。当程序启动:
1. testStruct被定义且分配到堆上一个可用的内存块
2. myFunction函数被执行,同时在被执行时分配一个栈。testVar1和testVar2都存储在栈上
-
当
addTwoNumbers被调用,一个新的栈桢和两个参数被推入栈中 -
当
addTwoNumbers结束执行,它的结果被返还到myFunction函数中,同时它的栈帧从栈中弹出,因为它不再被需要 -
跟随指向
testStruct的指针到包含它的堆上的位置,同时更新value字段。
6. myFunction退出并清理为其创建的堆栈。testStruct的值保留在堆上,直到被垃圾回收
testStruct目前在堆上,且没有任何分析,Go的运行时不知道它是否还被需要。为此,Go依赖于垃圾回收器。垃圾回收器有两个重要的部分,mutator(赋值器) 和 collector(收集器)。收集器执行回收逻辑同时查找需要释放内存的对象。赋值器执行应用代码,在堆上分配新的对象,同时也更新程序运行过程中剩余的对象,包括将一些不再被需要的对象变为无法访问。
Go垃圾回收器的实现
Go垃圾回收的特点有,非分代并发,三色标记清扫,让我们把这些词搞清楚。
分代理论假设生命周期较短的对象(如临时变量)最常被回收。因此,一个分代垃圾回收器专注于最近被分配的对象。但是,如我们之前提到,Go编译器的优化允许编译器将已知生命周期的对象分配到栈上。这表示堆上分配的对象越少,被垃圾回收的对象越少。这意味着在Go中,分代回收不是必要的。所以,Go采用一个非分代回收的方式。并行意味着收集器作为多赋值器线程同时运行。因此,Go采用非分代并行回收的方式。标记清扫是垃圾回收的一种方式,而三色标记清扫是这种方式的一种实现。
一个标记清扫回收器有两个阶段,毫不奇怪地命名为 mark(标记) 和 sweep(清扫)。在标记阶段,收集器遍历整个堆并且标记那些不再需要的对象。接下来的清扫阶段将移除这些对象。标记清扫是一种间接的算法,因为它只标记存活的对象,而移除其他所有的对象。
Go分几步实现:
Go使用名为stop the world的进程让所有goroutine达到一个垃圾收集的安全时间点。它会暂停程序同时开启一个写屏障去维持堆数据的安全性。它通过允许goroutine和收集器同时运行来实现并发。
一旦所有goroutine打开写屏障,Go运行时便starts the world(恢复程序运行),并让工作程序执行垃圾收集工作。
基于三色算法实现标记。当标记开始,除了Roots(根对象)被标记为灰色之外,所有对象都被标记为白色。Roots是堆中其他所有对象的来源,并且被实力化作为允许程序的一部分。垃圾回收器通过扫描栈,全局变量和堆指针开始标记以了解正在使用的内存。当扫描到栈,worker线程暂定栈的goroutine,同时将从Roots向下遍历找到的所有对象标记为灰色。随后恢复goroutine。
然后将灰色对象排入队列以变为黑色,这表明它们仍在使用中。一旦所有灰色对象被变为黑色,收集者将再一次stop the world,然后开始清理所有不再被需要的白色节点。程序现在可以继续运行,直到它需要再次清理更多内存。
一旦程序正在使用的内存被分配了额外比例的内存,这个过程将再次被初始化。GOGC这个环境变量定义了这个比例,并且默认设为100.Go的源码这样描述:
如果GOGC=100且我们已经使用了4M的内存,当正在使用的内存达到8M,我们将再次启动垃圾回收(该标记在 next_gc 变量中进行跟踪)。这使 GC 成本与分配成本成线性比例。 调整GOGC只会改变线性常数(以及使用的额外内存量)。
通过讲抽象内存到运行时,Go垃圾回收提升了你的编程效率。同时也是使得Go性能如此优越的一部分原因。Go拥有内建的工具允许你去优化,如何将垃圾回收在你的程序中触发。如果你感兴趣的话,你可以进一步调研。现在,我希望你收获了更多Go中关于垃圾回收工作原理和实现的知识。
引用
Garbage Collection in Go: Part 1
Getting to Go: The Journey of Go’s Garbage Collector
Go: How Does the Garbage Collector Mark the Memory?
Golang: Cost of using the heap
Google Groups discussion, comment by Ian Lance Taylor
Implementing memory management with Golang’s garbage collector