程序运行时,会将对象写入内存。在某些情况下,当这些对象不再需要的时候,它们应该被移除。这个过程称为内存管理。本文旨在介绍内存管理,然后深入探讨如何通过使用垃圾收集器在Go中实现内存管理。多年来,Go的内存管理已经发生了很多变化,未来很可能还会发生更多变化。如果你正在阅读这篇文章,并且你使用的是1.16之后的Go版本,那么其中一些信息可能已经过时了。
人工管理内存
在C语言中,程序员会调用malloc或calloc函数来为对象分配内存。这些函数返回一个指针指向堆内存中的位置。当不再需要此对象时,程序员调用free函数清除,之后可再次使用此内存块。这种内存管理方法被称为显式回收,效率很高。它使程序员能够更好地控制正在使用的内存,从而实现内存管理优化,特别是在低内存环境中。然而,这种人工管理内存方式很容易造成两种编程错误:
- 如果过早地调用free释放内存会产生空指针。空指针是不再指向内存中的有效对象指针。当这个指针稍后被访问时,无法保证在内存中该位置存在什么值。可能什么都没有,或者是其他任何值。
- 如果程序员忘记释放内存,随着越来越多的对象填满内存,可能会面临内存泄漏。如果程序耗尽内存,会导致程序运行很慢或崩溃。所以显式管理内存方式,可能会引入不可预测的错误。
自动管理内存
这就是为什么像Go这样的语言提供自动、动态内存管理,或者更简单地说,垃圾收集。具有垃圾收集功能的语言提供了以下好处:
1、增加安全性
2、跨操作系统可移植
3、更少的代码
4、代码运行时校验
5、数组的边界检查
垃圾收集有性能开销,但并不像通常想象的那么严重。而且程序员可以专注于他们的业务逻辑,而不用担心内存管理。
程序运行时会将对象存储在两个内存位置:堆和栈。垃圾收集操作的是堆,而不是堆栈。堆栈是使用LIFO数据结构来存储函数值的。一个函数调用另一个函数时会将其数据帧推入堆栈,堆栈将包含该函数的值,以此类推。当被调用的函数返回时,它的栈帧从堆栈中弹出。你在调试程序时,会经常碰到报错打印出调用栈信息。大多数语言编译器都会返回一个堆栈跟踪来帮助调试,显示在报错之前调用了哪些函数。
相反,堆包含函数外部引用的值。例如,在程序开始时定义的静态常量,或者更复杂的对象,比如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都存储在这个堆栈上。
3、当addtwonnumbers被调用时,带有两个函数参数的新堆栈帧被推送到堆栈上。
4、当addtwonnumbers完成执行时,它的结果返回给myFunction,并且addtwonnumbers的堆栈帧将弹出堆栈,因为它不再需要了。
5、指向testStruct的指针,其值存放在堆上,并更新值字段。
6、 myFunction执行结束,为它创建的堆栈被清理。testStruct的值一直在堆上,直到发生垃圾收集。
testStruct现在在堆上,Go运行时不知道是否仍然需要它。为此,Go依赖于垃圾收集器。垃圾收集器有两个关键部分:mutator和collector。collector执行垃圾收集逻辑并找到应该释放内存的对象。mutator执行应用程序代码并将新对象分配给堆。它还在程序运行时更新堆上的对象,这包括标记一些不再需要的对象。
Go垃圾收集器实现
Go的垃圾收集器是一个非分代并发、采用三色标记和清除。让我们解释下这些关键字。
分代:假设短期对象(如临时变量)回收更频繁。因此,分代垃圾收集器关注最近分配的对象。然而,正如前面提到的,Go编译器将已知生命周期的对象分配到堆栈。这意味着堆上的对象会更少,因此垃圾收集的对象也会更少。这意味着在Go中分代垃圾收集器并无优势。因此,Go使用非分代垃圾收集器。并发意味着收集器与mutator线程同时运行。因此,Go使用非分代、并发垃圾收集器。标记和清除是垃圾收集器的类型,三色标记是用来实现这一点的算法。
垃圾收集有两个阶段,即标记和清除。在标记阶段,收集器遍历堆并标记不再需要的对象。清楚阶段将删除这些对象。标记和清除是一种间接算法,因为标记正在使用对象,并删除其他不需要使用对象。
GO实现标记清除步骤:
Go让所有goroutine通过一个叫做stop the world的机制达到垃圾收集的安全点。这将暂时停止程序的运行,并打开写屏障以维护堆上的数据完整性。这允许goroutines和收集器同时运行,从而实现并发性。
一旦所有的goroutine都打开了写屏障,Go运行时就继续运行程序,并执行垃圾收集工作。
标记是用三色法实现的。当标记开始时,除了灰色的根对象外,所有对象都是白色的。根是所有其他堆对象的来源,并作为运行程序的一部分实例化。垃圾收集器通过扫描堆栈、全局变量和堆指针,标记正在使用的内容。当扫描一个堆栈时,停止goroutine,并通过从根向下遍历将所有找到的对象标记为灰色。然后,又恢复goroutine。这里说明下根对象是从程序开始运行的地方,一般为main函数里的初始对象。
灰色的对象最后标记成黑色,表明他们仍然在使用。一旦所有灰色对象都变成黑色,收集器将再次停止程序并清除所有不再需要的白色对象。程序现在可以继续运行,直到再次需要清理内存。
一旦程序按照所使用的内存比例分配了更多的内存,垃圾收集就会再次启动。' GOGC '环境变量决定了这一点,并且默认设置为100。Go的源代码是这样描述的:
如果GOGC=100并且内存使用4M,当达8M时,将再次GC(这个标记在next_gc变量中)。这使得GC与内存分配成线性比例。调整GOGC能改变下一次发生GC的内存使用比例。
Go的垃圾收集器通过将内存管理抽象到运行时中来提高效率,这是Go如此高性能的原因之一。Go内置了一些工具,允许您优化程序中垃圾收集的发生方式,如果您感兴趣,可以研究下这些工具。通过本文,希望您对垃圾收集的工作原理以及它在Go中是如何实现的有更多了解。