Go 内存模型
Contents
内存分配
运行着程序的虚拟内存空间包含两个重要区域:栈(stack)和堆(heap)。
- 函数调用的参数、返回值以及局部变量大都会被分配到栈上,这部分内存会由编译器进行管理。
- 堆中的对象由内存分配器分配并由垃圾收集器回收。
- 对于堆区的内存,C++ 等编程语言的开发者需要主动申请和释放内存,Go 等编程语言则由开发者和编译器共同管理。
内存管理一般包含三个不同的组件:用户程序(mutator)、分配器(allocator)和收集器(collector)。
- 用户程序通过内存分配器申请新内存,分配器从堆中初始化相应的内存区域。
- C 语言中
malloc()
用于动态申请内存,分配器使用的是 glibc 提供的ptmalloc2
。- 其它内存分配器有 Google 的 TCMalloc(Thread-Caching Malloc) 和 Facebook 的 jemalloc,两者在避免内存碎片和性能上比 glibc 优秀,多线程环境下尤为明显。
- C 语言中
编程语言的内存分配器一般分为两种:
- 线性分配器(Sequential Allocator,Bump Allocator)
- 线性分配器在内存中维护一个指向内存特定位置的指针,用户程序向分配器申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置。
- 线性分配器执行速度快、实现复杂度低,但是无法在内存释放时对其进行重用。
- 线性分配器的特性决定了它需要与合适的垃圾回收算法配合使用,由垃圾回收算法通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并。
- 适用的垃圾回收算法包括标记压缩(Mark-Compact)、复制回收(Copying GC)和分代回收(Generational GC)等算法。
- 线性分配器需要与具有拷贝特性的垃圾回收算法配合,所以 C 和 C++ 等需要直接对外暴露指针的语言就无法使用该策略。
- 空闲链表分配器(Free-List Allocator)
- 空闲链表分配器在内部会维护一个类似链表的数据结构,当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表。
- 不同的内存块通过指针构成了链表,因此可以重新利用回收的资源;分配内存时需要遍历链表,时间复杂度是 $$𝑂(𝑛)$$。
- 常见的内存块选择策略:
- First-Fit:从链表头开始遍历,选择第一个大小大于申请内存的内存块;
- Next-Fit:从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
- Best-Fit:从链表头遍历整个链表,选择最合适的内存块;
- Segregated-Fit:将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块。
- 隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率。
- Go 使用的分配策略与此类似。
Go 语言的内存分配器借鉴了 TCMalloc 的设计实现高速的内存分配,核心理念是使用多级缓存将对象根据大小分类,并按照类别实施不同的分配策略,即根据申请分配的内存大小选择不同的处理逻辑。
- Go 运行时根据对象的大小将对象分成微对象(
(0, 16B)
)、小对象([16B, 32KB]
)和大对象((32KB, +∞)
)三种。- 程序中的绝大多数对象的大小都在 32KB 以下。
- TCMalloc 和 Go 运行时分配器都引入了线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)三个组件分级管理内存。
- 线程缓存属于每一个独立的线程,它能够满足线程上绝大多数的内存分配需求。
- 每个处理器 P 都会分配一个线程缓存
runtime.mcache
用于处理微对象和小对象的分配, - 因为不涉及多线程,所以也不需要使用互斥锁来保护内存,减少了锁竞争带来的性能损耗。
- 每个处理器 P 都会分配一个线程缓存
- 当线程缓存不能满足需求时,运行时会使用中心缓存作为补充解决小对象的内存分配。
- 遇到 32KB 以上的对象时,内存分配器会选择页堆直接分配大内存。
- 线程缓存属于每一个独立的线程,它能够满足线程上绝大多数的内存分配需求。
Go 1.10 在启动时会初始化整片虚拟内存区域,包含 spans
、bitmap
和 arena
三部分。
spans
区存储了指向内存管理单元runtime.mspan
的指针,每个内存单元会管理几页的内存空间,每页大小为 8KB;bitmap
用于标识arena
区中哪些地址保存了对象,位图中的每个字节都表示堆区中的 32 字节是否空闲;arena
区是真正的堆区,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象。- 对于任意一个地址,我们都可以根据
arena
的基地址计算该地址所在的页数,并通过spans
数组获得管理该片内存的管理单元runtime.mspan
。spans
数组中多个连续的位置可能对应同一个runtime.mspan
结构。
- 对于任意一个地址,我们都可以根据
- Go 在垃圾回收时会根据指针的地址判断对象是否在堆中,并且找到管理该对象的
runtime.mspan
。- 这些都基于堆区的内存是连续的这一假设。
- 线性的堆内存需要预留大块的内存空间,但是申请大块的内存空间而不使用是不切实际的,不预留内存空间却会在特殊场景下造成程序崩溃。
- 这种设计在 C 和 Go 混合使用时会导致程序崩溃:
- 分配的内存地址会发生冲突,导致堆的初始化和扩容失败;
- 没有被预留的大块内存可能会被分配给 C 语言的二进制,导致扩容后的堆不连续。
- 这种设计在 C 和 Go 混合使用时会导致程序崩溃:
Go 1.11 将线性内存变成稀疏内存,移除了 512GB 的内存上限以及堆区内存连续性的假设,解决 C 和 Go 混合使用时的地址空间冲突问题。
线程缓存持有内存管理单元 runtime.mspan
。
runtime.mspan
是 Go 语言内存管理的基本单元。- 每个类型的内存管理单元都会管理特定大小的对象。
- 当内存管理单元中不存在空闲对象时,它们会从
runtime.mheap
持有的 134 个中心缓存runtime.mcentral
中获取新的内存单元。 - 中心缓存属于全局的堆结构体
runtime.mheap
,它会从操作系统中申请内存。- amd64 的 Linux 上
runtime.mheap
会持有 4,194,304runtime.heapArena
,每个runtime.heapArena
都会管理 64MB 的内存,单个 Go 语言程序的内存上限也就是 256TB。
- amd64 的 Linux 上
// 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 的页,这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍。 -
以下字段用来来管理内存页的分配和回收:
startAddr
,npages
:确定该结构体管理的多个页所在的内存,每个页的大小都是 8KB;freeindex
:扫描页中下一个空闲对象时的起始查找位置;allocBits
:gcmarkBits
:分别用于标记内存的占用和回收情况;allocCache
:allocBits
的补码,用于快速查找内存中未被使用的内存。- 当用户程序或者线程向
runtime.mspan
申请内存时,它会使用allocCache
以对象为单位在管理的内存中快速查找待分配的空间。 - 如果在内存中能找到空闲的内存单元则会直接返回,当内存中不包含空闲的内存时,上一级的组件
runtime.mcache
会调用runtime.mcache.refill
更新内存管理单元以满足为更多对象分配内存的需求。
- 当用户程序或者线程向
-
当
runtime.mspan
管理的内存不足时,运行时会以页为单位向堆申请内存。 -
运行时使用
runtime.mSpanStateBox
存储内存管理单元的状态runtime.mSpanState
。- 四种可能的状态:
mSpanDead
,mSpanInUse
,mSpanManual
,mSpanFree
。 - 设置
runtime.mspan
状态的操作必须是原子性的以避免垃圾回收造成的线程竞争问题。
- 四种可能的状态:
-
runtime.spanClass
是runtime.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,最后一位表示是否包含指针,该类型提供的两个方法能够帮我们快速获取对应的字段。