Go 内存分配器的设计与实现( 二 )

上述四种策略的前三种就不过多介绍了,Go 语言使用的内存分配策略与第四种策略有些相似,我们通过下图了解一下该策略的原理:

Go 内存分配器的设计与实现

文章插图
 
segregated-list
图 7-5 隔离适应策略
如上图所示,该策略会将内存分割成由 4、8、16、32 字节的内存块组成的链表,当我们向内存分配器申请 8 字节的内存时,我们会在上图中的第二个链表找到空闲的内存块并返回 。隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率 。
分级分配线程缓存分配(Thread-Caching Malloc,TCMalloc)是用于分配内存的的机制,它比 glibc 中的 malloc 函数还要快很多[^2] 。Go 语言的内存分配器就借鉴了 TCMalloc 的设计实现高速的内存分配,它的核心理念是使用多级缓存根据将对象根据大小分类,并按照类别实施不同的分配策略 。
对象大小Go 语言的内存分配器会根据申请分配的内存大小选择不同的处理逻辑,运行时根据对象的大小将对象分成微对象、小对象和大对象三种:
类别大小微对象(0, 16B)小对象[16B, 32KB]大对象(32KB, +∞)
表 7-1 对象的类别和大小
因为程序中的绝大多数对象的大小都在 32KB 以下,而申请的内存大小影响 Go 语言运行时分配内存的过程和开销,所以分别处理大对象和小对象有利于提高内存分配器的性能 。
多级缓存内存分配器不仅会区别对待大小不同的对象,还会将内存分成不同的级别分别管理,TCMalloc 和 Go 运行时分配器都会引入线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)三个组件分级管理内存:
Go 内存分配器的设计与实现

文章插图
 
multi-level-cache
图 7-6 多级缓存内存分配
线程缓存属于每一个独立的线程,它能够满足线程上绝大多数的内存分配需求,因为不涉及多线程,所以也不需要使用互斥锁来保护内存,这能够减少锁竞争带来的性能损耗 。当线程缓存不能满足需求时,就会使用中心缓存作为补充解决小对象的内存分配问题;在遇到 32KB 以上的对象时,内存分配器就会选择页堆直接分配大量的内存 。
这种多层级的内存分配设计与计算机操作系统中的多级缓存也有些类似,因为多数的对象都是小对象,我们可以通过线程缓存和中心缓存提供足够的内存空间,发现资源不足时就从上一级组件中获取更多的内存资源 。
虚拟内存布局这里会介绍 Go 语言堆区内存地址空间的设计以及演进过程,在 Go 语言 1.10 以前的版本,堆区的内存空间都是连续的;但是在 1.11 版本,Go 团队使用稀疏的堆内存空间替代了连续的内存,解决了连续内存带来的限制以及在特殊场景下可能出现的问题 。
线性内存Go 语言程序的 1.10 版本在启动时会初始化整片虚拟内存区域,如下所示的三个区域 spans、bitmap 和 arena 分别预留了 512MB、16GB 以及 512GB 的内存空间,这些内存并不是真正存在的物理内存,而是虚拟内存:
Go 内存分配器的设计与实现

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


推荐阅读