内存分配

运行着程序的虚拟内存空间包含两个重要区域:栈(stack)和堆(heap)。

  • 函数调用的参数、返回值以及局部变量大都会被分配到栈上,这部分内存会由编译器进行管理。
  • 堆中的对象由内存分配器分配并由垃圾收集器回收。
    • 对于堆区的内存,C++ 等编程语言的开发者需要主动申请和释放内存,Go 等编程语言则由开发者和编译器共同管理。

内存管理一般包含三个不同的组件:用户程序(mutator)、分配器(allocator)和收集器(collector)。

  • 用户程序通过内存分配器申请新内存,分配器从堆中初始化相应的内存区域。
    • C 语言中 malloc() 用于动态申请内存,分配器使用的是 glibc 提供的 ptmalloc2
      • 其它内存分配器有 Google 的 TCMalloc(Thread-Caching Malloc) 和 Facebook 的 jemalloc,两者在避免内存碎片和性能上比 glibc 优秀,多线程环境下尤为明显。

编程语言的内存分配器一般分为两种:

  1. 线性分配器(Sequential Allocator,Bump Allocator)
    • 线性分配器在内存中维护一个指向内存特定位置的指针,用户程序向分配器申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置。
    • 线性分配器执行速度快、实现复杂度低,但是无法在内存释放时对其进行重用。
    • 线性分配器的特性决定了它需要与合适的垃圾回收算法配合使用,由垃圾回收算法通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并。
      • 适用的垃圾回收算法包括标记压缩(Mark-Compact)、复制回收(Copying GC)和分代回收(Generational GC)等算法。
      • 线性分配器需要与具有拷贝特性的垃圾回收算法配合,所以 C 和 C++ 等需要直接对外暴露指针的语言就无法使用该策略。
  2. 空闲链表分配器(Free-List Allocator)
    • 空闲链表分配器在内部会维护一个类似链表的数据结构,当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表。
    • 不同的内存块通过指针构成了链表,因此可以重新利用回收的资源;分配内存时需要遍历链表,时间复杂度是 $$𝑂(𝑛)$$。
    • 常见的内存块选择策略:
      1. First-Fit:从链表头开始遍历,选择第一个大小大于申请内存的内存块;
      2. Next-Fit:从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
      3. Best-Fit:从链表头遍历整个链表,选择最合适的内存块;
      4. Segregated-Fit:将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块。
        • 隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率。
        • Go 使用的分配策略与此类似。

Go 语言的内存分配器借鉴了 TCMalloc 的设计实现高速的内存分配,核心理念是使用多级缓存将对象根据大小分类,并按照类别实施不同的分配策略,即根据申请分配的内存大小选择不同的处理逻辑。

  • Go 运行时根据对象的大小将对象分成微对象((0, 16B))、小对象([16B, 32KB])和大对象((32KB, +∞))三种。
    • 程序中的绝大多数对象的大小都在 32KB 以下。
  • TCMalloc 和 Go 运行时分配器都引入了线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)三个组件分级管理内存。
    • 线程缓存属于每一个独立的线程,它能够满足线程上绝大多数的内存分配需求。
      • 每个处理器 P 都会分配一个线程缓存 runtime.mcache 用于处理微对象和小对象的分配,
      • 因为不涉及多线程,所以也不需要使用互斥锁来保护内存,减少了锁竞争带来的性能损耗。
    • 当线程缓存不能满足需求时,运行时会使用中心缓存作为补充解决小对象的内存分配。
    • 遇到 32KB 以上的对象时,内存分配器会选择页堆直接分配大内存。

Go 1.10 在启动时会初始化整片虚拟内存区域,包含 spansbitmaparena 三部分。

  • spans 区存储了指向内存管理单元 runtime.mspan 的指针,每个内存单元会管理几页的内存空间,每页大小为 8KB;
  • bitmap 用于标识 arena 区中哪些地址保存了对象,位图中的每个字节都表示堆区中的 32 字节是否空闲;
  • arena 区是真正的堆区,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象。
    • 对于任意一个地址,我们都可以根据 arena 的基地址计算该地址所在的页数,并通过 spans 数组获得管理该片内存的管理单元 runtime.mspan
      • spans 数组中多个连续的位置可能对应同一个 runtime.mspan 结构。
  • Go 在垃圾回收时会根据指针的地址判断对象是否在堆中,并且找到管理该对象的 runtime.mspan
    • 这些都基于堆区的内存是连续的这一假设。
  • 线性的堆内存需要预留大块的内存空间,但是申请大块的内存空间而不使用是不切实际的,不预留内存空间却会在特殊场景下造成程序崩溃。
    • 这种设计在 C 和 Go 混合使用时会导致程序崩溃:
      1. 分配的内存地址会发生冲突,导致堆的初始化和扩容失败;
      2. 没有被预留的大块内存可能会被分配给 C 语言的二进制,导致扩容后的堆不连续。

