Go语言内存管理_开发者

Go这门语言抛弃了C/C++中的开发者管理内存的方式,实现了主动申请与主动释放管理,增加了逃逸分析和GC,将开发者从内存管理中释放出来,让开发者有更多的精力去关注软件设计,而不是底层的内存问题。这是Go语言成为高生产力语言的原因之一。

我们不需要精通内存的管理,因为它确实很复杂,但掌握内存的管理,可以让你写出更高质量的代码,另外,还能助你定位Bug。这篇文章采用层层递进的方式,依次会介绍关于存储的基本知识,Go内存管理的 “前辈” TCMalloc,然后是Go的内存管理和分配,最后是总结。这么做的目的是,希望各位能通过全局的认识和思考,拥有更好的编码思维和架构思维。

存储金字塔

Go语言内存管理_开发者_02

这幅图表达了计算机的存储体系,从上至下的访问速度越来越慢,访问时间越来越长。从上至下依次是:

(1) CPU寄存器

(2) CPU Cache

(3) 内存

(4) 硬盘等辅助存储设备

(5) 鼠标等外接设备

问题 :

   1.CPU直接访问硬盘,CPU能充分利用么?

   2.CPU直接访问内存,CPU能充分利用么?

Go语言内存管理_开发者_03

  然而,CPU跟内存的速率也不是相同的,从上图可以看到,CPU的速率提高的很快(摩尔定律),然而内存速率增长的很慢,虽然CPU的速率现在增加的很慢了,但是内存的速率也没增加多少,速率差距很大,从1980年开始CPU和内存速率差距在不断拉大,为了弥补这2个硬件之间的速率差异,所以在CPU跟内存之间增加了比内存更快的Cache,Cache是内存数据的缓存,可以降低CPU访问内存的时间。

Go语言内存管理_物理内存_04


三级Cache分别是L1、L2、L3,它们的速率是三个不同的层级,L1速率最快,与CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到这了,你有没有Get到整个存储体系的分层设计?自顶向下,速率越来越低,访问时间越来越长,从磁盘到CPU寄存器,上一层都可以看做是下一层的缓存。看了分层设计,下面开始正式介绍内存。

虚拟内存

虚拟内存是当代操作系统必备的一项重要功能,对于进程而言虚拟内存屏蔽了底层了RAM和磁盘,并向进程提供了远超物理内存大小的内存空间。我们看一下虚拟内存的分层设计

Go语言内存管理_开发者_05

 上图展示了某进程访问数据,当Cache没有命中的时候,访问虚拟内存获取数据的过程。在访问内存,实际访问的是虚拟内存,虚拟内存通过页表查看,当前要访问的虚拟内存地址,是否已经加载到了物理内存。如果已经在物理内存,则取物理内存数据,如果没有对应的物理内存,则从磁盘加载数据到物理内存,并把物理内存地址和虚拟内存地址更新到页表。

物理内存就是磁盘存储缓存层,在没有虚拟内存的时代,物理内存对所有进程是共享的,多进程同时访问同一个物理内存会存在并发问题。而引入虚拟内存后,每个进程都有各自的虚拟内存,内存的并发访问问题的粒度从多进程级别,可以降低到多线程级别。

栈和堆

我们现在从虚拟内存,再进一层,看虚拟内存中的栈和堆,也就是进程对内存的管理。

Go语言内存管理_虚拟内存_06

    上图展示了一个进程的虚拟内存划分,代码中使用的内存地址都是虚拟内存地址,而不是实际的物理内存地址。栈和堆只是虚拟内存上2块不同功能的内存区域。

栈在高地址,从高地址向低地址增长

堆在低地址,从低地址向高地址增长

栈和堆相比有这么几个好处:

栈的内存管理简单,分配比堆上快。

栈的内存不需要回收,而堆需要进行回收,无论是主动free,还是被动的垃圾回收,这都需要花费额外的CPU。

