注:以下所有结构体均为删减之后的,go version 为1.5,文章内容来自雨痕的Go学习笔记
概述
基本策略:
每次从操作系统申请一大块内存(比如 1MB), 以减少系统调用。
将申请到的大块内存按照特定大小预先切分成小块, 构成链表。
为对象分配内存时, 只需从大小合适的链表提取一个小块即可。
回收对象内存时, 将该小块内存重新归还到原链表, 以便复用。
如闲置内存过多, 则尝试归还部分内存给操作系统, 降低整体开销。
内存分配器只管理内存块, 并不关心对象状态。且不会主动回收内存, 由垃圾回收器在完成清理 操作后, 触发内存分配器回收操作。
内存块
分配器管理的内存块分为两种:
span: 由多个地址连续的页(page)组成的大块内存,面向内部管理。
object: 将 span 按特定大小切分成多个小块,每个小块可存储一个对象,面向对象分配。
分配器按页数来区分不同大小的 span。比如,以页数为单位将 span 存放到管理数组中, 需要时就以页数为索引进行查找。span 大小并非固定不变。在获取闲置 span 时, 如果没找到大小合适的, 那就返回页数更多的, 此时会引发裁剪操作, 多余部分将构成新的 span 被放回管理数组。
分配器还会尝试将地址相邻的空闲 span 合并, 以构建更大的内存块,减少碎片,提供更灵活的分配策略。分配器也会尝试将多个微小对象组合到一个 object 块内,以节约内存。
malloc.go
_PageShift = 13
_PageSize = 1 << _PageShift // 8KB
mheap.go
type mspan struct {
next *mspan // 双向链表
prev *mspan
npages uintptr // 页数
freelist gclinkptr // 待分配的 object 链表
}
用于存储对象的 object, 按 8 字节倍数分为 n 种。比如, 大小为 24 的 object 可用来存储
范围在 17 ~ 24 字节的对象。这种方式虽然会造成一些内存浪费,但分配器只需面对有限
的几种规格(size class)小块内存, 优化了分配和复用管理策略。
malloc.go
_NumSizeClasses = 67
分配器初始化时,会构建对照表存储大小和规格的对应关系, 包括用来切分的 span 页数。
msize.go
// Size classes. Computed and initialized by InitSizes.
//
// SizeToClass(0 <= n <= MaxSmallSize) returns the size class,
// 1 <= sizeclass < NumSizeClasses, for n.
// Size class 0 is reserved to mean "not small".
//
// class_to_size[i] = largest size in class i
// class_to_allocnpages[i] = number of pages to allocate when
// making new objects in class i
var class_to_size [_NumSizeClasses]int32
var class_to_allocnpages [_NumSizeClasses]int32
var size_to_class8 [1024/8 + 1]int8 // 1k以下的size和class映射关系
var size_to_class128 [(_MaxSmallSize-1024)/128 + 1]int8 // 1k~32k 之间的size和class映射关系
若对象大小超出特定阈值(32k)限制,会被当做大对象(large object)特别对待。
malloc.go
_MaxSmallSize = 32 << 10 // 32KB
管理组件
Go 内存分配基于 tcmalloc
分配器的三种组件:
cache -> central -> heap -> system
cache: 每个运⾏期⼯作线程都会绑定⼀个 cache,⽤于⽆锁 object 分配。
central: 为所有 cache 提供切分好的后备 span 资源。
heap: 管理闲置 span,需要时向操作系统申请新内存。
mheap.go
type mheap struct {
free [_MaxMHeapList]mspan // 页数在127以内的闲置的span链表数组
freelarge mspan // 页数大于127(大于1M)的大span链表
// 每个central对应一种sizeclass
central [_NumSizeClasses]struct {
mcentral mcentral
}
}
mcentral.go
type mcentral struct {
sizeclass int32
nonempty mspan // 链表:尚有空闲的 object 的 span
empty mspan // 链表:没有空闲的 object 的 span
}
mcache.go
type mcache struct {
alloc [_NumSizeClasses]*mspan // 以 sizeclass 为索引管理多个用于分配的 span
}
小对象分配流程:
计算待分配对象对应规格(size class)。
从 cache.alloc 数组找到规格相同的 span。
从 span.freelist 链表提取可⽤ object。
如 span.freelist 为空,从 central 获取新 span。
如 central.nonempty 为空,从 heap.free/freelarge 获取,并切分成 object 链表。
如 heap 没有⼤⼩合适的闲置 span,向操作系统申请新内存块。
释放流程:
将标记为可回收 object 交还给所属 span.freelist。
该 span 被放回 central,可供任意 cache 重新获取使⽤。
如 span 已收回全部 object,则将其交还给 heap,以便重新切分复⽤。
定期扫描 heap ⾥长时间闲置的 span,释放其占⽤内存。
注:⼤对象直接从 heap 分配和回收,不是所有的new都会从heap分配内存
初始化
内存分配器和垃圾回收算法都依赖连续地址, 所以在初始化阶段, 预先保留了很大的一段虚拟地址空间(保留地址空间,并不会分配内存)。
该段空间被划分成三个区域:
+-------------+-------------+---------------------------------------+
| spans 512MB | bitmap 32GB | arena 512GB |
+-------------+-------------+---------------------------------------+
spans_mapped bitmap_mapped arena_start arena_used arena_end
内存管理结构:
使⽤ arena 地址向操作系统申请内存,其⼤⼩决定了可分配⽤户内存上限。
位图 bitmap 为每个对象提供 4bit 标记位,⽤以保存指针、GC 标记等信息。
spans 存储相应的 span 的指针
mheap.go
type mheap struct {
spans **mspan
spans_mapped uintptr // spans 末尾地址
bitmap uintptr // bitmap起始地址
bitmap_mapped uintptr // bitmap末尾地址
arena_start uintptr
arena_used uintptr
arena_end uintptr
}
初始化⼯作很简单:
创建对象规格⼤⼩对照表。
计算相关区域⼤⼩,并尝试从某个指定位置开始保留地址空间。
在 heap ⾥保存区域信息,包括起始位置和⼤⼩。
初始化 heap 其他属性。
分配
关于内存分配的误解:
func main() {
data := new([10][1024*1024]byte) // 承诺但不立即分配物理内存
for i := range data {
// 缺页中断分配
for x, n := 0, len(data[i]); x < n; x++ {
data[i][x] = 1
}
}
}
操作系统⼤多采取机会主义分配策略。申请内存时,仅承诺但不⽴即分配物理内存。
物理内存分配发⽣在写操作导致缺页异常调度时,且按页提供。
逃逸分析
通常情况下, 编译器有责任尽可能使用寄存器和栈来存储对象, 这有助于提升性能,减少垃圾回收器压力。
在栈上分配的内存系统会自动地为其释放,例如在函数结束时,局部变量将不复存在,就是系统自动清除栈内存的结果。但堆中分配的内存即使你退出了new表达式的所处的函数或者作用域,那块内存还处于被使用状态而不能再利用。这就只能靠垃圾回收器来回收。 栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。而堆的分配相对比较复杂,可能涉及到使用系统调用去增加程序数据段的内存空间。
为对象分配内存是分配到栈上还是堆上有 golang 编译器决定,不是说用了 new 函数就一定会分配在堆上。
来看同一段代码不同编译结果:
package main
func test() *int {
x := new(int)
*x = 0xAABB
return x
}
func main() {
println(*test())
}
当编译器禁⽤内联优化时,x 在堆上分配内存
go build -gcflags "-l" -o test test.go // 关闭内联优化
但当使⽤默认参数时,函数 test 会被 main 内联,不会在堆上分配内存(逃逸分析)
go build -o test test.go
没有内联时,需要在两个栈帧(一个函数调用一个栈帧)间传递对象,因此在堆上分配⽽不是返回⼀个失效栈帧⾥的数据。⽽当内联后,实际上就成了 main 栈帧内的局部变量,⽆需去堆上操作。
内存分配
mcache.go
type mcache struct {
// Allocator cache for tiny objects pointers.
tiny unsafe.Pointer // unsafe 表示可以直接操作内存,正常的pointer没有类似C语言里面指针运算
tinyoffset uintptr
alloc [_NumSizeClasses]*mspan
}
申请内存基本思路:
⼤对象直接从 heap 获取 span。
⼩对象从 cache.alloc[sizeclass].freelist 获取 object。
微⼩对象组合到一个 cache.tiny object。
注:如果不够会向上一级申请
资源不足时的扩张
cache 从 central ⾥获取 span 时,优先取⽤已有资源,哪怕是要执⾏清理操作(mSpan_Sweep)。只有当现有资源都⽆法满⾜时,才去 heap 获取 span,并重新切分成 object 链表。
从 heap 获取 span 的算法核⼼是找到⼤⼩最合适的块。⾸先从页数相同的链表查找,如没有结果,再从页数更多的链表提取,直⾄超⼤块或申请新块(遍历)。如返回更⼤的 span,为避免浪费,会将多余部分切出来重新放回 heap 链表。同时还尝试合并相邻闲置 span 空间,减少碎⽚。
heap 不足时向操作系统申请新内存块,⽤ mmap 从指定位置申请内存,同时同步扩张 bitmap 和 spans 区域,以及调整 arena_used 这个位置指⽰器。
内存分配流程图:
回收
回收:内存复⽤,不再使⽤的内存会被放回合适位置,等下次分配时再次使⽤。
回收操作是以 span 为基本单位。通过⽐对 bitmap ⾥的扫描标记,逐步将 object 收归原 span,最终上交 central 或 heap 复⽤。具体是通过调⽤ mSpan_Sweep 来引发内存分配器回收流程。调用示例见【上文资源不足时的扩张】。mSpan_Sweep 是在特定时机被触发,不像垃圾回收器是在后台定时检测。
遍历 span,将收集到的不可达 object 合并到 freelist 链表。如该 span 已收回全部 object,那么就将这块完全⾃由的内存还给 heap,以便后续复⽤。不可达判断和垃圾回收类似通过判断指向的指针是否保持。
⽆论是向操作系统申请内存,还是清理回收内存,只要往 heap ⾥放 span,都会尝试合并左右相邻的闲置 span,以构成更⼤的⾃由块。
释放
在运⾏时⼊⼜函数 main.main ⾥,会专门启动⼀个监控任务 sysmon,它每隔⼀段时间就会检查 heap ⾥的闲置内存块。遍历 free、freelarge ⾥的所有 span,如闲置时间超过阈值,则释放其关联的物理内存。
触发释放时使用系统调⽤ madvise 告知操作系统某段内存暂不使⽤,建议内核收回对应物理内存。这只是⼀个建议,是否回收由内核决定。如物理内存资源充⾜,该建议可能会被忽略,以避免⽆谓的损耗。⽽当再次使⽤该内存块时,会引发缺页异常,内核会⾃动重新关联物理内存页。