原文链接:https://internals-for-interns.com/posts/go-memory-allocator/
你可以将内存分配器想象成一位仓库管理员。你的程序持续需要各种尺寸的"箱子"——有时极小,有时极大——并且需要快速获取。分配器的职责就是尽可能迅速地分发这些箱子,保持仓库井然有序以避免浪费,同时与垃圾回收器协同回收那些不再被使用的箱子。
内存分配何时发生?
并非程序中的每个变量都会经过内存分配器。Go 语言有两个存放数据的地方:栈和堆。
栈是较为简单的部分。每次函数调用都会在栈上获得一小块临时空间,当函数返回时,这块空间会自动消失。这种方式快速且简单——无需记录管理。
但有时数据需要在创建它的函数结束后继续存在。可能是返回某个对象的指针,或是存储程序其他部分稍后会使用的值。这类数据不能存放在栈上——因为函数返回时它们会随之消失。所以它们会被放在堆上,这是一个生命周期更长的内存区域。
| |
| |
Go 编译器在这方面其实相当智能。它会在编译时分析你的代码,决定哪些数据可以留在栈上,哪些需要放到堆上——这被称为逃逸分析。
每当有数据被分配到堆上时,内存分配器便开始发挥作用。这个系统负责在堆上寻找可用空间并将其交付使用。这正是本文后续要探讨的核心内容。
需要稍作简化说明:在 Go 语言中,协程栈实际上是从堆中分配的——因此内存分配器为栈提供了生存空间。但栈一旦被分配,其上的变量管理与堆对象截然不同:这些变量只是栈帧内的偏移量,每个变量都不需要分配器介入管理。所以虽然分配器负责栈内存的分配,但并不参与栈上具体变量的定位。本文将重点聚焦于堆内存的管理机制。
那么分配器管理着堆内存。但最初这些内存究竟从何而来呢?
为何不直接向操作系统申请?
当你的程序需要内存时,必须有人提供它。最终,这个提供者就是操作系统。操作系统管理着你机器上的所有物理 RAM,任何需要内存的进程都必须通过系统调用来向操作系统申请,比如在 Linux/macOS 上使用mmap,或在 Windows 上使用VirtualAlloc。
问题在于系统调用速度很慢。它们涉及从用户空间切换到内核空间,操作系统进行自己的簿记,然后再切换回来。如果 Go 每次你写new或make时都进行系统调用,性能将会非常糟糕——尤其是在一种为高并发设计的语言中,成千上万的 goroutine 可能同时分配内存。
因此,Go 运行时采用了不同的方法:它预先向操作系统申请大块内存(我们稍后会看到,在大多数 64 位系统上这些块是 64MB),然后在内部管理分配。当你的代码需要 100 字节时,分配器不会去找操作系统——它会从已经拥有的内存中切出 100 字节。只有当内存耗尽时,它才会再次向操作系统申请。
这就是内存分配器背后的核心理念。它位于你的程序与操作系统之间,充当一个快速中介,通过在关键路径上避免系统调用来降低分配成本。但在内部管理所有这些内存并非易事——分配器需要追踪哪些内存正在使用、哪些空闲,并且要在不成为瓶颈的前提下完成这一切。让我们看看它是如何实现的。
竞技场与内存页
我们提到运行时会向操作系统申请大块内存。这些内存块被称为(Arenas)竞技场,在大多数 64 位系统中每个竞技场为 64MB(Windows 和 32 位系统上为 4MB,WebAssembly 上为 512KB)。
当你的程序启动并开始分配内存时,运行时会向操作系统申请第一个竞技场。随着程序需要更多内存,它会申请额外的竞技场。这些竞技场在内存中无需相邻——运行时会通过内部映射表来追踪所有竞技场。
这是否意味着 Go 会立即占用 64MB 内存?并非如此。当运行时"请求"一个内存区域时,它首先只是预留 64MB 的地址空间——可以理解为在土地上登记了你的名字,但尚未建造任何东西。此时并未使用物理内存。随后,当运行时真正需要使用该内存区域的某些部分时,它会通知操作系统以约 4MB 的块为单位使这些区域可用。即便如此,操作系统也要等到程序实际向这些地址写入数据时才会分配真实内存——物理内存是按需出现的,每次一个操作系统页面,整个过程完全透明。因此实际成本是渐进的:预留空间(基本免费),按需以 4MB 块为单位提交(每次一个系统调用),然后由操作系统在后台根据使用情况填充物理内存。这也是分配器如此快速的另一个原因——一旦内存被提交,分配器的所有操作都无需再与操作系统交互。
但 64MB 的区块直接分配实在太大了。如果你的程序只申请 32 字节,你肯定不想给它整个竞技场。因此每个竞技场会被划分为 8KB(8192 字节)的页面。这是 Go 语言自定义的页面大小——不同于操作系统通常使用的 4KB 页面。
页面是分配器内部运作的基本单位。当需要满足内存分配请求时,分配器以页面为单位进行操作——需要获取多少页面、哪些页面空闲、哪些正在使用。一个 64MB 的竞技场包含 8192 个页面(64MB/8KB),运行时系统会追踪每个页面的状态。
但对大多数内存分配来说,8KB 仍然太大了。为一个 32 字节的结构体分配整个页面显然不合理。这时就需要引入跨度(span)的概念。
跨度(Spans):对象的栖身之所
跨度是指一个或多个连续的页面,专门用于存放单一尺寸的对象。这是分配器实际向程序提供内存的层级。
让我们具体说明一下。假设你的程序需要大量 32 字节的对象。内存分配器会取一个页面(8KB),将其转换为用于 32 字节对象的跨度,并将其划分为 256 个槽位(8192 / 32 = 256)。每个槽位恰好可以容纳一个对象。当你分配一个 32 字节的对象时,分配器只需找到该跨度中的下一个空闲槽位并返回它。当你需要另一个对象时,它会获取下一个空闲槽位。快速而简单。
这是因为每个跨度(span)中的槽位大小都相同。无需寻找合适的块,跨度内没有碎片化,也不需要合并相邻的空闲块。只需找到一个空闲槽位并使用它即可。为了找到空闲槽位,每个跨度都维护一个名为 allocBits 的位图——每个槽位对应一个比特位,其中 1 表示“正在使用”,0 表示“空闲”。寻找下一个空闲槽位只需扫描下一个为 0 的比特位。跨度还记录其在内存中的起始位置、覆盖的页数、槽位总数以及当前已分配的槽位数。此外,还有一个名为 gcmarkBits 的第二位图供垃圾收集器使用——这一点我们稍后会讨论。所有这些元数据都是跨度结构本身的一部分——即运行时分配用于管理跨度的独立对象——并不存储在存放对象的页面内部。因此,一个页面(或跨度覆盖的任意多个页面)的完整 8KB 空间都可用于对象槽位。
大小类别
现在,如果每个跨度只容纳一种大小的对象,我们就需要为不同大小准备不同的跨度。但分配器不可能为每个可能的字节数都创建一个跨度——那样会变得难以管理。因此,Go 定义了 68 个大小类别,范围从 8 字节到 32KB。当你分配内存时,比如 20 字节,Go 会将其向上取整到最接近的大小类别(本例中为 24 字节),并使用该类别对应的跨度。虽然取整会损失几个字节,但换来的简洁性和速度是值得的。
以下是一些示例:
| Class 类别 | Object Size 对象大小 | Span Size (Pages) 跨度大小(页数) | Objects per Span 每个跨度的对象数 |
|---|---|---|---|
| 1 | 8 B(字节) | 8 KB (1 page) | 1024 |
| 4 | 32 B (字节) | 8 KB (1 page) | 256 |
| 10 | 128 B (字节) | 8 KB (1 page) | 64 |
| 32 | 1024 B(字节) | 8 KB (1 page) | 8 |
| 41 | 3072 B(字节) | 24 KB (3 pages) | 8 |
| 46 | 5376 B (字节) | 16 KB (2 pages) | 3 |
| 51 | 8192 B (字节) | 8 KB (1 page) | 1 |
| 60 | 18432 B (字节) | 72 KB (9 pages) | 4 |
| 65 | 27264 B (字节) | 80 KB (10 pages) | 3 |
| 67 | 32768 B (字节) | 32 KB (4 pages) | 1 |
观察表格时,页面数量可能显得有点随机——为什么 18KB 的对象需要 9 页,而 8KB 的对象只需要 1 页?规则其实很简单:从 1 页开始,持续增加页面,直到末尾的浪费空间(无法容纳另一个对象的剩余字节)小于跨度的 12.5%。对于像 32 字节这样的小对象,一页能容纳 256 个对象且无剩余空间——完美,无需更多页面。对于中等大小的对象,如 3KB 或 5KB,单页会在末尾留下太多不可用空间,因此跨度会增加到 2、3 甚至最多 10 页,以减少浪费。
你可能会注意到,有些类别每个跨度只容纳一个对象——比如类别 51(8KB)或类别 67(32KB)。这意味着在单次分配后,跨度就满了。难道使用更多页面让跨度容纳更多对象不是更好吗?不一定。更大的跨度意味着即使你只需要一两个该大小的对象,也会有更多内存被预留。对于像 32 字节这样的小对象,程序通常一次分配数百个,将 256 个打包到一个跨度中是合理的。但对于更大的对象,大多数程序只需要几个,因此保持跨度较小可以避免浪费内存。
因此,大小类别系统处理的是 8 字节到 32KB 的常见范围。但并非所有内容都能完美地适应这个范围。
边缘情况:大对象与小对象
你可能已经注意到一个奇怪的地方:我们说有 68 个大小类别,但表格只从 1 到 67——只有 67 个。缺失的那个在哪里?那就是大小类别 0,专为大于 32KB 的对象保留。与其他类别不同,它没有固定的对象大小或跨度大小。相反,类别 0 的跨度会精确分配对象所需的页面数量,不进行槽位细分——一个对象对应一个跨度。
另一方面,像 bool 或 int8 这样极小的对象会遇到另一个问题。最小的大小类别是 8 字节,所以即使是 1 字节的值也会占用一个 8 字节的槽位——对于如此小的对象来说浪费很大。为了解决这个问题,Go 有一个微型分配器,它将多个微型对象(小于 16 字节且不包含指针)打包到一个 16 字节的块中。这样,多个布尔值或小整数可以共享一个槽位,而不是各自占用一个。这极大地减少了分配大量小值时的浪费。
我们已经了解了跨度是如何按大小组织的——但大小并不是分配器唯一关心的事情。
跨度(Spans)类别:大小 + 指针
内存分配器不仅关心对象的大小,还关心对象是否包含指针。为什么?因为垃圾回收器需要扫描包含指针的对象,以便追踪引用并找到存活数据。不包含指针的对象(比如一个 [100]byte 或仅包含整数的结构体)在垃圾回收期间可以安全跳过——没有需要追踪的内容。
因此,Go 为每种情况维护独立的跨度:一种用于需要扫描的对象(scan),另一种用于不需要扫描的对象(noscan)。大小类别与扫描/非扫描标志的组合被称为跨度类别。由于有 68 种大小类别,每种又有 2 种变体,总共就有 136 种跨度类别。
综合以上,完整的图景如下所示:

