前言
得益于Go内置的内存管理机制,我们不用关心内存处理细节。一方面,了解这些底层逻辑,可以帮助我们更合理创建对象,提高内存使用效率。另一方面,Go对内存的管理机制本身就很有意思。
我们所熟知的堆和栈
在大多数编程语言中,栈指的是操作系统线程的栈,用于存储函数参数、局部变量。栈是一个LIFO(last in first out)的结构,一个函数调用会压入一个栈帧,函数调用返回会pop一个栈帧,它所占用的内存也都会被释放,所以任何引用栈上对象的指针,在此之后都会不可用。因此我们需要用到堆。
堆是一个更复杂的内存空间,应用程序从操作系统拿到的是一块连续的虚拟地址空间,如何高效率的分配与回收内存,减少内存碎片,这是应用程序的事。
Go的堆和栈
Go语言用并发度更好的goroutine来替代线程,与线程类似,每个goroutine都有自己的栈。不同于线程的是,goroutine位于用户空间,因此它的行为是由运行时(而不是操作系统)来定义。
线程创建时,会由操作系统分配固定大小的栈,而goroutine的栈一开始是2KB(目前),之后可以在运行过程中扩容和缩容。需要注意的是,go采用了连续栈的实现方式,也就是说栈扩容或缩容时,我们会找到一块符合大小的空间,然后将原栈上的内容全部拷贝到新的空间内。
- 栈扩容
在每次函数调用前,go运行时会检查栈空间是否足够,不足会触发扩容机制。

- 栈缩容
当goroutine栈使用率不足1/4时,会触发缩容,内存缩减为原来的1/2
堆在概念上是类似的,所有的goroutine共享一个堆。但它的内存管理策略要复杂的多,需要解决分配内存,回收内存,解决内存碎片化等一系列问题。
我们一般说内存管理,都是指的堆内存,操作系统线程的栈不需要程序去关心。goroutine的栈是由Go runtime定义的,这也意味着它和堆共享了同一块内存空间,在设计上也很类似,堆和栈共用了底层数据结构span,以及线程缓存机制减少锁并发。这也可以理解,为什么栈可以无限扩容了。

TCMalloc
TCMalloc是Thread Cache Malloc的简称,是Go内存管理的起源;随着Go的迭代,Go的内存管理与TCMalloc不一致地方在不断扩大。但其主要思想、原理和概念都是和TCMalloc一致的。掌握TCMalloc的理念,可以更好的理解Go的内存管理方式。
- 将内存分类,按照不同大小组织成各级链表,根据对象尺寸分配到合适的方格中

- TCMalloc的做法是为每个线程预分配一块缓存,线程申请小内存时,可以从缓存分配内存,这样做有两个好处:
- 为线程分配缓存需要一次系统调用,后续线程申请小内存时,从缓存分配,都是在用户态进行,没有系统调用,降低了分配时间。
- 多个线程申请小内存时,都从缓存中分配,没有并发加锁的竞争。
对照上图,我们来理解几个核心概念:
Page
操作系统对内存的管理以页为单位,TCMallloc也是,不过TCMalloc的Page大小和操作系统的不一定相等,而是倍数关系。目前Go以8KB为一页。
Span
一组连续的page称为span,比如可以有2页大小的span,或16页大小的span。span比page高一个层级,是TCMalloc中内存管理的基本单位。
ThreadCache
每个线程自己的Cache,一个Cache有多个内存块链表,每个链表连接的是都是内存块,同一个链表上的内存块大小相同。可以说根据内存块大小,对内存做了分类,这样就可以根据申请内存的大小,快速的从合适的链表上选择空闲内存块。由于每个线程都有自己的ThreadCache,所以访问它不需要加锁。
CentralCache
是所有线程共享的Cache,也是保存的空闲内存块链表,链表数量和ThreadCache相等。当ThreadCache内存不足时,可以从CentralCache中取,当ThreadCache内存块多时,可以返还给CentralCache。CentralCache是线程共享的,因此访问需要加锁。
PageHeap
PageHeap是堆内存的抽象,也是保存的若干链表。当CentralCache没有内存时,会从PageHeap取,当CentralCache内存多时,会放回PageHeap。如下图,分别是保存的1页page的span链表,2页page的span链表,最后是large span链表,用来存法大对象。
上面提到了大对象的概念,TCMalloc中关于对象大小的定义:
- 小对象大小:0~256KB
- 中对象大小:257~1MB
- 大对象大小:>1MB
小对象的分配流程:ThreadCache -> CentralCache -> PageHeap,大部分时候ThreadCache内存都是充足的,不需要访问CenralCache和PageHeap,分配效率非常高。 中对象存储流程:从PageHeap中选择适当的大小即可,128Page能保存的最大内存是1MB 大对象分配流程:从large span set中选择数量合适的页组成span,用于存储数据。
Go内存管理
Go的内存管理起源于TCMalloc,但它还包括了另外两样东西:逃逸分析和垃圾收集。逃逸分析我们下面会介绍,垃圾收集是另一个比较大的话题,我会在另一篇文章中介绍它。
Go内存管理的基本理念和TCMalloc类似,名称有些变化。

