注:以下所有结构体均为删减之后的,go version 为1.5,文章内容来自雨痕的Go学习笔记

概述

基本策略:

  1. 每次从操作系统申请一大块内存(比如 1MB), 以减少系统调用。

  2. 将申请到的大块内存按照特定大小预先切分成小块, 构成链表。

  3. 为对象分配内存时, 只需从大小合适的链表提取一个小块即可。

  4. 回收对象内存时, 将该小块内存重新归还到原链表, 以便复用。

  5. 如闲置内存过多, 则尝试归还部分内存给操作系统, 降低整体开销。

内存分配器只管理内存块, 并不关心对象状态。且不会主动回收内存, 由垃圾回收器在完成清理 操作后, 触发内存分配器回收操作。

内存块

分配器管理的内存块分为两种:

  • span: 由多个地址连续的页(page)组成的大块内存,面向内部管理

  • object: 将 span 按特定大小切分成多个小块,每个小块可存储一个对象,面向对象分配

分配器按页数来区分不同大小的 span。比如,以页数为单位将 span 存放到管理数组中, 需要时就以页数为索引进行查找。span 大小并非固定不变。在获取闲置 span 时, 如果没找到大小合适的, 那就返回页数更多的, 此时会引发裁剪操作, 多余部分将构成新的 span 被放回管理数组。
分配器还会尝试将地址相邻的空闲 span 合并, 以构建更大的内存块,减少碎片,提供更灵活的分配策略。分配器也会尝试将多个微小对象组合到一个 object 块内,以节约内存。

malloc.go

  1. _PageShift = 13
  2. _PageSize = 1 << _PageShift // 8KB

mheap.go

  1. type mspan struct {
  2. next *mspan // 双向链表
  3. prev *mspan
  4. npages uintptr // 页数
  5. freelist gclinkptr // 待分配的 object 链表
  6. }

用于存储对象的 object, 按 8 字节倍数分为 n 种。比如, 大小为 24 的 object 可用来存储
范围在 17 ~ 24 字节的对象。这种方式虽然会造成一些内存浪费,但分配器只需面对有限
的几种规格(size class)小块内存, 优化了分配和复用管理策略。
malloc.go

  1. _NumSizeClasses = 67

分配器初始化时,会构建对照表存储大小和规格的对应关系, 包括用来切分的 span 页数。

msize.go

  1. // Size classes. Computed and initialized by InitSizes.
  2. //
  3. // SizeToClass(0 <= n <= MaxSmallSize) returns the size class,
  4. // 1 <= sizeclass < NumSizeClasses, for n.
  5. // Size class 0 is reserved to mean "not small".
  6. //
  7. // class_to_size[i] = largest size in class i
  8. // class_to_allocnpages[i] = number of pages to allocate when
  9. // making new objects in class i
  10. var class_to_size [_NumSizeClasses]int32
  11. var class_to_allocnpages [_NumSizeClasses]int32
  12. var size_to_class8 [1024/8 + 1]int8 // 1k以下的size和class映射关系
  13. var size_to_class128 [(_MaxSmallSize-1024)/128 + 1]int8 // 1k~32k 之间的size和class映射关系

若对象大小超出特定阈值(32k)限制,会被当做大对象(large object)特别对待。
malloc.go

  1. _MaxSmallSize = 32 << 10 // 32KB

管理组件

Go 内存分配基于 tcmalloc

分配器的三种组件:
cache -> central -> heap -> system

  • cache: 每个运⾏期⼯作线程都会绑定⼀个 cache,⽤于⽆锁 object 分配。

  • central: 为所有 cache 提供切分好的后备 span 资源。

  • heap: 管理闲置 span,需要时向操作系统申请新内存。

mheap.go

  1. type mheap struct {
  2. free [_MaxMHeapList]mspan // 页数在127以内的闲置的span链表数组
  3. freelarge mspan // 页数大于127(大于1M)的大span链表
  4. // 每个central对应一种sizeclass
  5. central [_NumSizeClasses]struct {
  6. mcentral mcentral
  7. }
  8. }

mcentral.go

  1. type mcentral struct {
  2. sizeclass int32
  3. nonempty mspan // 链表:尚有空闲的 object 的 span
  4. empty mspan // 链表:没有空闲的 object 的 span
  5. }

mcache.go

  1. type mcache struct {
  2. alloc [_NumSizeClasses]*mspan // 以 sizeclass 为索引管理多个用于分配的 span
  3. }

小对象分配流程:

  1. 计算待分配对象对应规格(size class)。

  2. 从 cache.alloc 数组找到规格相同的 span。

  3. 从 span.freelist 链表提取可⽤ object。

  4. 如 span.freelist 为空,从 central 获取新 span。

  5. 如 central.nonempty 为空,从 heap.free/freelarge 获取,并切分成 object 链表。

  6. 如 heap 没有⼤⼩合适的闲置 span,向操作系统申请新内存块。