栈上的内存有更好的局部性,堆上内存访问就不那么友好了,CPU访问的2块数据可能在不同的页上,CPU访问数据的时间可能就上去了。

堆内存管理

    Go语言内存管理_物理内存_07

  我们再进一层,当我们说内存管理的时候,主要是指堆内存的管理,因为栈的内存管理不需要程序去操心,这小节看下堆内存管理到底完成了什么。如上图所示主要是3部分,分别是分配内存块,回收内存块和组织内存块。

  在一个最简单的内存管理中,堆内存最初会是一个完整的大块,即未分配任何内存。当发现内存申请的时候,堆内存就会从未分配内存分割出一个小内存块(block),然后用链表把所有内存块连接起来。需要一些信息描述每个内存块的基本信息,比如大小(size)、是否使用中(used)和下一个内存块的地址(next),内存块实际数据存储在data中。

Go语言内存管理_虚拟内存_08

一个内存块包含了3类信息,如下图所示,元数据、用户数据和对齐字段,内存对齐是为了提高访问效率。下图申请5Byte内存的时候,就需要进行内存对齐

Go语言内存管理_开发者_09

释放内存实质是把使用的内存块从链表中取出来,然后标记为未使用,当分配内存块的时候,可以从未使用内存块中优先查找大小相近的内存块,如果找不到,再从未分配的内存中分配内存。

上面这个简单的设计中还没考虑内存碎片的问题,因为随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。为了解决内存碎片,可以将2个连续的未使用的内存块合并,减少碎片。

以上就是内存管理的基本思路,关于基本的内存管理,想了解更多,可以阅读这篇文章《Writing a Memory Allocator》,本节的3张图片也是来自这篇文章。


TCMalloc

TCMalloc是Thread Cache Malloc的简称,是Go内存管理的起源,Go的内存管理是借鉴了TCMalloc,随着Go的迭代,Go的内存管理与TCMalloc不一致地方在不断扩大,但其主要思想、原理和概念都是和TCMalloc一致的

TCMalloc的做法是什么呢?为每个线程预分配一块缓存,线程申请小内存时,可以从缓存分配内存,这样有2个好处:

为线程预分配缓存需要进行1次系统调用,后续线程申请小内存时直接从缓存分配,都是在用户态执行的,没有了系统调用,缩短了内存总体的分配和释放时间,这是快速分配内存的第二个层次。

多个线程同时申请小内存时,从各自的缓存分配,访问的是不同的地址空间,从而无需加锁,把内存并发访问的粒度进一步降低了,这是快速分配内存的第三个层次。

基本原理

Go语言内存管理_开发者_10

几个重要概念 :

  Page : 操作系统对内存管理以页为单位,TCMalloc也是这样,只不过TCMalloc里的Page大小与操作系统里的大小并不一定相等,而是倍数关系。《TCMalloc解密》里称x64下Page大小是8KB

Span :一组连续的Page被称为Span,比如可以有2个页大小的Span,也可以有16页大小的Span,Span比Page高一个层级,是为了方便管理一定大小的内存区域,Span是TCMalloc中内存管理的基本单位

ThreadCache :ThreadCache是每个线程各自的Cache,一个Cache包含多个空闲内存块链表,每个链表连接的都是内存块,同一个链表上内存块的大小是相同的,也可以说按内存块大小,给内存块分了个类,这样可以根据申请的内存大小,快速从合适的链表选择空闲内存块。由于每个线程有自己的ThreadCache,所以ThreadCache访问是无锁的。

CentralCache :CentralCache是所有线程共享的缓存,也是保存的空闲内存块链表,链表的数量与ThreadCache中链表数量相同,当ThreadCache的内存块不足时,可以从CentralCache获取内存块;当ThreadCache内存块过多时,可以放回CentralCache。由于CentralCache是共享的,所以它的访问是要加锁的。

