内存管理和垃圾回收

静态类型的编译型语言,如Golang、Rust、C/C++都是不需要VM的,Go应用程序的二进制文件中嵌入了一个小型运行时(Go runtime),可以处理如垃圾收集、调度和并发之类的语言功能。

Go runtime的内存分配算法主要源自 Google 为 C 语言开发的TCMalloc算法,全称Thread-Caching Malloc。核心思想就是把内存分为多级管理,从而降低锁的粒度。它将可用的堆内存采用二级分配的方式进行管理:每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。

Go runtime将Goroutines(G)调度到逻辑处理器(P)上执行,每个P都有一台逻辑机器(M)。

Go调度

内存结构

每个Go进程在启动的时候,会向操作系统(OS)申请一大块内存(注意,这时还只是一段虚拟的地址空间,并不会正真地分配内存)切成小块后自行管理,这是该进程可以访问的全部内存

申请到的内存块被分配了三个区域:在x64上分别是:512MB,16GB,512GB大小,在这个虚拟内存中实际正在使用的内存称为Resident Set(驻留内存),如下图所示:

内存
  • arena区域就是所谓的堆区,Go动态分配的内存都是在这个区域,它把内存分割成8KB大小的页,一些页组合起来称为mspan。

  • bitmap区域标识arena区域哪些地址保存了对象,并且用4bit标志位表示对象是否包含指针、GC标记信息。bitmap中一个byte大小的内存对应arena区域中4个指针大小(指针大小为 8B)的内存,所以bitmap区域的大小是512GB/(4*8B)=16GB

  • spans区域存放mspan(也就是arena分割的页组合起来的内存管理基本单元)的指针,每个指针对应一页,所以spans区域的大小就是512GB/8KB*8B=512MB

除以8KB是计算arena区域的页数,而最后乘以8是计算spans区域所有指针的大小。创建mspan的时候,按页填充对应的spans区域,在回收object时,根据地址很容易就能找到它所属的mspan。

该空间由内部内存结构管理,如下图所示:

Go内存结构

内存管理中的多级缓存,如下图所示:

Go多级缓存

Go语言的内存分配器包含:

  • 内存管理单元:mspan

  • 线程缓存:mcache

  • 中心缓存:mcentral

  • 页堆:mheap

全景图

内存管理单元:mspan

mspan是mheap中管理的内存页的最基本结构。这是一个双向链接列表,其中包含起始页面的地址,span size class和span中的页面数量。像TCMalloc一样,Go将内存页按大小分为67个不同类别,大小从8字节到32KB,如下图所示,每一块表示一种大小规格的mspan,每一行表示一个双向链表,将同一种大小规格的mspan连接起来:

image

每个span存在两个,一个span用于带指针的对象(scan class),一个用于无指针的对象(noscan class)。这在GC期间很有用,因为noscan类查找活动对象时无需遍历span。

将msapn放到更大的维度来看:

mspan

线程缓存:mcache

mcache:这是一个非常有趣的构造。mcache是提供给 P(逻辑处理器)的高速缓存,用于存储小对象(对象大小<=32KB)。尽管这类似于线程堆栈,但它是堆的一部分,用于动态数据。所有种类大小的mcache包含scan和noscan类型mspan。Goroutine可以从mcache没有任何锁的情况下获取内存,因为一次P只能有一个G锁,这更有效。mcache需要时会从mcentral中请求新的span。

mcache

中心缓存:mcentral

mcentral将相同大小级别的span归类在一起。每个mcentral包含两个mspanList:

  • empty:双向span链表,包括没有空闲对象的span或缓存mcache中的span。当此处的span被释放时,它将被移至non-empty span链表。

  • non-empty:有空闲对象的span双向链表。当从mcentral请求新的span,mcentral将从该链表中获取span并将其移入empty span链表。

如果mcentral没有可用的span,它将向mheap请求新页。

mcentral

页堆:mheap

这里是Go存储动态数据(在编译时无法计算大小的任何数据)的地方。它是最大的内存块,也是进行垃圾收集(GC)的地方。

驻留内存(resident set)被划分为每个大小为8KB的页,并由一个全局mheap对象管理。

大对象(大于32kb的对象)直接从mheap分配。这些大对象申请请求是以获取中央锁(central lock)为代价的,因此在任何给定时间点只能满足一个P的请求。

mheap通过将页堆归类为不同结构进行管理的,如下图所示:

mheap

arena:堆在已分配的虚拟内存中根据需要增长和缩小。当需要更多内存时,mheap从虚拟内存中以每块64MB(对于64位体系结构)为单位获取新内存, 这块内存被称为arena。这块内存也会被划分页并映射到span。

这是栈存储区,每个Goroutine(G)有一个栈。在这里存储了静态数据,包括函数栈帧,静态结构,原生类型值和指向动态结构的指针。这与分配给每个P的mcache不是一回事

堆栈的使用

与许多垃圾回收语言相比,Go的一个主要区别是许多对象直接在程序栈上分配

Go编译器使用一种称为"逃逸分析"的过程来查找其生命周期在编译时已知的对象,并将它们分配在栈上,而不是在垃圾回收的堆内存中。在编译过程中,Go进行了逃逸分析,以确定哪些可以放入栈(静态数据),哪些需要放入堆(动态数据)。