Go 1.11 将线性内存变成稀疏内存,移除了 512GB 的内存上限以及堆区内存连续性的假设,解决 C 和 Go 混合使用时的地址空间冲突问题。

线程缓存持有内存管理单元 runtime.mspan

  • runtime.mspan 是 Go 语言内存管理的基本单元。
  • 每个类型的内存管理单元都会管理特定大小的对象。
  • 当内存管理单元中不存在空闲对象时,它们会从 runtime.mheap 持有的 134 个中心缓存 runtime.mcentral 中获取新的内存单元。
  • 中心缓存属于全局的堆结构体 runtime.mheap,它会从操作系统中申请内存。
    • amd64 的 Linux 上 runtime.mheap 会持有 4,194,304 runtime.heapArena,每个 runtime.heapArena 都会管理 64MB 的内存,单个 Go 语言程序的内存上限也就是 256TB。
// https://github.com/golang/go/blob/41d8e61a6b9d8f9db912626eb2bbc535e929fefc/src/runtime/mheap.go#L382
type mspan struct {
	next *mspan
	prev *mspan

    startAddr uintptr // 起始地址
	npages    uintptr // 页数
    freeindex uintptr

	allocBits  *gcBits
	gcmarkBits *gcBits
	allocCache uint64

    state       mSpanStateBox

    spanclass   spanClass
}
  • runtime.mspan 串联成双向链表,运行时使用 runtime.mSpanList 存储双向链表的头结点和尾节点并在线程缓存以及中心缓存中使用。

  • 每个 runtime.mspan 都管理 npages 个大小为 8KB 的页,这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍。

  • 以下字段用来来管理内存页的分配和回收:

    • startAddrnpages:确定该结构体管理的多个页所在的内存,每个页的大小都是 8KB;
    • freeindex:扫描页中下一个空闲对象时的起始查找位置;
    • allocBitsgcmarkBits:分别用于标记内存的占用和回收情况;
    • allocCacheallocBits 的补码,用于快速查找内存中未被使用的内存。
      • 当用户程序或者线程向 runtime.mspan 申请内存时,它会使用 allocCache 以对象为单位在管理的内存中快速查找待分配的空间。
      • 如果在内存中能找到空闲的内存单元则会直接返回,当内存中不包含空闲的内存时,上一级的组件 runtime.mcache 会调用 runtime.mcache.refill 更新内存管理单元以满足为更多对象分配内存的需求。
  • runtime.mspan 管理的内存不足时,运行时会以页为单位向堆申请内存。

  • 运行时使用 runtime.mSpanStateBox 存储内存管理单元的状态 runtime.mSpanState

    • 四种可能的状态:mSpanDeadmSpanInUsemSpanManualmSpanFree
    • 设置 runtime.mspan 状态的操作必须是原子性的以避免垃圾回收造成的线程竞争问题。
  • runtime.spanClassruntime.mspan 的跨度类,它决定了内存管理单元中存储的对象大小和个数。

    // https://github.com/golang/go/blob/b634f5d97a6e65f19057c00ed2095a1a872c7fa8/src/runtime/sizeclasses.go#L6
    
    // class  bytes/obj  bytes/span  objects  tail waste  max waste
    //     1          8        8192     1024           0     87.50%
    //     2         16        8192      512           0     43.75%
    //     3         24        8192      341           8     29.24%
    //     4         32        8192      256           0     21.88%
    //     5         48        8192      170          32     31.52%
    // ...
    //    67      32768       32768        1           0     12.50%
    
    // A spanClass represents the size class and noscan-ness of a span.
    // Each size class has a noscan spanClass and a scan spanClass. The
    // noscan spanClass contains only noscan objects, which do not contain
    // pointers and thus do not need to be scanned by the garbage
    // collector.
    type spanClass uint8
    
    • class 5 中,对象大小上限是 48 字节,管理 1 个页(一个页是 8KB),最多存储 170 个对象;内存按页进行管理,尾部会浪费 32 字节($$8192-48*170$$);当页中存储的对象都是 33 字节时,最多会浪费 31.52%($$\frac{((48-33)*170+32)}{8192}$$)的资源。
    • 除了上述 67 个跨度类之外,运行时中还包含 ID 为 0 的特殊跨度类,它能够管理大于 32KB 的特殊对象。
    • 跨度类中除了存储类别的 ID 之外,它还会存储一个 noscan 标记位,该标记位表示对象是否包含指针,垃圾回收会对包含指针的 runtime.mspan 结构体进行扫描。
    • runtime.spanClass 的前 7 位存储着跨度类的 ID,最后一位表示是否包含指针,该类型提供的两个方法能够帮我们快速获取对应的字段。