分配器向操作系统请求内存区域,将其划分为页面,将页面分组为跨度,再将跨度划分为固定大小的槽位。每一级都使下一级变得易于管理。但我们还有一个尚未讨论的大问题:Go 程序同时运行许多 goroutine,它们都需要分配内存。如何在不使分配器成为瓶颈的情况下,保持这一切的有序运行?
锁竞争问题
至此我们已经了解了内存的组织方式——竞技场、页面、跨度、槽位。但还有一个关键问题尚未解决:当多个协程同时尝试分配内存时会发生什么?
假设你只有一个全局跨度列表。每次任何协程需要内存时,都必须先获取锁,找到空闲槽位,然后释放锁。当数千个协程并发运行时,这把锁就会成为瓶颈——协程花费在相互等待上的时间,比实际执行有效工作的时间还要多。
这就是锁竞争问题,而解决这个问题正是 Go 分配器设计中最重要的环节之一。解决方案采用三级层次结构,每一级都有不同的作用域和锁行为:
第一级:mcache(每个 P 独享,无需加锁)
还记得在引导程序文章中提到的,Go 调度器拥有固定数量的 P(处理器),通常每个 CPU 核心对应一个。每个 P 都有自己专属的 mcache——这是一组私有的内存跨度集合,每个跨度类别对应一个跨度。当运行在某个 P 上的 goroutine 需要分配内存时,它会从该 P 的 mcache 中获取一个槽位。由于同一时间只有一个 goroutine 在 P 上运行,因此无需加锁。这是快速路径,处理了绝大多数内存分配请求。
第二级:mcentral(按跨度类别划分,短暂加锁)
当 mcache 中某个特定跨度类别的跨度已满时,就需要获取新的跨度。这时就轮到 mcentral 发挥作用了。136 个跨度类别各自对应一个 mcentral,每个 mcentral 维护着共享的跨度池。mcache 会将已满的跨度归还给 mcentral,并从中获取一个含有空闲槽位的新跨度。这个过程需要加锁,但锁持有时间很短——仅仅是交换两个跨度。由于每个跨度类别都有独立的 mcentral,分配不同大小内存或扫描/非扫描变体的 goroutine 之间不会相互竞争。
第三级:mheap(全局,昂贵的锁)
当一个 mcentral 没有更多 span 可以分配时,它会向 mheap 请求新的页面来创建一个新的 span。mheap 是全局的页面分配器——只有一个,访问它需要一个全局锁。这是慢速路径。它涉及搜索空闲页面,可能向操作系统请求一个新的 arena,并初始化一个新的 span。但这种情况很少发生,因为上面的层级吸收了大部分的需求。
这也是我们之前提到的大对象(>32KB)最终的去处——它们跳过 mcache 和 mcentral,直接进入 mheap。
整个设计就像一个缓存链:

每一层都充当下一层的缓存。快速路径是无锁的,中等路径使用细粒度锁,而慢速路径在实际中足够罕见,其开销可以忽略不计。这一设计基于名为 tcmalloc(线程缓存分配器)的方法,最初由谷歌为 C/C++程序设计,但已针对 Go 的特定需求进行了调整。
既然我们已经理解了结构和层级,接下来让我们一步步了解实际发生的过程。
分配流程
所有操作都通过运行时中的一个单一函数完成:位于 src/runtime/malloc.go 中的 mallocgc() 。
它首先检查分配的大小。根据对象的大小,会采取截然不同的路径。让我们从最简单的情况开始。
零大小分配
一个有趣的边界情况:如果你分配一个零大小的对象,比如 var x struct{} 会怎样?Go 不会费心分配任何内存——所有零字节的分配都会返回指向同一个全局变量(zerobase)的指针。这是安全的,因为你永远无法真正通过零大小对象进行读取或写入。
现在来看真正的分配,从最小的开始。
微小对象
对于不含指针的微小对象——比如 bool 、 int8 ,或者不含指针的小型结构体——分配器会使用我们之前提到的微小分配器。mcache 会记录当前的微小块(其实就是从大小类别 2 的 span 中取出的普通 16 字节槽位——它本身没什么特别之处)以及一个偏移量,该偏移量标记了该块目前已被使用了多少。
当需要进行微小分配时,分配器首先会将大小向上取整以进行适当的对齐(根据对象大小取整为 2、4 或 8 字节),然后检查当前的微小块从当前偏移量到末尾是否有足够的空间。如果空间足够,分配器会返回一个指向 block + offset 的指针,并推进偏移量。因此,一个 1 字节的 bool 后面跟着一个 1 字节的 int8 ,它们会被打包在同一个 16 字节块内相邻的位置。
当当前的小块内存没有足够空间时,分配器会从 mcache 的 2 号尺寸类跨度中获取一个新的 16 字节槽位(使用常规小对象路径),并将对象放置在其起始位置。但这里有个微妙细节:分配器不会盲目切换到新块。它会比较旧块剩余的空闲空间与新块剩余的空间(即 16 减去刚放置的对象大小)。剩余空间更多的块将成为当前的小块内存。这种做法能最大限度地减少浪费——分配器总是倾向于为未来的微小分配保留最多空间的块。无论哪种情况,你所请求的对象都会从新槽位返回;这只是一个关于哪个块在下次微小分配时保持"当前"状态的问题。
由于微小分配器会捕获所有 16 字节以下的无指针对象,它最终会吸收你预期会落入最小尺寸类的大部分对象。实际上,8 字节尺寸类(类 1)专门用于确实包含指针的 8 字节值——比如 *int 或单指针接口。而 int64 尽管也是 8 字节,却会通过微小分配器处理。
当对象达到 16 字节或更大(或包含指针)时,微分配器不再适用,我们将进入主分配路径。
小型对象(16 字节至 32KB)
这是最常见的分配路径,也是整个层级结构优化的核心。其流程如下:
- 将所需大小向上取整至最接近的尺寸类别,并确定跨度类别(尺寸 + 扫描/非扫描属性)。
- 检查线程缓存:查找对应跨度类别的跨度,并通过位图定位下一个空闲槽位。若存在空闲槽位,则直接返回该内存块——整个过程无需加锁,仅涉及位运算操作。
- 若该跨度已满,线程缓存会将其归还至中心缓存,并请求获取含空闲槽位的新跨度。中心缓存首先在已有跨度中查找可用资源。若发现尚未被垃圾回收器清扫的跨度,会先执行清扫操作,再将其移交给线程缓存。
- 如果 mcentral 没有可用内存,它会向 mheap 请求分配新页面并创建新的 span。
- 如果 mheap 没有足够的空闲页面,它会向操作系统请求一个新的 arena。
大多数分配在步骤 2 就停止了。步骤 3-5 发生的频率逐渐降低,这就是系统性能良好的原因。
大对象(> 32KB)
正如我们之前所介绍的,这些对象完全跳过 mcache 和 mcentral,直接进入 mheap,后者会精确分配所需的页面。
我们已经了解了内存是如何分配的,但反方向呢——内存又是如何被释放的?
垃圾回收集成
内存分配器并非独立运作——它与垃圾回收器紧密相连。
垃圾收集器的任务是判断堆上哪些对象仍在被使用,哪些是垃圾。它通过遍历对象图来实现这一点——从已知的根(全局变量、栈变量等)开始,沿着指针追踪所有可达的对象。任何无法到达的对象都是死对象,可以被释放。
这就是每个跨度中两个位图发挥作用的地方。每个跨度都有一个 allocBits 位图(记录哪些槽位已分配)和一个 gcmarkBits 位图(记录垃圾收集器发现哪些槽位是存活的)。在垃圾收集周期中,收集器在 gcmarkBits 中标记存活对象。标记完成后,运行时会交换这两个位图——这样 allocBits 现在仅反映存活对象,所有未被标记的对象实际上就被释放了。分配器随后可以重用这些槽位。
这也解释了你在分配流程中可能注意到的一个现象:当 mcentral 将 span 交给 mcache 时,有时需要先进行扫描。扫描是指通过检查 span 的位图,确定在垃圾回收周期后哪些槽位处于空闲状态的过程。分配器采用惰性扫描策略——它不会一次性扫描所有 span,而是在需要为新分配提供空间时按需扫描。这种方式将扫描的开销分散到所有分配操作中,避免了集中扫描导致的长时间停顿。
如果某个 span 在扫描后完全变空(所有对象都成了垃圾),其占用的页面将返还给 mheap,并可用于其他 span 类别。
因此,垃圾回收器负责识别死亡对象,而分配器则回收这些对象占用的槽位。但还有一个问题:这些内存是否会返还给操作系统?
内存释放与回收机制
当垃圾回收器释放对象时,它们并不会归还给操作系统。这些内存槽位只是在其所属的跨度中重新变为可用状态,等待下一次分配。从操作系统的视角来看,这些页面始终由运行时系统持有——你的程序仍然占用着所有这些内存。
但如果你的程序曾出现活动高峰,分配了大量内存,而其中大部分现已变成垃圾呢?此时会有大量空闲页面闲置在内存堆中无所作为,而操作系统却认为你的程序仍在占用所有这些内存。
这时就需要清扫器登场了。它是一个后台协程,会定期查找长期未使用的空闲页面,并通知操作系统可以回收这些内存。这些页面仍会映射在你程序的地址空间中(这样运行时系统后续无需系统调用即可复用它们),但操作系统知道可以收回其背后的物理内存。在 Linux 系统中,这是通过 MADV_DONTNEED 实现的——这个提示相当于告诉系统“我暂时不需要这些内存,请随意用于其他地方。”
这是一种平衡的艺术。过早归还内存会损害性能——如果程序很快又需要那块内存,就得重新触发缺页中断。但保留过多未使用的内存又会浪费系统资源。清理器试图找到恰当的平衡点。
小结
让我们回顾一下已讨论的内容。在编译时,逃逸分析决定哪些值需要分配在堆上。在运行时,内存分配器负责实际管理堆内存。运行时不会每次都向操作系统申请内存,而是预先获取 64MB 的大块内存区域,并将其细分为 8KB 的页面。页面被分组为跨度,每个跨度包含固定大小的槽位,用于存放单一尺寸的对象——共有 68 种尺寸类别,范围从 8 字节到 32KB。扫描/非扫描的区分使跨度类别翻倍至 136 种,这样垃圾回收器就可以跳过不含指针的对象。
为了避免锁竞争,内存分配器采用三级层级结构:mcache(每个 P 独享,无锁)处理大多数分配请求,mcentral(按跨度类别划分,使用短暂锁)为 mcache 补充新的内存跨度,而 mheap(全局)在其他层级耗尽时分配页面。微小对象会被打包存放,大型对象则完全绕过此层级结构。
分配器通过每个内存跨度上的双重位图与垃圾回收器协同工作,而内存清理器确保未使用的内存最终归还给操作系统。
如果你想亲自探索实现细节,运行时源码中的 src/runtime/malloc.go 、 mheap.go 、 mcache.go 和 mcentral.go 文件注释详尽且出奇地易于阅读。