可以通过运行带有-gcflags '-m'标志的go build命令来查看分析的细节。

具体过程如下图所示:

栈的使用
堆的使用
  • main函数被保存栈中的“main栈帧”中

  • 每个函数调用都作为一个栈帧块被添加到栈中

  • 包括参数和返回值在内的所有静态变量都保存在函数的栈帧块内

  • 无论类型如何,所有静态值都直接存储在栈中,这也适用于全局范畴

  • 所有动态类型都在堆上创建,并且被栈上的指针所引用,小于32Kb的对象由P的mcache分配。这同样适用于全局范畴

  • 具有静态数据的结构体保留在栈上,直到在该位置将任何动态值添加到该结构中为止,该结构被移到堆上

  • 从当前函数调用的函数被推入堆顶部

  • 当函数返回时,其栈帧将从栈中删除

  • 一旦主过程(main)完成,堆上的对象将不再具有来自栈的指针的引用,并成为孤儿对象

栈是由操作系统自动管理的,而不是Go本身。因此,不必担心栈。另一方面,堆并不是由操作系统自动管理的,并且由于其具有最大的内存空间并保存动态数据,因此它可能会成倍增长,从而导致程序随着时间耗尽内存。随着时间的流逝,它也变得支离破碎,使应用程序变慢。解决这些问题是垃圾收集的初衷

内存分配

Go的内存管理包括在需要内存时自动分配内存,在不再需要内存时进行垃圾回收,这是由Go runtime完成的。与C/C++不同,开发人员不必处理它,并且Go进行的基础管理得到了高效的优化。

许多采用垃圾收集的编程语言都使用分代内存结构来使收集高效,同时进行压缩以减少碎片。

Go在这里采用了不同的方法,Go在构造内存方面有很大的不同。Go使用线程本地缓存(thread local cache)来加速小对象分配,并维护着scan/noscan的span来加速GC。这种结构以及整个过程避免了碎片,从而在GC期间无需做紧缩处理。

Go根据对象大小将它们分成微对象、小对象和大对象,根据大小选择不同的分配逻辑:

  • 微对象 (0, 16B) — 先使用微型分配器,再依次尝试mcache、mcentral和mheap

  • 小对象 (16B, 32KB) — 依次尝试使用mcache、mcentral和mheap

  • 大对象 (32KB, +∞) — 直接在mheap分配内存

垃圾收集

如何自动回收堆内存,这对于应用程序的性能非常重要。当程序尝试在堆上分配的内存大于可用内存时,会遇到内存不足的错误(out of memory)。不当的堆内存管理也可能导致内存泄漏。

Go通过垃圾回收机制管理堆内存,释放孤儿对象(orphan object)使用的内存,所谓孤儿对象是指那些不再被栈直接或间接(通过另一个对象中的引用)引用的对象,从而为创建新对象的分配腾出了空间。

从Go 1.12版本开始,Go使用了非分代的、并发的、基于三色标记和清除的垃圾回收器

当完成一定百分比(GC百分比)的堆分配,GC过程就开始了。收集器将在不同工作阶段执行不同的工作:

  • 标记设置(mark setup, stw):GC启动时,收集器将打开写屏障(write barrier),以便可以在下一个并发阶段维护数据完整性。此步骤需要非常小的暂停(stw),因此每个正在运行的Goroutine都会暂停以启用此功能,然后继续。

  • 标记(并发执行的):打开写屏障后,实际的标记过程将并行启动,这个过程将使用可用CPU能力的25%。对应的P将保留,直到该标记过程完成。这个过程是使用专用的Goroutines完成的。在这个过程中,GC标记了堆中的活动对象(被任何活动的Goroutine的栈中引用的)。当采集花费更长的时间时,该过程可以从应用程序中征用活动的Goroutine来辅助标记过程。这称为Mark Assist。

  • 标记终止(stw):标记一旦完成,每个活动的Goroutine都会暂停,写入屏障将关闭,清理任务将开始执行。GC还会在此处计算下一个GC目标。完成此操作后,保留的P的会释放回应用程序。

  • 清除(并发):当完成收集并尝试分配后,清除过程开始将未标记为活动的对象回收。清除的内存量与分配的内存量是同步的(即回收后的内存马上可以被再分配了)。

标记设置
并行标记
标记颜色
标记终止
  • 以一个Goroutine为例,实际过程是对所有活动Goroutine都进行的。首先打开写屏障。

  • 标记过程选择GC root并将其着色为黑色,并以深度优先的树状方式遍历该该根节点里面的指针,将遇到的每个对象都标记为灰色

  • 当它到达noscan span中的某个对象或某个对象不再有指针时,它完成了这个根节点的标记操作并选取下一个GC root对象

  • 当扫描完所有GC root节点之后,它将选取灰色对象,并以类似方式继续遍历其指针

  • 如果在打开写屏障时,指向对象的指针发生任何变化,则该对象将变为灰色,以便GC对其进行重新扫描

  • 当不再有灰色对象留下时,标记过程完成,并且写屏障被关闭

  • 当分配开始时(因为写屏障关闭了),清除过程也会同步进行

这里有一些停止世界(stop)的过程,但是通常这个过程非常快,在大多数情况下可以忽略不计。对象的着色在span的gcmarkBits属性中进行。

最后更新于

这有帮助吗?