go语言的内存管理是建立在操作系统的内存管理之上的,它最大化程度上的发挥了操作系统内存管理层面的优势,避开了导致低效的情况。
go实现了主动申请与主动释放管理,增加了逃逸分析和gc(垃圾回收),将开发者从内存管理中释放出来,让开发者有更多的精力去关注软件设计,而不是底层的内存问题。这是go语言成为高生产力语言的原因之一。
重要的核心思量分为以下几点:
- 每次从操作系统申请一大块儿的内存,由go来对这块儿内存做分配,减少系统调用
- 内存分配算法采用google的tcmalloc算法。其核心思想就是把内存切分的非常的细小,分为多级管理,以降低锁的粒度。
- 回收对象内存时,并没有将其真正释放掉,只是放回预先分配的大块内存中,以便复用。只有内存闲置过多的时候,才会尝试归还部分内存给操作系统,降低整体开销
1.1 虚拟内存
我们知道由于cpu的访问速度远大于硬盘等存储设备的访问速度,所以为了弥补这两中硬件之间的速率差异,增加了比硬盘快很多的内存。摩尔定律告诉我们cpu的速率提高的很快,而内存速率增长的很慢,虽说近几年两者之间速率的差增长的较慢了,但是这种速率差依旧很大,所以又引入了比内存更快的cache,我们对各个设备的访问速率进行简单的排名:
- cpu寄存器
- cpu cache
- 内存
- 硬盘等存储设备
- 鼠标等外设
除了速率冲突之外,为了实现多任务的需求,往往会出现一些问题:
- 内存访问冲突,这往往是多个程序使用同一块内存空间导致的;
- 内存不够用,每个程序都需要自己单独使用一块内存,内存大小就成了任务数量的瓶颈;
- 程序开发成本高,程序需使用多少内存,内存地址是多少,都不能搞错,正确的开发难度高。
所以引入了虚拟内存的概念,所有程序使用统一的连续虚拟内存,从程序角度来看它会觉得自己独享了一整块内存,这也就解决了第一个问题——冲突问题。
二来对于内存不够用的问题,虚拟内存本质上是将磁盘当成了最终存储,程序往往从虚拟内存上申请了很大的空间使用,但操作系统不会真的在物理内存上开辟大空间,往往只是开辟一小块。当程序访问内存时,操作系统发现访问的地址无法转为物理地址就在开辟空间给程序使用,如果成功转换,就正常访问。
对于进程而言虚拟内存屏蔽了底层的ram和磁盘,并向进程提供了远超物理内存大小的内存空间。
1.2 cpu内存访问基本过程
我们看一下cpu内存访问的完整过程:
- cpu使用虚拟地址访问数据,执行mov指令(数据传送指令)加载数据到寄存器,把地址传递给mmu(内存管理单元);
- mmu 生成 pte(页表) 地址,并从主存(或自己的 cache)中得到它。
- 如果 mmu 根据 pte 得到真实的物理地址,正常读取数据。流程到此结束。
- 如果 pte 信息表示没有关联的物理地址,mmu 则触发一个缺页异常。
- 操作系统捕获到这个异常,开始执行缺页处理程序。在物理内存上创建一页内存,并更新页表。
- 缺页处理程序在物理内存中确定一个牺牲页,如果这个牺牲页上有数据,则把数据保存到磁盘上,然后将虚拟内存中的内容拷贝到这个牺牲页上。
- 缺页处理程序更新 pte。
- 缺页处理程序结束,再回去执行上一条指令(导致缺页异常的那个指令,也就是 mov 指令)。这次肯定命中了。
以上的访问过程中我们认为如果内存访问到第三步就成功结束,就说页命中了,反之就是未命中,或者可以说是缺页了。所以引出了一个命中率这样的衡量指标,假设n次内存访问中,出现命中的次数是m,那么m/n*100%
表示命中率,这个指标可以用来衡量内存管理程序的好坏。
如果物理内存不足,数据会频繁在主存和磁盘之间交换,命中率低,性能下降,我们也称之为内存颠簸,这时系统的swap空间利用率增高,cpu利用率中iowait占比增高。
如果物理内存够用,页命中率不会很低,不会出现内存颠簸的情况,因为大所处程序都有局部性的特点,所谓的局部性指的是被引用过一次的存储器位置,很有可能之后再次被引用,而且该位置附近的其他位置,也很有可能在后续一段时间内被引用。
1.3 程序的内存布局
我们知道了每个程序都有自己一套独立的地址空间可以使用,但我们在用高级语言,无论是 c 还是 go 写程序的时候,很少直接使用这些地址,而是通过变量名来访问数据的,编译器会自动将我们的变量名转换成真正的虚拟地址。
每一个程序所拥有的虚拟内存空间如下:
我们接下来细看虚拟内存中的堆和栈,也就是进程对内存的管理,在虚拟内存中有2块功能不同的内存区域:
- 栈在高地址,从高地址向低地址增长;
- 堆在低地址,从低地址向高地址增长;
栈和堆相比的好处:
- 栈的内存管理简单,分配比堆上块,栈通过压栈出栈方式自动分配释放的,由系统管理,使用起来高效无感知,而堆用以动态分配的,由程序自己管理分配和释放;
- 栈的内存不需要回收,而堆需要回收;
- 栈上的内存有更好的局部性,堆上的内存访问就不这么友好;
tcmalloc是thread cache malloc的简称,是go内存管理的起源。我们前面提到引入虚拟内存后,让内存的并发访问问题的粒度从多进程级别,降低到多线程级别。然而同一进程下的所有线程共享相同的内存空间,它们申请内存时需要加锁,如果不加锁就存在同一块内存被2个线程同时访问的问题。
tcmalloc的做法是什么呢?为每个线程预分配一块缓存,线程申请小内存时,可以从缓存分配内存,这样有2个好处:
- 为线程预分配缓存需要进行1次系统调用,后续线程申请小内存时直接从缓存分配,都是在用户态执行的,没有了系统调用,缩短了内存总体的分配和释放时间,这是快速分配内存的第二个层次。
- 多个线程同时申请小内存时,从各自的缓存分配,访问的是不同的地址空间,从而无需加锁,把内存并发访问的粒度进一步降低了,这是快速分配内存的第三个层次。
2.1 内存池
内存池是一种内存管理方式,通常应用在服务器端后台开发中,主要是为了避免进程在运行的过程中频繁的从堆上申请内存,使用以空间换取时间的方式来提高运行效率。比如linux中常常直接调用mmap申请内存,但这会带来一些代价:
- 系统调用,需要程序从用户态切换到内核态,再调用结束后又需要返回用户态;
- 频繁申请很小的内存空间,容易出现大量的内存碎片,增大了操作系统整理碎片的压力;
- 为了保证内存访问具有良好的局部性,开发者需要投入大量的精力去做优化,这是个很重的负担;
而解决的方式就是实现一个内存池,首先需要向操作系统申请大块内存交由其进行管理。当上层释放内存时它不实际归还操作系统,而是放回池子中重复利用。这样子就不需要频繁申请内存,对象池将会被频繁利用,不会出现碎片,且程序所访问的是同一块内存空间,具有良好的局部性。
2.2 多方面讨论内存管理
在多线程方面,很自然的做法就是每条线程都有自己的本地的内存,然后有一个全局的分配链,当某个线程中内存不足后就向全局分配链中申请内存。这样就避免了多线程同时访问共享变量时的加锁。
在避免内存碎片方面,大块内存直接按页为单位分配,小块内存会切成各种不同的固定大小的块,申请做任意字节内存时会向上取整到最接近的块,将整块分配给申请者以避免随意切割。
系统线程的内存分配方面,主要是使用了一个本地的mcache,少量的地址分配就直接从mcache中分配,并且定期做垃圾回收,将线程的mcache中的空闲内存返回给全局控制堆。小于32k为小对象,大对象直接从全局控制堆上以页(4k)为单位进行分配,也就是说大对象总是以页对齐的。一个页可以存入一些相同大小的小对象,小对象从本地内存链表中分配,大对象从中心内存堆中分配。
2.3 go中的内存池
go的内存管理其实本质上就是一个内存池,只不过内部做了很多优化,比如自动伸缩内存池大小,合理的分割内存块等。
2.3.1 mheap
程序启动初始,将会一次性从系统申请大块内存作为内存池,这个内存空间将会被放在mheap中进行管理。
mheap是用于做什么的?
负责将申请的一大块内存分割为不同的区域,并将其中的一部内存分割为合适的大小,分配给用户使用。
这其中就会涉及几个概念:
概念 | 大小/组成 | 其他 |
---|---|---|
page(页) | 8k | go与操作系统之间的内存申请和释放都是以page为单位的 |
span(内存块) | 一个或多个连续page组成 | / |
sizeclass(空间规格) | / | 每个span都带有一个sizeclass的标志,用于标志其中的page如何使用 |
object(对象) | 存储一个变量的数据内存空间 | span再进行初始化时会分割为一堆等大的object |
所谓的内存分配其实就是分配一个object。
mheap的结构体如下:
type mheap struct {
// other fields
lock mutex
free [_maxmheaplist]mspan // free lists of given length, 1m 以下
freelarge mspan // free lists length >= _maxmheaplist, >= 1m
busy [_maxmheaplist]mspan // busy lists of large objects of given length
busylarge mspan // busy lists of large objects length >= _maxmheaplist
central [_numsizeclasses]struct {
// _numsizeclasses = 67
mcentral mcentral
// other fields
}
// other fields
}
2.3.2 mcentral
用途相同(sizeclass相等)的span将会以链表的形式组织在一起,其中的sizeclass局势指该span用来存储哪种大小的对象,
sizeclass在分配一块大小为n的内存时的用法:
- 系统计算n应该使用哪种sizeclass
- 然后根据sizeclass找到一个可用的span来用作分配
mheap在申请得到一个大span,由mheap.freelarge和mheap.busylarge中进行管理,申请好后需要将这个大span分割为小span,放在mcentral中来管理,如果mcentral中的span不够用了,就会从mheap.freelarge上再切一块,如果mheap.freelarge空间不够,就会从os上重新申请大内存。
mcentral的结构体如下:
type mcentral struct {
lock mutex // 分配时需要加锁
sizeclass int32 // 哪种 sizeclass
nonempty mspan // 还有可用的空间的 span 链表
empty mspan // 没有可用的空间的 span 列表
}
这种方式避免了外部碎片,因为同一个span是按照固定大小分配和回收的,不会出现不可利用的一小块内存将内存分割。
什么是外部碎片?
内存碎片指的是系统在内存管理过程中不可避免地出现多块无法使用的内存空间。这种碎片分为两大类,一类时内部碎片,这往往是因为字节对齐导致的一部分内存无法使用,另一类就是外部碎片,一般是因为内存的不断分配和释放导致一些小内存分散在内存各处,无法被用以分配。
2.3.3 mcache
在mcentral中有一个lock字段,它是用于避免多个线程同时从mcentral中申请内存所导致的冲突。但锁是低效的,在高并发的服务中,它会使得内存申请成为整个系统的瓶颈,所以在mcentral前增加一层mcache。
每个处理器都有一个mcache成员,两者一一对应,当goroutine申请内存时,首先会从其所在的处理器的mcache中分配,如果mcache没有可用的span,再从mcentral中获取,填充到mcache中。
因为在同一时刻,一个处理器只有一个线程在上面运行,不会出现竞争,所以mcache不需要加锁,这加速了内存分配。
2.4 go的内存分配过程
我们可以将go语言的内存管理看成一个两级的内存管理结构,mheap和mcache。
上一级管理的基本单位是页,主要用于分配大对象,每次分配都是若干连续的页,也就是若干个4kb的大小。使用的数据结构是mheap和mspan,用bestfit算法做分配,用位示图做回收。下面一级管理的基本单位是不同类型的固定大小的对象,更像一个对象池而不是内存池,用引用计数做回收。下面这一级使用的数据结构是mcache。
go的内存分配器在分配对象时,根据对象的大小,分成三类:小对象(小于等于16b)、一般对象(大于16b,小于等于32kb)、大对象(大于32kb)。mcache层次仅用于分配小对象,分配和释放大的对象则是直接使用mheap的,跳过mcache和mcentral自由链。
大体上的分配流程:
- >32kb 的对象,直接从mheap上分配;
- <=16b 的对象使用mcache的tiny分配器分配;
- (16b,32kb] 的对象,首先计算对象的规格大小,然后使用mcache中相应规格大小的mspan分配;
- 如果mcache没有相应规格大小的mspan,则向mcentral申请
- 如果mcentral没有相应规格大小的mspan,则向mheap申请
- 如果mheap中也没有合适大小的mspan,则向操作系统申请
3.1 什么是逃逸分析?
在c语言中,局部变量在函数调用时进行定义申请内存,函数调用结束后就会释放空间,这种变量一般就会定义在栈上,自动的分配和释放,对于全局变量就需要在堆上进行初始化,由程序员自己调用malloc和free进行申请和释放。
在go中即使在堆上也不需要主动调用malloc来分配,而是由编译器自动分析找到这个需要在堆上分配空间的变量,编译器分析的过程称为逃逸分析,但是这种分析不一定百分百准确。
go编译器在编译是追踪一个变量的生命周期,如果能确定一个数据只在函数空间中访问,不被外部使用就会使用栈空间,否则使用堆空间。
3.2 什么是逃逸
逃逸是指在某个方法之内创建的对象,除了在方法体之内被引用之外,还在方法体之外被其它变量引用到;这样带来的后果是在该方法执行完毕之后,该方法中创建的对象将无法被gc回收,由于其被其它变量引用。正常的方法调用中,方法体中创建的对象将在执行完毕之后,将回收其中创建的对象;故由于无法回收,即成为逃逸。
3.3 逃逸分析的缺点
我们在上文中提到过逃逸分析不一定百分百准确,概括来说,多级间接赋值往往就会导致逃逸。
什么是多级间接?
对某个引用类对象中的引用类成员进行赋值,引用类数据类型有func,interface, slice, map, chan, *type
。
使用如上分析,以下这些情况中的引用类成员将会发生逃逸:
a.b = value
,如果a和b都是引用类的数据类型,就会导致value逃逸。- 如果变量值是一个函数,函数的参数又是引用类型,则传递给它的参数都会逃逸。
- 只要使用了 interface 类型(不是 interafce{}),那么赋值给它的变量一定会逃逸。因为执行时会将实际值作为参数传递给方法。相当于interfacevariable.method.this = realvalue。
- 向 channel 中发送数据,本质上就是为 channel 内部的成员赋值,就像给一个 slice 中的某一项赋值一样。所以 chan *type, chan map[type]type, chan []type, chan interface{} 类型都会导致发送到 channel 中的数据逃逸。
- 可变参数如 func(arg …string) 实际与 func(arg []string) 是一样的,会增加一层访问路径。这也是 fmt.sprintf 总是会使参数逃逸的原因。
上述的逃逸分析使得go成功的避免了手动进行malloc分配内存,接下来要讲的垃圾回收就是取代了手动的free动作。go语言中使用的垃圾回收使用的是标记清除算法。
4.1 标记清除算法
标记清扫算法是一个很基础的垃圾回收算法,该算法中有一个标记初始的root区域,以及一个受控堆区。root区域主要是程序运行到当前时刻的栈和全局数据区域。在受控堆区中,很多数据是程序以后不需要用到的,这类数据就可以被当作垃圾回收了。判断一个对象是否为垃圾,就是看从root区域的对象是否有直接或间接的引用到这个对象。 如果没有任何对象引用到它,则说明它没有被使用,因此可以安全地当作垃圾回收掉。
标记清扫算法分为两阶段:标记阶段和清扫阶段。标记阶段,从root区域出发,扫描所有root区域的对象直接或间接引用到的对象,将这些对上全部加上标记。在回收阶段,扫描整个堆区,对所有无标记的对象进行回收。
4.2 位图标记
既然垃圾回收算法要求给对象加上垃圾回收的标记,那么如何来进行标记操作的呢?比较容易想到的是直接在对象头部某个位或者字节的方式对其进行标记,或者标记在一张表中,但这样子显然效率不高,所以对其进行了改进。
使用位图标记,这是一种非侵入式的标记位的方式。我们使用位图中的每个位对应每个可能的分配对象的地址,这样的位图可以只有一个,也可以有多个,比如每个内存块为何各自的位图。
优点:
- 标记位更加密集;
- 标记过程只需要读取存活对象的指针域而不会修改任何对象;
- 清扫器不会对存活对象进行任何读写操作,它只会在释放垃圾对象的过程中覆盖其某些域,例如将它连接到空闲链表上。所以位图标记不仅可以减少内存中需要修改的字节数,而且减少了对高速缓存行的写入,进而减少需要写回内存的数据量;
- 减少回收过程的内存换页次数。这是因为许多分配器往往也会将这些对象分配在相邻空间。故而每个位或者每个字节全部都被设置/清空标记位的情况经常出现,因此回收器可以批量读取/清空一批对象的标记位,除此以外我们也可以通过位图可以更简单的判定某一个内存块中的所有对象是否都是垃圾,进而可能一次性回收整个内存块。
4.3 保守的垃圾回收带来的害处
我们认为清除垃圾算法是一种“保守的”垃圾回收,这种保守的意思是指这种算法无法获取对象类型信息,因此只能保守的假设地址区间中的每个字都是指针。
这种保守的假设会带来一些问题:
- 问题一:浪费时间进行了不必要的标记;
假设某个结构体中是不包含指针成员的,那么对该结构体成员进行垃圾回收的时候就不需要递归的标记hi该结构体的成员,但是由于没有具体的类型信息我们只能对结构体的每个字节递归的进行标记,这样显然浪费了很多时间。 - 问题二:保守的垃圾回收在某种情况下会出现垃圾无法回收;
假设有一个long类型的变量,该变量值恰好是某个内存的地址,那么在保守的回收机制中就会误认为那个地址的对象是还需要使用的对象对其进行标记。
4.4 精准的垃圾回收
既然保守的垃圾回收会带来不必要的问题,所以在go1.1版本中就来实支持精准的垃圾回收,和保守的相对比这种算法就需要获取数据的类型信息。
在上文中的内存部分我们知道其实信息是存储在一个个mspan中的,根据地址就可以计算该数据所属的mspan,公式如下:
页号 = (地址 - mheap.arena_start) >> 页大小
mspan = mheap->map[页号]
获得mspan之后我们就可以根据它的结构体信息mtyped如下:
struct mtypes
{
byte compression; // one of mtypes_*
bool sysalloc; // whether (void*)data is from runtime·sysalloc
uintptr data;
};
mtypes描述mspan里分配的类型,其中compression域描述数据的布局。它的取值如下表所示:
类型 | 描述 |
---|---|
mtypes_empty | 所有的块都是free的,或者这个分配块的类型信息不可用。这种情况下data域是无意义的。 |
mtypes_single | 这个mspan只包含一个块,data域存放类型信息,sysalloc域无意义 |
mtypes_words | 这个mspan包含多个块(块的种类多于7)。这时data指向一个数组[numblocks]uintptr,,数组里每个元素存放相应块的类型信息。 |
mtypes_bytes | 这个mspan中包含最多7种不同类型的块。下文附相关的结构体信息。第i个块的类型是data.type[data.index[i]] |
struct {
type [8]uintptr // type[0] is always 0
index [numblocks]byte
}
上一节中说过,每一块mspan中存放的块的大小都是一样的,不过它们的类型不一定相同。
- 如果没有使用,那么这个mspan的类型就是mtypes_empty。
- 如果存一个很大块,大于这个mspan大小的一半,因此存不了其它东西了,那么这个mspan的类型mtypes_single。
- 假设存了多种块,每一块用一个指针,本来可以直接用mtypes_words存的。
- 但是当类型不多时,可以把这些类型的指针集中起来放在数组中,然后存储数组索引。这是一个小的优化,可以节省内存空间。
最终将会得到数据的相关类型信息,结构体如下:
struct type
{
uintptr size;
uint32 hash;
uint8 _unused;
uint8 align;
uint8 fieldalign;
uint8 kind;
alg *alg;
void *gc;
string *string;
uncommontype *x;
type *ptrto;
};
4.5 并行的垃圾回收
go中的标记算法主要是由debug_scanblock来完成的,该算法是递归实现的,单线程。所以想要并行的实现垃圾回收,需要先将这个标记函数转为非递归形。
非递归版本的树的遍历需要用到一个队列,非递归遍历的伪代码如下:
根结点进队
while(队列不空) {
出队
访问
将子结点进队
}
接下来为了使上面的代码能够并行地工作,就需要一个线程安全的队列的。假设有这样一个队列,那么上面代码就能够工作了。但是,如果不加任何优化,这里的队列的并行访问非常地频繁,对这个队列加锁代价会非常高,即使是使用cas操作也会大大降低效率。
所以接下来就需要优化上面的队列,在go中通过三个数据结构来完成这个队列的功能。分别为:ptrtarget数组,workbuf,lfstack。
- workbuf
这个结构体的意思是工作缓冲区,里面存放的是一个数组,数组中的每个元素都是一个待处理的结点,也就是一个obj指针。这个对象本身是已经标记了的,这个对象直接或间接引用到的对象,都是应该被标记的,它们不会被当作垃圾回收掉。workbuf是比较大的,一般是n个内存页的大小(目前是2页,也就是8k)。 - ptrtarget数组
相当于一个intermediate buffer,跟workbuf有一点点的区别。第一,它比workbuf小很多,大概只有32或64个元素的数组。第二,workbuf中的对象全部是已经标记过的,而ptrtarget中的元素可能是标记的,也可能是没标记的。第三,ptrtarget里面的元素是指针而不是对象,指针是指向任意地址的,而对象是对齐到正确地址的。 - lfstack
是被用作了一个无锁的链表,链表结点是以workbuf为单位的。并行垃圾回收中,多条线程会从这个链表中取数据,每次以一个workbuf为工作单位。同时,标记的过程中也会产生workbuf结点放到链中。lfstack保证了对这个链的并发访问的安全性。由于现在链表结点是以workbuf为单位的,所以保证整体的性能,lfstack的底层代码是用cas操作实现的。
4.6 什么时候进行垃圾回收
垃圾回收的触发是由一个gcpercent的变量控制的,当新分配的内存占已在使用中的内存的比例超过gcprecent时就会触发。比如,gcpercent=100,当前使用了4m的内存,那么当内存分配到达8m时就会再次gc。如果回收完毕后,内存的使用量为5m,那么下次回收的时机则是内存分配达到10m的时候。也就是说,并不是内存分配越多,垃圾回收频率越高,这个算法使得垃圾回收的频率比较稳定,适合应用的场景。