Page
和TCMalloc中page相同,在X86下page大小是8KB,上图最下方的蓝色方格就是一个page。
Span
与TCMalloc中span相同,一组连续的页组成一个span,为内存管理的基本单位,代码中为mspan。
span有哪些类型呢?源码里有注释,一共是67种。超过32K的对象由class 0表示,该类class只包含一个对象。
- class: 每个span结构中都有一个class ID, 表示该span可处理的对象类型
- bytes/obj:该class代表对象的字节数
- bytes/span:每个span占用堆的字节数
- objects: 每个span可分配的对象个数,也即(bytes/spans)/(bytes/obj)
- max waste: span最大的浪费比率
// class bytes/obj bytes/span objects tail waste max waste min align
// 1 8 8192 1024 0 87.50% 8
// 2 16 8192 512 0 43.75% 16
// 3 24 8192 341 8 29.24% 8
// 4 32 8192 256 0 21.88% 32
// 5 48 8192 170 32 31.52% 16
// 6 64 8192 128 0 23.44% 64
// 7 80 8192 102 32 19.07% 16
// 8 96 8192 85 32 15.95% 32
// 9 112 8192 73 16 13.56% 16
// 10 128 8192 64 0 11.72% 128
// 11 144 8192 56 128 11.82% 16
// 12 160 8192 51 32 9.73% 32
// 13 176 8192 46 96 9.59% 16
// 14 192 8192 42 128 9.25% 64
// 15 208 8192 39 80 8.12% 16
// 16 224 8192 36 128 8.15% 32
// 17 240 8192 34 32 6.62% 16
// 18 256 8192 32 0 5.86% 256
// 19 288 8192 28 128 12.16% 32
// 20 320 8192 25 192 11.80% 64
// 21 352 8192 23 96 9.88% 32
// 22 384 8192 21 128 9.51% 128
// 23 416 8192 19 288 10.71% 32
// 24 448 8192 18 128 8.37% 64
// 25 480 8192 17 32 6.82% 32
// 26 512 8192 16 0 6.05% 512
// 27 576 8192 14 128 12.33% 64
// 28 640 8192 12 512 15.48% 128
// 29 704 8192 11 448 13.93% 64
// 30 768 8192 10 512 13.94% 256
// 31 896 8192 9 128 15.52% 128
// 32 1024 8192 8 0 12.40% 1024
// 33 1152 8192 7 128 12.41% 128
// 34 1280 8192 6 512 15.55% 256
// 35 1408 16384 11 896 14.00% 128
// 36 1536 8192 5 512 14.00% 512
// 37 1792 16384 9 256 15.57% 256
// 38 2048 8192 4 0 12.45% 2048
// 39 2304 16384 7 256 12.46% 256
// 40 2688 8192 3 128 15.59% 128
// 41 3072 24576 8 0 12.47% 1024
// 42 3200 16384 5 384 6.22% 128
// 43 3456 24576 7 384 8.83% 128
// 44 4096 8192 2 0 15.60% 4096
// 45 4864 24576 5 256 16.65% 256
// 46 5376 16384 3 256 10.92% 256
// 47 6144 24576 4 0 12.48% 2048
// 48 6528 32768 5 128 6.23% 128
// 49 6784 40960 6 256 4.36% 128
// 50 6912 49152 7 768 3.37% 256
// 51 8192 8192 1 0 15.61% 8192
// 52 9472 57344 6 512 14.28% 256
// 53 9728 49152 5 512 3.64% 512
// 54 10240 40960 4 0 4.99% 2048
// 55 10880 32768 3 128 6.24% 128
// 56 12288 24576 2 0 11.45% 4096
// 57 13568 40960 3 256 9.99% 256
// 58 14336 57344 4 0 5.35% 2048
// 59 16384 16384 1 0 12.49% 8192
// 60 18432 73728 4 0 11.11% 2048
// 61 19072 57344 3 128 3.57% 128
// 62 20480 40960 2 0 6.87% 4096
// 63 21760 65536 3 256 6.25% 256
// 64 24576 24576 1 0 11.45% 8192
// 65 27264 81920 3 128 10.00% 128
// 66 28672 57344 2 0 4.91% 4096
// 67 32768 32768 1 0 12.50% 8192
mcache
mcache与TCMalloc中的ThreadCache类似,mcache保存的是各种大小的span链表,小对象直接从mcache分配,它起到缓存的作用,并且无需加锁访问。
不同于ThreadCache的是,TCMalloc中是每个线程一个ThreadCache,而Go中是每个P拥有一个mcache。P是和线程绑定的,Go中最多有GOMAXPROCS个线程运行,因此每个P一个cache同样可以保证mcache的无锁访问。
另外一个不同点,相对于TCMalloc中的ThreadCache,mcache对每个级别的span,保存了两个链表:
- scan -- 包含指针
- noscan -- 不包含指针
这样设计的好处在于,垃圾回收阶段,我们不需要扫描noscan类型的链表去查看是否还有回收对象。
mcentral
与TCMalloc的CentralCache类似,是所有线程的缓存,访问需要加锁。它按span class对span进行分类,串联成链表。当mcache中某个级别的span不够时,会向mcentral申请一个当前级别的span。
与TCMalloc不同的是,mcentral对于每个级别的链表,保存了两个链表,用于协助Go垃圾回收:
- empty -- 所有的span都不确定里面是否有空闲的对象空间。当一个span交给mcache的时候,就会加入到empty链表
- noempty -- 所有span都至少有1个空闲的对象空间。mcache释放span时加入到该链表的