PageHeap : PageHeap是对堆内存的抽象,PageHeap存的也是若干链表,链表保存的是Span。当CentralCache的内存不足时,会从PageHeap获取空闲的内存Span,然后把1个Span拆成若干内存块,添加到对应大小的链表中并分配内存;当CentralCache的内存过多时,会把空闲的内存块放回PageHeap中。

如下图所示,分别是1页Page的Span链表,2页Page的Span链表等,最后是large span set,这个是用来保存中大对象的。毫无疑问,PageHeap也是要加锁的。

Go语言内存管理_虚拟内存_11

前文提到了小、中、大对象,Go内存管理中也有类似的概念,我们看一眼TCMalloc的定义:

小对象大小:0~256KB  大部分情况下ThreadCache足够,不需要锁

中对象大小:257~1MB  从pageHeap 堆内存中拿,需要加锁

大对象大小:>1MB     也需要加锁

小对象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分时候,ThreadCache缓存都是足够的,不需要去访问CentralCache和HeapPage,无系统调用配合无锁分配,分配效率是非常高的。

中对象分配流程:直接在PageHeap中选择适当的大小即可,128 Page的Span所保存的最大内存就是1MB。

大对象分配流程:从large span set选择合适数量的页面组成span,用来存储数据。

Go内存管理

前文提到Go内存管理源自TCMalloc,但它比TCMalloc还多了2件东西:逃逸分析和垃圾回收,这是2项提高生产力的绝佳武器。这一大章节,我们先介绍Go内存管理和Go内存分配,最后涉及一点垃圾回收和内存释放。

Go内存管理的许多概念在TCMalloc中已经有了,含义是相同的,只是名字有一些变化。先给大家上一幅宏观的图,借助图一起来介绍。

传统意义上的栈被Go的运行时霸占,不开放给用户态代码;而传统意义上的

(系统)堆内存,又被Go运行时划分为两个部分,Go运行时所需的堆内存(堆外内存) 和用于Go用户态代码使用的堆内存,也成为Go堆,负责用户态对象的存放以及goroutine的执行栈

目前Go运行时使用的是连续栈机制,当一个执行栈发生溢出时,新建一个两倍于原栈大小的新栈,再将整个原栈复制到新栈上,从而栈在内存上连续

Go语言内存管理_虚拟内存_12

Page :与TCMalloc中的Page相同,x64架构下1个Page的大小是8KB。上图的最下方,1个浅蓝色的长方形代表1个Page

Span:Span与TCMalloc中的Span相同,Span是内存管理的基本单位,代码中为mspan,一组连续的Page组成1个Span,所以上图一组连续的浅蓝色长方形代表的是一组Page组成的1个Span,另外,1个淡紫色长方形为1个Span

mcache :mcache与TCMalloc中的ThreadCache类似,mcache保存的是各种大小的Span,并按Span class分类,小对象直接从mcache分配内存,它起到了缓存的作用,并且可以无锁访问。但是mcache与ThreadCache也有不同点,TCMalloc中是每个线程1个ThreadCache,Go中是每个P拥有1个mcache。因为在Go程序中,当前最多有GOMAXPROCS个线程在运行,所以最多需要GOMAXPROCS个mcache就可以保证各线程对mcache的无锁访问,线程的运行又是与P绑定的,把mcache交给P刚刚好。

mcentral :mcentral与TCMalloc中的CentralCache类似,是所有线程共享的缓存,需要加锁访问。它按Span级别对Span分类,然后串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。

但是mcentral与CentralCache也有不同点,CentralCache是每个级别的Span有1个链表,mcache是每个级别的Span有2个链表,这和mcache申请内存有关,稍后我们再解释。

mheap : mheap与TCMalloc中的PageHeap类似,它是堆内存的抽象,把从OS申请出的内存页组织成Span,并保存起来。当mcentral的Span不够用时会向mheap申请内存,而mheap的Span不够用时会向OS申请内存。mheap向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。

