文章目录
- 1.内存管理角色
- 1.常见的内存分配方法
- 线性分配器
- 空闲链表分配器
- TCMalloc
- 2.Go 内存管理组件
- mspan
- mcache
- 初始化
- 替换
- 微分配器
- mcentral
- mheap
- 3.内存分配
- 4.内存管理思想
- 参考文献
1.内存管理角色
内存管理一般包含三个不同的组件,分别是用户程序(Mutator)、分配器(Allocator)和收集器(Collector),当用户程序申请内存时,它会通过内存分配器申请新内存,而分配器会负责从堆中初始化相应的内存区域。
你可能会问,为什么用户程序叫作 Mutator?
在计算机科学中,特别是在与垃圾回收和并发编程相关的领域,“Mutator”(变异者)是指程序中能够修改共享状态的部分。这个术语通常与 “Collector”(收集器)一起使用,Collector 负责执行垃圾回收,而 Mutator 负责运行和修改程序的状态。
不过本文的介绍的不是 Mutator 和 Collector,而是负责分配内存的 Allocator。
1.常见的内存分配方法
线性分配器
线性分配器(Bump Allocator)是一种高效的内存分配方法,但是有较大的局限性。当我们使用线性分配器时,只需要在内存中维护一个指向内存特定位置的指针,如果用户程序向分配器申请内存,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针:
空闲链表分配器
空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表:
TCMalloc
TCMalloc 是由 Google 开发的一种内存分配器,主要用于优化多线程环境下的内存分配和释放性能。TCMalloc 是Thread-Caching Malloc 的缩写,即线程缓存分配器。
TCMalloc 比 glibc 中的 malloc 还要快很多。Go 的内存分配器就借鉴了 TCMalloc 的设计实现高速的内存分配,它的核心思想是使用多级缓存并将对象根据大小分类,按照类别实施不同的分配策略。
TCMalloc 中将内存分成三类,即小对象,小于256K的,中型对象,介于256K到1M的,大于1M的为大对象。
TCMalloc 不仅会区别对待大小不同的对象,还会将内存分成不同的级别分别管理,分为线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)。
2.Go 内存管理组件
Go 语言的内存分配器包含内存管理单元、线程缓存、中心缓存和页堆几个重要组件:
- runtime.mspan
- runtime.mcache
- runtime.mcentral
- runtime.mheap
mspan
runtime.mspan 是 Go 内存管理的基本单元,该结构体中包含 next 和 prev 两个字段,它们分别指向了前一个和后一个 runtime.mspan。
多个连续的 Page 会组成一个 Span。Go 中的一个 Page 为 8KB。
type mspan struct {...next *mspanprev *mspanstartAddr uintptr // 起始地址npages uintptr // 页数freeindex uintptrallocBits *gcBitsgcmarkBits *gcBitsallocCache uint64...
}
- startAddr 和 npages — 确定该结构体管理的多个页所在的内存。
- freeindex — 扫描页中空闲对象的初始索引。
- allocBits 和 gcmarkBits — 分别用于标记内存的占用和回收情况。
- allocCache — allocBits 的补码,可以用于快速查找内存中未被使用的内存。
当用户程序或者线程向 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 结构体进行扫描。
mcache
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 mspanfunc allocmcache() *mcache {var c *mcachesystemstack(func() {lock(&mheap_.lock)c = (*mcache)(mheap_.cachealloc.alloc())c.flushGen = mheap_.sweepgenunlock(&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 uintptrtinyoffset uintptrtinyAllocs uintptr...
}
微分配器只会用于分配非指针类型的内存,上述三个字段中 tiny 会指向堆中的一片内存,tinyoffset 是下一个空闲内存所在的偏移量,最后的 tinyAllocs 会记录内存分配器中分配的对象个数。
mcentral
runtime.mcentral 是内存分配器的中心缓存,与线程缓存不同,访问中心缓存中的内存管理单元需要使用互斥锁。
// Central list of free objects of a given size.
type mcentral struct {spanclass spanClasspartial [2]spanSetfull [2]spanSet
}
每个中心缓存都会管理某个跨度类的内存管理单元,它会同时持有两个 runtime.spanSet,分别存储包含空闲对象和不包含空闲对象的内存管理单元。
当 mcache 的某个类别 span 的内存被分配光时,它会会通过中心缓存的 runtime.mcentral.cacheSpan 方法获取新的内存管理单元。
mheap
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 mcentralpad [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte}...
}
Go 所有的内存空间都由如下所示的二维矩阵 runtime.heapArena 管理,这个二维矩阵管理的内存可以是不连续的。
type mheap struct {...arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena...
}
3.内存分配
堆上所有的对象都会通过调用 runtime.newobject 函数分配内存,该函数会调用 runtime.mallocgc 分配指定大小的内存空间,这也是用户程序向堆上申请内存空间的必经函数。
Go 中的内存大小分类并不像 TCMalloc 那样分成小、中、大对象,而是分成微对象、小对象和大对象三种。Go 的内存分配器会根据申请分配的内存大小选择不同的处理逻辑。
类别 | 大小 |
---|---|
微对象 | (0, 16B) |
小对象 | [16B, 32KB] |
大对象 | (32KB, +∞) |
- 微对象 (0, 16B) — 先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存。
- 小对象 [16B, 32KB] — 依次尝试使用线程缓存、中心缓存和堆分配内存。
- 大对象 (32KB, +∞) — 直接在堆上分配内存。
4.内存管理思想
Go 内存管理核心思想可以分为以下几点:
- 每次从操作系统申请一大块儿的内存,由 Go 对这块儿内存做分配,减少系统调用。
- 内存分配借鉴了 Google 的 TCMalloc(Thead-Caching Malloc)算法。
TCMalloc 的核心思想是:
(1)内存切分,减少碎片。
- 采用了 span 机制来减少内存碎片。多个连续的内存页(8KB)组成 span,每个 span 又划分成大小固定的多个 slot。
- slot size 有 67 种,每个 size 有两种类型,scan 和 noscan,表示分配的对象是否包含指针。
(2)分级管理,无锁并降低锁的粒度。
- 多层次的分配 Cache,每个 P 上有一个 mcache,mcache 会为每个 size 最多缓存一个 span,用于无锁分配。
- 全局每个 size 的 span 都有一个 mcentral,锁的粒度相对于全局的 mheap 小很多。每个 mcentral 可以看成是每个 size 的 span 的一个全局后备 cache。获取不到再上升到全局的 mheap。mheap 获取不到再向系统申请。从无锁到全局 1/(67*2)力度的锁,再到全局锁,再到系统调用。
(3)回收复用
- 内存由 GC 进行释放。回收对象内存时,并没有将其真正释放掉,只是放回预先分配的大块内存中,以便复用。
- 只有内存闲置过多的时候,sysmon 协程会定时把 mheap 空余的内存归还给操作系统,降低整体开销。
参考文献
图解 TCMalloc
内存分配器 - Go语言设计与实现
超干货!彻底搞懂Golang内存管理和垃圾回收 - 腾讯云
golang内存管理和分配机制 - Levon’s Blog