mheap
mheap是堆内存的抽象,它将从OS获取的内存页组织成span保存起来。当mcentral内存不够用时,会向mheap申请;mheap不够用时,会向OS申请。
TCMalloc将span组织成链表,而mheap将span组织成了树结构,而且是两颗二叉排序树。
- free -- 保存的是空闲的并且非垃圾回收的span
- scav -- 保存的是空闲,并且垃圾回收的span
如果是垃圾回收得到的空闲span,会放到free中,否则放到scav中(比如刚从OS申请得到的内存页)。
当mcentral向mheap申请内存时,提供需要的内存页数和span class,mheap会按照先free后scav的顺序搜索可用的span,如果没有找到,会向OS申请新内存。如果找到的span比需求的大,会将span拆分成两个,1个刚好够大小,交给mcentral,另一个则会放到free中。
Go内存分配
Go不想TCMalloc一样将对象分为大、中、小,只有大对象和小对象。但在小对象里分出了一个Tiny对象,指的是从1byte到16byte之间,不包含指针的对象。
分配流程:
1、如果对象大于32K,直接分配到mheap上
2、如果对象在16B~32K之间,计算最合适的size class,将它分配到mcache上对应的mspan中
3、如果对象小于16B,Go会使用Tiny allocator算法,将多个小对象合并到同一个内存块中
大对象分配,和mcentral向mheap申请内存的方式是类似的。
Appendix
上面提到的mspan,mcache,mcentral,mheap数据都是直接从OS申请而来的,并不在Go堆管理的那部分内存内。Go程序启动时的内存组织如图所示:
预申请的内存划分为spans、bitmap、arena三部分。其中arena即为所谓的堆区,应用中需要的内存从这里分配。其中spans和bitmap是为了管理arena区而存在的。
- spans区域用于存放mspan元数据,它的结构如下:
startAddr是在span初始化时就指定了arena中某个页的地址
type mspan struct {
next *mspan //链表前向指针,用于将span链接起来
prev *mspan //链表前向指针,用于将span链接起来
startAddr uintptr // 起始地址,也即所管理页的地址
npages uintptr // 管理的页数
nelems uintptr // 块个数,也即有多少个块可供分配
allocBits *gcBits //分配位图,每一位代表一个块是否已分配
allocCount uint16 // 已分配块的个数
spanclass spanClass // class表中的class ID
elemsize uintptr // class表中的对象大小,也即块大小
}
- allocBits指向bitmaps区域中的一个位图,每位代表一个块是否被分配。
参考资料
Understanding Allocations in Go