但是mheap与PageHeap也有不同点:mheap把Span组织成了树结构,而不是链表,并且还是2棵树,然后把Span分配到heapArena进行管理,它包含地址映射和span是否包含指针等位图,这样做的主要原因是为了更高效的利用内存:分配、回收和再利用。

Go语言内存管理_虚拟内存_13

Go语言内存管理_虚拟内存_14

 Go内存分配

   Go中的内存分类并不像TCMalloc那样分成小、中、大对象,但是它的小对象里又细分了一个Tiny对象,Tiny对象指大小在1Byte到16Byte之间并且不包含指针的对象。小对象和大对象只用大小划定,无其他区分。

类别

内存大小

微对象

(0,16B)

小对象

(16B,32KB)

大对象

(> 32KB)

多级缓存

内存分配器不仅会区别对待大小不同的对象,还会将内存分成不同的级别分别管理,Go运行时分配器都会引入线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)三个组件分级管理内存。

Go语言内存管理_物理内存_15

线程缓存属于每一个独立的线程,它能够满足线程上绝大多数的内存分配需求,因为不涉及多线程,所以也不需要使用互斥锁来保护内存,这能够减少锁竞争带来的性能损耗。当线程缓存不能满足需求时,运行时会使用中心缓存作为补充解决小对象的内存分配,在遇到 32KB 以上的对象时,内存分配器会选择页堆直接分配大内存。

这种多层级的内存分配设计与计算机操作系统中的多级缓存有些类似,因为多数的对象都是小对象,我们可以通过线程缓存和中心缓存提供足够的内存空间,发现资源不足时从上一级组件中获取更多的内存资源。

mspan是Go语言内存管理的最小单元。其是一个双向链表的结构,这样可以方便我们从任一节点就能直接访问到所以的管理单元。每个span管理8KB大小的页,这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍。

Go定义了67中不同大小的mspan,每一个mspan都会存储特定大小的对象并且包含特定数量的页数以及对象,所有的数据都会被预选计算好并存储。Id为0的mspan单元大小是32KB,专门用于分配大的内存。

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

0

29.24%

4

32

8192

256

0

46.88%

5

48

8192

170

32

31.52%

6

64

8192

128

0

23.44%

7

80

8192

102

32

19.07%

67

32768

32768

1

0

12.50%

  程序向mspan申请内存时,会根据申请的内存大小按需分配,可以在管理的内存中快速找到需要分配的空间。


小对象是在mcache中分配的,而大对象是直接从mheap分配的,从小对象的内存分配看起。

小对象的内存分配

   大小转换这一小节,我们介绍了转换表,size class从1到66共66个,代码中_NumSizeClasses=67代表了实际使用的size class数量,即67个,从0到67,size class 0实际并未使用到。


寻找span的流程如下:

计算对象所需内存大小size

根据size到size class映射,计算出所需的size class

根据size class和对象是否包含指针计算出span class

获取该span class指向的span

从span分配对象空间

  Span可以按对象大小切成很多份,这些都可以从映射表上计算出来,以size class 3对应的span为例,span大小是8KB,每个对象实际所占空间为32Byte,这个span就被分成了256块,可以根据span的起始地址计算出每个对象块的内存地址。

Go语言内存管理_虚拟内存_16

   随着内存的分配,span中的对象内存块,有些被占用,有些未被占用,比如上图,整体代表1个span,蓝色块代表已被占用内存,绿色块代表未被占用内存。当分配内存时,只要快速找到第一个可用的绿色块,并计算出内存地址即可,如果需要还可以对内存块数据清零。

当span内的所有内存块都被占用时,没有剩余空间继续分配对象,mcache会向mcentral申请1个span,mcache拿到span后继续分配对象。

mcache向mcentral申请span

mcentral和mcache一样,都是0~133这134个span class级别,但每个级别都保存了2个span list,即2个span链表:

