Go 内存分配器的设计与实现

真没什么逻辑
系统设计、微服务架构和云原生技术
程序中的数据和变量都会被分配到程序所在的虚拟内存中,内存空间包含两个重要区域 — 栈区(Stack)和堆区(Heap) 。函数调用的参数、返回值以及局部变量大都会被分配到栈上,这部分内存会由编译器进行管理;不同编程语言使用不同的方法管理堆区的内存,C++ 等编程语言会由工程师主动申请和释放内存,Go 以及 JAVA 等编程语言会由工程师和编译器共同管理,堆中的对象由内存分配器分配并由垃圾收集器回收 。
不同的编程语言会选择不同的方式管理内存,本节会介绍 Go 语言内存分配器,详细分析内存分配的过程以及其背后的设计与实现原理 。
设计原理内存管理一般包含三个不同的组件,分别是用户程序(Mutator)、分配器(Allocator)和收集器(Collector)[^1],当用户程序申请内存时,它会通过内存分配器申请新的内存,而分配器会负责从堆中初始化相应的内存区域 。

Go 内存分配器的设计与实现

文章插图
 
mutator-allocator-collector
图 7-1 内存管理的组件
Go 语言的内存分配器实现非常复杂,在分析内存分配器的实现之前,我们需要了解内存分配的设计原理,帮助我们更快掌握内存的分配过程 。这里将要详细介内存分配器的分配方法以及 Go 语言内存分配器的分级分配方法、虚拟内存布局和地址空间 。
分配方法编程语言的内存分配器一般包含两种分配方法,一种是线性分配器(Sequential Allocator,Bump Allocator),另一种是空闲链表分配器(Free-List Allocator),这两种分配方法有着不同的实现机制和特性,本节会依次介绍它们的分配过程 。
线性分配器线性分配(Bump Allocator)是一种高效的内存分配方法,但是有较大的局限性 。当我们在编程语言中使用线性分配器,我们只需要在内存中维护一个指向内存特定位置的指针,当用户程序申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针:
Go 内存分配器的设计与实现

文章插图
 
bump-allocator
图 7-2 线性分配器
根据线性分配器的原理,我们可以推测它有较快的执行速度,以及较低的实现复杂度;但是线性分配器无法在内存被释放时重用内存 。如下图所示,如果已经分配的内存被回收,线性分配器是无法重新利用红色的这部分内存的:
Go 内存分配器的设计与实现

文章插图
 
bump-allocator-reclaim-memory
图 7-3 线性分配器回收内存
正是因为线性分配器的这种特性,我们需要合适的垃圾回收算法配合使用 。标记压缩(Mark-Compact)、复制回收(Copying GC)和分代回收(Generational GC)等算法可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能了 。
因为线性分配器的使用需要配合具有拷贝特性的垃圾回收算法,所以 C 和 C++ 等需要直接对外暴露指针的语言就无法使用该策略,我们会在下一节详细介绍常见垃圾回收算法的设计原理 。
空闲链表分配器空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构 。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表:
Go 内存分配器的设计与实现

文章插图
 
free-list-allocator
图 7-4 空闲链表分配器
因为不同的内存块以链表的方式连接,所以使用这种方式分配内存的分配器可以重新利用回收的资源,但是因为分配内存时需要遍历链表,所以它的时间复杂度就是。空闲链表分配器可以选择不同的策略在链表中的内存块中进行选择,最常见的就是以下四种方式: