Inside go:内存分配机制

795 阅读13分钟

前言

得益于Go内置的内存管理机制,我们不用关心内存处理细节。一方面,了解这些底层逻辑,可以帮助我们更合理创建对象,提高内存使用效率。另一方面,Go对内存的管理机制本身就很有意思。

我们所熟知的堆和栈

在大多数编程语言中,栈指的是操作系统线程的栈,用于存储函数参数、局部变量。栈是一个LIFO(last in first out)的结构,一个函数调用会压入一个栈帧,函数调用返回会pop一个栈帧,它所占用的内存也都会被释放,所以任何引用栈上对象的指针,在此之后都会不可用。因此我们需要用到堆。 overview of stack 堆是一个更复杂的内存空间,应用程序从操作系统拿到的是一块连续的虚拟地址空间,如何高效率的分配与回收内存,减少内存碎片,这是应用程序的事。

Go的堆和栈

Go语言用并发度更好的goroutine来替代线程,与线程类似,每个goroutine都有自己的栈。不同于线程的是,goroutine位于用户空间,因此它的行为是由运行时(而不是操作系统)来定义。

线程创建时,会由操作系统分配固定大小的栈,而goroutine的栈一开始是2KB(目前),之后可以在运行过程中扩容和缩容。需要注意的是,go采用了连续栈的实现方式,也就是说栈扩容或缩容时,我们会找到一块符合大小的空间,然后将原栈上的内容全部拷贝到新的空间内。

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

TCMalloc

TCMalloc是Thread Cache Malloc的简称,是Go内存管理的起源;随着Go的迭代,Go的内存管理与TCMalloc不一致地方在不断扩大。但其主要思想、原理和概念都是和TCMalloc一致的。掌握TCMalloc的理念,可以更好的理解Go的内存管理方式。

  • 将内存分类,按照不同大小组织成各级链表,根据对象尺寸分配到合适的方格中
  • TCMalloc的做法是为每个线程预分配一块缓存,线程申请小内存时,可以从缓存分配内存,这样做有两个好处:
    • 为线程分配缓存需要一次系统调用,后续线程申请小内存时,从缓存分配,都是在用户态进行,没有系统调用,降低了分配时间。
    • 多个线程申请小内存时,都从缓存中分配,没有并发加锁的竞争。

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链表,用来存法大对象。 PageHeap 上面提到了大对象的概念,TCMalloc中关于对象大小的定义:

  1. 小对象大小:0~256KB
  2. 中对象大小:257~1MB
  3. 大对象大小:>1MB

小对象的分配流程:ThreadCache -> CentralCache -> PageHeap,大部分时候ThreadCache内存都是充足的,不需要访问CenralCache和PageHeap,分配效率非常高。 中对象存储流程:从PageHeap中选择适当的大小即可,128Page能保存的最大内存是1MB 大对象分配流程:从large span set中选择数量合适的页组成span,用于存储数据。

Go内存管理

Go的内存管理起源于TCMalloc,但它还包括了另外两样东西:逃逸分析和垃圾收集。逃逸分析我们下面会介绍,垃圾收集是另一个比较大的话题,我会在另一篇文章中介绍它。

Go内存管理的基本理念和TCMalloc类似,名称有些变化。 Go内存管理

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区域中的一个位图,每位代表一个块是否被分配。

参考资料

Go内存分配那些事,就这么简单!

Understanding Allocations in Go

A visual guide to Go Memory Allocator from scratch (Golang)

简单易懂的 Go 内存分配原理解读