nonempty:这个链表里的span,所有span都至少有1个空闲的对象空间。这些span是mcache释放span时加入到该链表的。

empty:这个链表里的span,所有的span都不确定里面是否有空闲的对象空间。当一个span交给mcache的时候,就会加入到empty链表。

这两个东西名称一直有点绕,建议直接把empty理解为没有对象空间就好了。

Go语言内存管理_物理内存_17

mcache向mcentral申请span时,mcentral会先从nonempty搜索满足条件的span,如果没有找到再从emtpy搜索满足条件的span,然后把找到的span交给mcache。

mheap里保存了两棵二叉排序树,按span的page数量进行排序:

free:free中保存的span是空闲并且非垃圾回收的span。

scav:scav中保存的是空闲并且已经垃圾回收的span。

 如果是垃圾回收导致的span释放,span会被加入到scav,否则加入到free,比如刚从OS申请的的内存也组成的Span。

Go语言内存管理_开发者_18

mheap中还有arenas,由一组heapArena组成,每一个heapArena都包含了连续的pagesPerArena个span,这个主要是为mheap管理span和垃圾回收服务。mheap本身是一个全局变量,它里面的数据,也都是从OS直接申请来的内存,并不在mheap所管理的那部分内存以内。

mcentral向mheap申请span

当mcentral向mcache提供span时,如果empty里也没有符合条件的span,mcentral会向mheap申请span。

此时,mcentral需要向mheap提供需要的内存页数和span class级别,然后它优先从free中搜索可用的span。如果没有找到,会从scav中搜索可用的span。如果还没有找到,它会向OS申请内存,再重新搜索2棵树,必然能找到span。如果找到的span比需要的span大,则把span进行分割成2个span,其中1个刚好是需求大小,把剩下的span再加入到free中去,然后设置需要的span的基本信息,然后交给mcentral

mheap向OS申请内存

当mheap没有足够的内存时,mheap会向OS申请内存,把申请的内存页保存为span,然后把span插入到free树。在32位系统中,mheap还会预留一部分空间,当mheap没有空间时,先从预留空间申请,如果预留空间内存也没有了,才向OS申请。

大对象的内存分配

大对象的分配比小对象省事多了,99%的流程与mcentral向mheap申请内存的相同,所以不重复介绍了。不同的一点在于mheap会记录一点大对象的统计信息,详情见mheap.alloc_m()。

Go垃圾回收和内存释放

如果只申请和分配内存,内存终将枯竭。Go使用垃圾回收收集不再使用的span,调用mspan.scavenge()把span释放还给OS(并非真释放,只是告诉OS这片内存的信息无用了,如果你需要的话,收回去好了),然后交给mheap,mheap对span进行span的合并,把合并后的span加入scav树中,等待再分配内存时,由mheap进行内存再分配。

现在我们关注一下,Go程序是怎么把内存释放给操作系统的?释放内存的函数是sysUnused,它会被mspan.scavenge()调用:

Go的栈内存

最后提一下栈内存。从一个宏观的角度看,内存管理不应当只有堆,也应当有栈。每个goroutine都有自己的栈,栈的初始大小是2KB,100万的goroutine会占用2G,但goroutine的栈会在2KB不够用时自动扩容,当扩容为4KB的时候,百万goroutine会占用4GB。


Go的内存分配原理就不再回顾了,它主要强调两个重要的思想:

使用缓存提高效率。在存储的整个体系中到处可见缓存的思想,Go内存分配和管理也使用了缓存,利用缓存一是减少了系统调用的次数,二是降低了锁的粒度、减少加锁的次数,从这2点提高了内存管理效率。

以空间换时间,提高内存管理效率。空间换时间是一种常用的性能优化思想,这种思想其实非常普遍,比如Hash、Map、二叉排序树等数据结构的本质就是空间换时间,在数据库中也很常见,比如数据库索引、索引视图和数据缓存等,再如Redis等缓存数据库也是空间换时间的思想。