在本章中,我们将研究Go编程语言(Golang)[1]的内存管理。和C/C++、Rust等一样,Go是一种静态类型的编译型语言。因此,Go不需要VM,Go应用程序二进制文件中嵌入了一个小型运行时(Go runtime),可以处理诸如垃圾收集(GC),调度和并发之类的语言功能。
首先,让我们看看Go内部的内存结构是什么样子的。
Go运行时将Goroutines(G)调度到逻辑处理器(P)上执行。每个P都有一台逻辑机器(M)。在这篇文章中,我们将使用P、M和G。如果您不熟悉Go调度程序[2],请先阅读《Go调度程序:Ms,Ps和Gs》[3]。
Goroutine调度原理
每个Go程序进程都由操作系统(OS)分配了一些虚拟内存,这是该进程可以访问的全部内存。在这个虚拟内存中实际正在使用的内存称为Resident Set(驻留内存)。该空间由内部内存结构管理,如下所示:
Go内部内存结构原理图
这是一个简化的视图,基于Go使用的内部对象。实际上,Go将内存划分和分组为页(page),就像这篇文章[4]描述的那样。
这与我们在前几章中看到的JVM[5]和V8[6]的内存结构完全不同。如您所见,这里没有分代内存。这样做的主要原因是TCMalloc[7](线程缓存Malloc),Go自己的内存分配器正是基于该模型实现的。
让我们看看Go独特的内存构造是什么样子的:
这里是Go存储动态数据(在编译时无法计算大小的任何数据)的地方。它是最大的内存块,也是进行垃圾收集(GC)的地方。
驻留内存(resident set)被划分为每个大小为8KB的页,并由一个全局mheap对象管理。
大对象(大小> 32kb的对象)直接从mheap分配。这些大对象申请请求是以获取中央锁(central lock)为代价的,因此在任何给定时间点只能满足一个P的请求。
mheap通过将页归类为不同结构进行管理的:
mspan结构
每个span存在两个,一个span用于带指针的对象(scan class),一个用于无指针的对象(noscan class)。这在GC期间有帮助,因为noscan类查找活动对象时无需遍历span。
如果mcentral没有可用的span,它将向mheap请求新页。
这是栈存储区,每个Goroutine(G)有一个栈。在这里存储了静态数据,包括函数栈帧,静态结构,原生类型值和指向动态结构的指针。这与分配给每个P的mcache不是一回事。
现在我们已经清楚了内存的组织方式,现在让我们看看程序执行时Go是如何使用Stack和Heap的。
我们使用下面的这个Go程序,代码没有针对正确性进行优化,因此可以忽略诸如不必要的中间变量之类的问题,因此,重点是可视化栈和堆内存的使用情况。
package main
import "fmt"
type Employee struct {
name string
salary int
sales int
bonus int
}
const BONUS_PERCENTAGE = 10
func getBonusPercentage(salary int) int {
percentage := (salary * BONUS_PERCENTAGE) / 100
return percentage
}
func findEmployeeBonus(salary, noOfSales int) int {
bonusPercentage := getBonusPercentage(salary)
bonus := bonusPercentage * noOfSales
return bonus
}
func main() {
var john = Employee{"John", 5000, 5, 0}
john.bonus = findEmployeeBonus(john.salary, john.sales)
fmt.Println(john.bonus)
}
与许多垃圾回收语言相比,Go的一个主要区别是许多对象直接在程序栈上分配。Go编译器使用一种称为“逃逸分析”[8]的过程来查找其生命周期在编译时已知的对象,并将它们分配在栈上,而不是在垃圾回收的堆内存中。
在编译过程中,Go进行了逃逸分析,以确定哪些可以放入栈(静态数据),哪些需要放入堆(动态数据)。我们可以通过运行带有-gcflags '-m'
标志的go build命令来查看分析的细节。对于上面的代码,它将输出如下内容:
❯ go build -gcflags '-m' gc.go
# command-line-arguments
temp/gc.go:14:6: can inline getBonusPercentage
temp/gc.go:19:6: can inline findEmployeeBonus
temp/gc.go:20:39: inlining call to getBonusPercentage
temp/gc.go:27:32: inlining call to findEmployeeBonus
temp/gc.go:27:32: inlining call to getBonusPercentage
temp/gc.go:28:13: inlining call to fmt.Println
temp/gc.go:28:18: john.bonus escapes to heap
temp/gc.go:28:13: io.Writer(os.Stdout) escapes to heap
temp/gc.go:28:13: main []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape
让我们将其可视化。单击下方图片下载幻灯片,然后翻阅幻灯片,以查看上述程序是如何执行的以及如何使用栈和堆存储器的:
正如你看到的:
您可以看到,栈是由操作系统自动管理的,而不是Go本身。因此,我们不必担心栈。另一方面,堆并不是由操作系统自动管理的,并且由于其具有最大的内存空间并保存动态数据,因此它可能会成倍增长,从而导致我们的程序随着时间耗尽内存。随着时间的流逝,它也变得支离破碎,使应用程序变慢。解决这些问题是垃圾收集的初衷。
Go的内存管理包括在需要内存时自动分配内存,在不再需要内存时进行垃圾回收。这是由标准库完成的(译注:应该是运行时完成的)。与C/C++不同,开发人员不必处理它,并且Go进行的基础管理得到了高效的优化。
许多采用垃圾收集的编程语言都使用分代内存结构来使收集高效,同时进行压缩以减少碎片。正如我们前面所看到的,Go在这里采用了不同的方法,Go在构造内存方面有很大的不同。
Go使用线程本地缓存(thread local cache)来加速小对象分配,并维护着scan/noscan的span来加速GC。这种结构以及整个过程避免了碎片,从而在GC期间无需做紧缩处理。让我们看看这种分配是如何发生的。
Go根据对象的大小决定对象的分配过程,分为三类:
微小对象(Tiny)(size <16B):使用mcache的微小分配器分配大小小于16个字节的对象。这是高效的,并且在单个16字节块上可完成多个微小分配。
微小分配
小对象(尺寸16B〜32KB):大小在16个字节和32k字节之间的对象被分配在G运行所在的P的mcache的对应的mspan size class上。
小对象分配
在微小型和小型对象分配中,如果mspan的列表为空,分配器将从mheap获取大量的页面用于mspan。如果mheap为空或没有足够大的页面满足分配请求,那么它将从操作系统中分配一组新的页(至少1MB)。
大对象(大小> 32KB):大于32 KB的对象直接分配在mheap的相应大小类上(size class)。如果mheap为空或没有足够大的页面满足分配请求,则它将从操作系统中分配一组新的页(至少1MB)。
大对象分配
注意:您可以在此处[9]找到以幻灯片形式记录的GIF图像
现在我们知道Go如何分配内存了,让我们再看看它是如何自动回收堆内存的,这对于应用程序的性能非常重要。当程序尝试在堆上分配的内存大于可用内存时,我们会遇到内存不足的错误(out of memory)。不当的堆内存管理也可能导致内存泄漏。
Go通过垃圾回收机制管理堆内存。简单来说,它释放了孤儿对象(orphan object)使用的内存,所谓孤儿对象是指那些不再被栈直接或间接(通过另一个对象中的引用)引用的对象,从而为创建新对象的分配腾出了空间。
从Go 1.12版本[10]开始,Go使用了非分代的、并发的、基于三色标记和清除的垃圾回收器。收集过程大致如下所示,由于版本之间的差异,我不想做细节的描述。但是,如果您对此感兴趣,那么我推荐这个很棒的系列文章[11]。
当完成一定百分比(GC百分比)的堆分配,GC过程就开始了。收集器将在不同工作阶段执行不同的工作:
让我们在一个Goroutine中看看这个过程。为了简洁起见,将对象的数量保持较小。单击下面图片,可下载幻灯片,然后翻阅幻灯片查看该过程:
我们看到这里有一些停止世界(stop)的过程,但是通常这个过程非常快,在大多数情况下可以忽略不计。对象的着色在span的gcmarkBits属性中进行。
这篇文章为您提供了Go内存结构和内存管理的概述。这里不是全面详尽的说明,有许多更高级的概念,实现细节在各个版本之间都在不断变化。但是对于大多数Go开发人员来说,这些信息就已经足够了,我希望它能帮助您编写出更好的、性能更高的应用程序,牢记这些,将有助于您避免下一个内存泄漏问题。
Go编程语言(Golang): https://tonybai.com/tag/go
[2]Go调度程序: https://tonybai.com/2017/11/23/the-simple-analysis-of-goroutine-schedule-examples
[3]《Go调度程序:Ms,Ps和Gs》: https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.html
[4]这篇文章: https://tonybai.com/2020/02/20/a-visual-guide-to-golang-memory-allocator-from-ground-up
[5]JVM: https://deepu.tech/memory-management-in-jvm/
[6]V8: https://deepu.tech/memory-management-in-v8/
[7]TCMalloc: http://goog-perftools.sourceforge.net/doc/tcmalloc.html
[8]“逃逸分析”: https://www.ardanlabs.com/blog/2017/05/language-mechanics-on-escape-analysis.html
[9]此处: https://speakerdeck.com/deepu105/go-memory-allocation
[10]Go 1.12版本: https://tonybai.com/2019/03/02/some-changes-in-go-1-12/
[11]系列文章: https://www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html
转自:
https://juejin.cn/post/7107533102083211301
- EOF -
Go 开发大全
参与维护一个非常全面的Go开源技术资源库。日常分享 Go, 云原生、k8s、Docker和微服务方面的技术文章和行业动态。
关注后获取
回复 Go 获取6万star的Go资源库
分享、点赞和在看
支持我们分享更多好文章,谢谢!