本文用于持续记录 Go GC 相关技术学习笔记。
Go 内存分配器
内存分配器相关笔记,主要是运行时的 mallocinit
函数的初始化以及 mallocgc
函数的动态分配。
设计原则
Go 的内存分配器基于 Thread-Cache Malloc(tcmalloc),tcmalloc 为每个线程实现了一个本地缓存,区分了小对象(小于 32 KB)和大对象分配两种分配模式,其管理的内存单元称为 span。
Go 的内存分配器与 tcmalloc 存在一定差异。 这个差异来源于 Go 语言被设计为没有显式的内存分配与释放, 完全依靠编译器与运行时的配合来自动处理,因此也就造就了内存分配器、垃圾回收器两大组件。
传统意义上的栈被 Go 的运行时霸占,不开放给用户态代码;而传统意义上的堆内存,又被 Go 运行时划分为了两个部分, 一个是 Go 运行时自身所需的堆内存,即堆外内存;另一部分则用于 Go 用户态代码所使用的堆内存,也叫做 Go 堆。 Go 堆负责了用户态对象的存放以及 goroutine 的执行栈。
小于 32KB 内存块的分配策略
当程序里发生了 32KB
以下的小块内存申请时,Go 会从一个叫做 mcache
的本地缓存给程序分配内存。这个本地缓存 mcache
持有一系列的大小为 32KB
的内存块,这样的一个内存块叫做 mspan
,它是要给程序分配内存时的分配单元。
在Go的调度器模型里([[Go runtime]]),每个线程 M
会绑定给一个处理器 P
,在单一粒度的时间里只能做多处理运行一个 goroutine
,每个 P
都会绑定一个上面说的本地缓存 mcache
。当需要进行内存分配时,当前运行的 goroutine
会从 mcache
中查找可用的 mspan
。从本地 mcache
里分配内存时不需要加锁,这种分配策略效率更高。
为了避免浪费,mcache
持有的一系列 mspan
并不是同一大小,而是从 8 字节 到 32KB 分了大概70类。
无可用 mspan
时
当没有可分配的 mspan
时,会从 mcentral
中获取一个所需大小空间的新的 mspan
,从 mcentral
中分配会对其进行加锁, 但一次性获取整个 span
的过程均摊了对 mcentral
加锁的成本。mcentral
中有多个全局列表与每个规格的 mspan
对应,所以加锁不会影响其他规格的 mspan
。
如果 mcentral
的 mspan
也为空时,则它也会发生增长,从而从 mheap
中获取一连串的页,作为一个新的 mspan
进行提供。mheap
里的 arena
区域是真正的堆区,运行时会将 8KB 看作一页,这些内存页中存储了所有在堆上的初始化的对象。
而如果 mheap
的 arena
区域仍然为空,或者没有足够大的对象来进行分配时,则会从操作系统中分配一组新的页(至少 1MB), 从而均摊与操作系统沟通的成本。
大于 32KB 的内存块的分配策略
直接在 mheap
上分配对应数量的内存页。
主要结构
整理总结上面提到的主要结构:
- **arena:**保留整个虚拟地址空间。
- Go 堆被视为由多个
arena
组成,每个arena
在 64 位机器上为 64MB,且起始地址与arena
的大小对齐,所有的arena
覆盖了整个 Go 堆的地址空间。
- Go 堆被视为由多个
- **mheap:**分配的堆,在也大小为 8KB 的粒度上进行管理。
- **mspan:**是
mheap
上管理的一连串的页。mspan
是相同大小等级的 span 的双向链表的一个节点,每个节点还记录了自己的起始地址、 指向的 span 中页的数量。
- **mcentral:**收集了给定大小等级的所有
span
。 - **mcache:**per-P 的缓存。
- 它是一个包含不同大小等级的 span 链表的数组,其中 mcache.alloc 的每一个数组元素 都是某一个特定大小的 mspan 的链表头指针。
源码
TODO
总结
总结起来关于Go内存分配管理的策略有如下几点:
- Go在程序启动时,会向操作系统申请一大块内存,由
mheap
结构全局管理。 - Go内存管理的基本单元是
mspan
,每种mspan
可以分配特定大小的object
。 mcache
,mcentral
,mheap
是Go
内存管理的三大组件,mcache
管理线程在本地缓存的mspan
;mcentral
管理全局的mspan
供所有线程使用;mheap
管理Go
的所有动态分配内存。- 一般小对象通过
mspan
分配内存;大对象则直接由mheap
分配内存。