释放流程:

  1. 将标记为可回收 object 交还给所属 span.freelist。

  2. 该 span 被放回 central,可供任意 cache 重新获取使⽤。

  3. 如 span 已收回全部 object,则将其交还给 heap,以便重新切分复⽤。

  4. 定期扫描 heap ⾥长时间闲置的 span,释放其占⽤内存。

注:⼤对象直接从 heap 分配和回收,不是所有的new都会从heap分配内存

初始化

内存分配器和垃圾回收算法都依赖连续地址, 所以在初始化阶段, 预先保留了很大的一段虚拟地址空间(保留地址空间,并不会分配内存)。
该段空间被划分成三个区域:

  1. +-------------+-------------+---------------------------------------+
  2. | spans 512MB | bitmap 32GB | arena 512GB |
  3. +-------------+-------------+---------------------------------------+
  4. spans_mapped bitmap_mapped arena_start arena_used arena_end

内存管理结构:

  • 使⽤ arena 地址向操作系统申请内存,其⼤⼩决定了可分配⽤户内存上限。

  • 位图 bitmap 为每个对象提供 4bit 标记位,⽤以保存指针、GC 标记等信息。

  • spans 存储相应的 span 的指针

golang 内存管理 - 图1

mheap.go

  1. type mheap struct {
  2. spans **mspan
  3. spans_mapped uintptr // spans 末尾地址
  4. bitmap uintptr // bitmap起始地址
  5. bitmap_mapped uintptr // bitmap末尾地址
  6. arena_start uintptr
  7. arena_used uintptr
  8. arena_end uintptr
  9. }

初始化⼯作很简单:

  1. 创建对象规格⼤⼩对照表。

  2. 计算相关区域⼤⼩,并尝试从某个指定位置开始保留地址空间。

  3. 在 heap ⾥保存区域信息,包括起始位置和⼤⼩。

  4. 初始化 heap 其他属性。

分配

关于内存分配的误解:

  1. func main() {
  2. data := new([10][1024*1024]byte) // 承诺但不立即分配物理内存
  3. for i := range data {
  4. // 缺页中断分配
  5. for x, n := 0, len(data[i]); x < n; x++ {
  6. data[i][x] = 1
  7. }
  8. }
  9. }
  • 操作系统⼤多采取机会主义分配策略。申请内存时,仅承诺但不⽴即分配物理内存。

  • 物理内存分配发⽣在写操作导致缺页异常调度时,且按页提供。

逃逸分析

通常情况下, 编译器有责任尽可能使用寄存器和栈来存储对象, 这有助于提升性能,减少垃圾回收器压力。

在栈上分配的内存系统会自动地为其释放,例如在函数结束时,局部变量将不复存在,就是系统自动清除栈内存的结果。但堆中分配的内存即使你退出了new表达式的所处的函数或者作用域,那块内存还处于被使用状态而不能再利用。这就只能靠垃圾回收器来回收。 栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。而堆的分配相对比较复杂,可能涉及到使用系统调用去增加程序数据段的内存空间。

为对象分配内存是分配到栈上还是堆上有 golang 编译器决定,不是说用了 new 函数就一定会分配在堆上。
来看同一段代码不同编译结果:

  1. package main
  2. func test() *int {
  3. x := new(int)
  4. *x = 0xAABB
  5. return x
  6. }
  7. func main() {
  8. println(*test())
  9. }

当编译器禁⽤内联优化时,x 在堆上分配内存

  1. go build -gcflags "-l" -o test test.go // 关闭内联优化

但当使⽤默认参数时,函数 test 会被 main 内联,不会在堆上分配内存(逃逸分析)

  1. go build -o test test.go

没有内联时,需要在两个栈帧(一个函数调用一个栈帧)间传递对象,因此在堆上分配⽽不是返回⼀个失效栈帧⾥的数据。⽽当内联后,实际上就成了 main 栈帧内的局部变量,⽆需去堆上操作。

内存分配

mcache.go

  1. type mcache struct {
  2. // Allocator cache for tiny objects pointers.
  3. tiny unsafe.Pointer // unsafe 表示可以直接操作内存,正常的pointer没有类似C语言里面指针运算
  4. tinyoffset uintptr
  5. alloc [_NumSizeClasses]*mspan
  6. }

申请内存基本思路:

  • ⼤对象直接从 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 这个位置指⽰器。

内存分配流程图:

golang 内存管理 - 图2

回收

回收:内存复⽤,不再使⽤的内存会被放回合适位置,等下次分配时再次使⽤。
回收操作是以 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 告知操作系统某段内存暂不使⽤,建议内核收回对应物理内存。这只是⼀个建议,是否回收由内核决定。如物理内存资源充⾜,该建议可能会被忽略,以避免⽆谓的损耗。⽽当再次使⽤该内存块时,会引发缺页异常,内核会⾃动重新关联物理内存页。