内存管理一般包含三个不同的组件,分别是用户程序(Mutator)、分配器(Allocator)和收集器(Collector),当用户程序申请内存时,它会通过内存分配器申请新内存,而分配器会负责从堆中初始化相应的内存区域。
你可能会问,为什么用户程序叫作 Mutator?
在计算机科学中,特别是在与垃圾回收和并发编程相关的领域,“Mutator”(变异者)是指程序中能够修改共享状态的部分。这个术语通常与 “Collector”(收集器)一起使用,Collector 负责执行垃圾回收,而 Mutator 负责运行和修改程序的状态。
不过本文的介绍的不是 Mutator 和 Collector,而是负责分配内存的 Allocator。
线性分配器(Bump Allocator)是一种高效的内存分配方法,但是有较大的局限性。当我们使用线性分配器时,只需要在内存中维护一个指向内存特定位置的指针,如果用户程序向分配器申请内存,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针:
空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表:
TCMalloc 是由 Google 开发的一种内存分配器,主要用于优化多线程环境下的内存分配和释放性能。TCMalloc 是Thread-Caching Malloc 的缩写,即线程缓存分配器。
TCMalloc 比 glibc 中的 malloc 还要快很多。Go 的内存分配器就借鉴了 TCMalloc 的设计实现高速的内存分配,它的核心思想是使用多级缓存并将对象根据大小分类,按照类别实施不同的分配策略。
TCMalloc 中将内存分成三类,即小对象,小于256K的,中型对象,介于256K到1M的,大于1M的为大对象。
TCMalloc 不仅会区别对待大小不同的对象,还会将内存分成不同的级别分别管理,分为线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)。
Go 语言的内存分配器包含内存管理单元、线程缓存、中心缓存和页堆几个重要组件:
runtime.mspan 是 Go 内存管理的基本单元,该结构体中包含 next 和 prev 两个字段,它们分别指向了前一个和后一个 runtime.mspan。
多个连续的 Page 会组成一个 Span。Go 中的一个 Page 为 8KB。
type mspan struct {
...
next *mspan
prev *mspan
startAddr uintptr // 起始地址
npages uintptr // 页数
freeindex uintptr
allocBits *gcBits
gcmarkBits *gcBits
allocCache uint64
...
}
当用户程序或者线程向 runtime.mspan 申请内存时,它会使用 allocCache 字段以对象为单位在管理的内存中快速查找待分配的空间:
如果我们能在内存中找到空闲的内存单元会直接返回。如果找不到,上一级的组件 runtime.mcache 会为调用 runtime.mcache.refill 更新内存管理单元以满足为更多对象分配内存的需求。
runtime.spanClass 是 runtime.mspan 的跨度类,表示内存管理单元中存储的对象的大小:
type mspan struct {
...
spanclass spanClass
...
}
type spanClass uint8
Go 的内存管理模块中一共包含 67 种跨度类,表示 67 种预先设定好的对象大小。对象大小与占用的页数存储在 runtime.class_to_size 和 runtime.class_to_allocnpages 变量。
var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}
var class_to_allocnpages = [_NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, 7, 5, 8, 3, 10, 7, 4}
runtime.spanClass 是一个 uint8 类型的整数,它的前 7 位存储着跨度类的 ID,最后一位表示是否包含指针,垃圾回收会对包含指针的 runtime.mspan 结构体进行扫描。
runtime.mcache 是 Go 的线程缓存,它会与线程上的处理器(P)一一绑定,主要用来缓存用户程序申请的微小对象。每一个线程缓存都持有 68 * 2 个 runtime.mspan,这些内存管理单元都存储在结构体的 alloc 字段中。
type mcache struct {
...
alloc [numSpanClasses]*mspan // spans to allocate from, indexed by spanClass
...
}
const numSpanClasses = _NumSizeClasses << 1
其中 scan 的 mspan 表示这个 span 包含指针需要进行垃圾回收扫描。扫描的目的是找到并标记所有可达的对象,以便进行垃圾回收。
noscan 的 mspan 表示这个 span 不包含指针,无需进行垃圾回收扫描。这样的 span 可能存储的是不包含指针的对象,例如基本类型的数据。
注意,线程缓存在刚刚被初始化时是不包含 mspan 的,只有当用户程序申请内存时才会从上一级组件获取新的 mspan 满足内存分配的需求。
运行时在初始化处理器(P)时会调用 runtime.allocmcache 初始化线程缓存,该函数会在系统栈中使用 runtime.mheap 中的线程缓存分配器初始化新的 runtime.mcache 结构体:
// dummy mspan that contains no free objects.
var emptymspan mspan
func allocmcache() *mcache {
var c *mcache
systemstack(func() {
lock(&mheap_.lock)
c = (*mcache)(mheap_.cachealloc.alloc())
c.flushGen = mheap_.sweepgen
unlock(&mheap_.lock)
})
for i := range c.alloc {
c.alloc[i] = &emptymspan
}
c.nextSample = nextSample()
return c
}
就像我们在上面提到的,初始化后的 runtime.mcache 中的所有 runtime.mspan 都是空的占位符 emptymspan。
runtime.mcache.refill 会为 mcache 获取一个指定跨度类的 mspan,被替换的 mspan 不能包含空闲的内存空间,而获取的 mspan 中需要至少包含一个空闲对象用于分配内存。
func (c *mcache) refill(spc spanClass) {
s := c.alloc[spc]
s = mheap_.central[spc].mcentral.cacheSpan()
c.alloc[spc] = s
}
如上述代码所示,该方法会从中心缓存中申请新的 runtime.mspan 存储到线程缓存中,这也是向线程缓存插入内存管理单元的唯一方法。
线程缓存中还包含几个用于分配微对象的字段,下面的这三个字段组成了微对象分配器,专门管理 16 字节以下的对象:
type mcache struct {
...
// Allocator cache for tiny objects w/o pointers.
// See "Tiny allocator" comment in malloc.go.
// tiny points to the beginning of the current tiny block, or
// nil if there is no current tiny block.
//
// tiny is a heap pointer. Since mcache is in non-GC'd memory,
// we handle it by clearing it in releaseAll during mark
// termination.
//
// tinyAllocs is the number of tiny allocations performed
// by the P that owns this mcache.
tiny uintptr
tinyoffset uintptr
tinyAllocs uintptr
...
}
微分配器只会用于分配非指针类型的内存,上述三个字段中 tiny 会指向堆中的一片内存,tinyoffset 是下一个空闲内存所在的偏移量,最后的 tinyAllocs 会记录内存分配器中分配的对象个数。
runtime.mcentral 是内存分配器的中心缓存,与线程缓存不同,访问中心缓存中的内存管理单元需要使用互斥锁。
// Central list of free objects of a given size.
type mcentral struct {
spanclass spanClass
partial [2]spanSet
full [2]spanSet
}
每个中心缓存都会管理某个跨度类的内存管理单元,它会同时持有两个 runtime.spanSet,分别存储包含空闲对象和不包含空闲对象的内存管理单元。
当 mcache 的某个类别 span 的内存被分配光时,它会会通过中心缓存的 runtime.mcentral.cacheSpan 方法获取新的内存管理单元。
runtime.mheap 页堆是内存分配的核心结构体,Go 语言程序会将其作为全局变量存储,而堆上初始化的所有对象都由该结构体统一管理,该结构体中包含两组非常重要的字段,其中一个是全局的中心缓存列表 central,另一个是管理堆区内存区域的 arenas 以及相关字段。
页堆中包含一个长度为 136 的 runtime.mcentral 数组,其中 68 个为跨度类需要 scan 的中心缓存,另外的 68 个是 noscan 的中心缓存:
type mheap struct {
...
// central free lists for small size classes.
// the padding makes sure that the mcentrals are
// spaced CacheLinePadSize bytes apart, so that each mcentral.lock
// gets its own cache line.
// central is indexed by spanClass.
central [numSpanClasses]struct {
mcentral mcentral
pad [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
}
...
}
Go 所有的内存空间都由如下所示的二维矩阵 runtime.heapArena 管理,这个二维矩阵管理的内存可以是不连续的。
type mheap struct {
...
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
...
}
堆上所有的对象都会通过调用 runtime.newobject 函数分配内存,该函数会调用 runtime.mallocgc 分配指定大小的内存空间,这也是用户程序向堆上申请内存空间的必经函数。
Go 中的内存大小分类并不像 TCMalloc 那样分成小、中、大对象,而是分成微对象、小对象和大对象三种。Go 的内存分配器会根据申请分配的内存大小选择不同的处理逻辑。
类别 | 大小 |
---|---|
微对象 | (0, 16B) |
小对象 | [16B, 32KB] |
大对象 | (32KB, +∞) |
Go 内存管理核心思想可以分为以下几点:
TCMalloc 的核心思想是:
(1)内存切分,减少碎片。
(2)分级管理,无锁并降低锁的粒度。
(3)回收复用
图解 TCMalloc
内存分配器 - Go语言设计与实现
超干货!彻底搞懂Golang内存管理和垃圾回收 - 腾讯云
golang内存管理和分配机制 - Levon’s Blog