GO语言内存管理子系统主要由两部分组成:内存分配器和垃圾回收器(gc)。内存分配器主要解决小对象的分配管理和多线程的内存分配问题。什么是小对象呢?小于等于32k的对象就是小对象,其它都是大对象。小对象的内存分配是通过一级一级的缓存来实现的,目的就是为了提升内存分配释放的速度以及避免内存碎片等问题。
为了方便描述,先给两张图。
图1就是go内存分配模型图,从图中,很容易看到有三大模块:Heap, Cental,Cache。central在实际逻辑中是属于heap的子模块。
下面介绍下这三个概念:
1,Cache:线程私有的,每个线程都有一个cache,用来存放小对象。由于每个线程都有cache,所以获取空闲内存是不用加锁的。cache层的主要目的就是提高小内存的频繁分配释放速度。 我们在写程序的时候,其实绝大多数的内存申请都是小于32k的,属于小对象,因此这样的内存分配全部走本地cache,不用向操作系统申请显然是非常高效的。
cache在源码中结构为MCache,定义在malloc.h文件中,从cache中申请内存的函数是:
void *runtime·MCache_Alloc(MCache *c, int32 sizeclass, uintptr size, int32 zeroed)
参数size是要申请的内存大小,这个大小可能比申请内存时指定的大小稍大。
从图3可以看到cache有一个容量为61的数组list,每个数组单元挂一个链表,链表的每个节点就是一块可用内存,同一个链表中节点的大小是一样的,但是不用数组单元的链表下的节点大小是不一样的,数组的下标(0-60)就是不同的sizeclass。
MCache_Alloc分配内存的过程是:根据sizeclass参数从list中取出一个内存块链表,如果这个链表不为空,就把这个链表的可用节点返回;如果为空,说明cache中没有满足此类大小的缓存内存,就要从central中去取一部分此类大小的缓存,再把第一个节点返回使用,其他剩余的内存块挂到这个链表上,为下一次分配作准备。 cache的内存分配逻辑很简单,就是cache取不到就从central取。除去内存分配外,cache上还有很多状态计数器,主要用来统计内存分配情况,如:分配的内存量,缓存的内存量等,用于其它相关类查询内存情况以及profile等。
cache释放主要有两个条件:a,某个内存链表过长(>=256 )时,会截取此链表的一部分节点,返还给central;b,整个cache缓存过大(>=1M),同样将每个链表返还部分节点给central。
2,Central:所有线程共享的组件,不是独占的,因此需要加锁操作。它其实也是一个缓存,cache的一个上游用户,但缓存的不是小对象内存块,而是一组一组的内存page(一个page4K)。从图2可以看出,在heap结构里,使用了一个0到n的数组来存储了一批central,并不是只有一个central对象。从上面结构定义可以知道这个数组长度位61个元素,也就是说heap里其实是维护了61个central,这61个central对应了cache中的list数组,也就是每一个sizeclass就有一个central。所以,在cache中申请内存时,如果在某个sizeclass的内存链表上找不到空闲内存,那么cache就会向对应的sizeclass的central获取一批内存块。注意,这里central数组的定义里面使用填充字节,这是因为多线程会并发访问不同central避免false sharing。
central在源码中定义的结构为MCentral,定义在malloc.h中:
struct MCentral
{
Lock;
int32 sizeclass;
MSpan nonempty;
MSpan empty;
int32 nfree;
};
nonempty和empty两个字段很重要,它们都是MSpan类型(下面讲),这两个字段分别挂一个span节点构造的双向链表,只是这个双向链表的头节点不作使用。nonempty意思是非空,表示这个链表上存储的span节点都是非空状态,也就是说这些span节点都有空闲的内存,empty表示span都是空的,没有空闲可用。一开始,empty就是空的,当nonempty上的一个span被用完后,就会将span移到empty中。
central内存分配过程: central通过int32 runtime·MCentral_AllocList(MCentral *c, int32 n, MLink **pfirst)方法获取一批内存给cache。这个逻辑也很简单,但是要关注下填充nonempty的情况,也就是central没有空闲内存要向heap申请的情况。这里是调用MCentral_Grow函数实现。首先通过MHeap_Alloc向heap申请一个span,然后将span给切分成central对应的sizeclass的小内存块,将这些小内存块串成链表挂在span的freelist上,最后将span放到nonempty链表中。central一次只申请一个span,这个span含有多少page是根据central对应的sizeclass确定。
central的释放过程:ceche释放的时候,是将一批小内存块返还给central,central在接收到内存块的时候,会把每个内存块分配返还给对应的span,在把内存返还给span后,如果span先前被用完内存,待在empty中那么此刻就需要把它移到nonempty中,表示又有内存用了。在归还内存块给span后,如果span的每个page内存都回收回来了,也就是没有任何内存被使用了,此刻就将这个span整体归还给heap了。
central这一层管理的粒度就是span,所以span是一个非常重要的工具组件。
3,Heap:所有底层线程共享,是离go程序最远的一层,也是离OS最近的一层。主要是从OS申请内存交给central。在64位平台,heap从OS申请到的内存地址保留区是136G,而bitmap要8G空间,因此真正可申请的内存就是128G。
无论是分配小内存还是大对象,都需要通过
MSpan* runtime·MHeap_Alloc(MHeap *h, uintptr npage, int32 sizeclass, int32 acct, int32 zeroed)函数。向heap要内存,不是以字节为单位,而是page(npage参数)。sizeClass为0表示绕过cache和central直接分配大内存。从heap申请的page肯定是连续的,并通过span来管理。所有返回值是一个span而不是page数组。
heap内存分配过程:分配逻辑位于mheap.c中的MHeap_AllocLocked函数。图2中可以看出,heap结构中包括free和large两个域,free是一个256个单元的数组,每个单元上存储一个span链表,但不同的单元span含有page的个数也是不同的。span含page的个数等于span在数组中的单元下标(index,free【2】含有2个page)。如果一个span的page数超过255,那这个span就被放到large链表中。从heap要内存,要根据page的数目从free或large链表中获取合适的span。如果page大到连large链表中都没有合适的span,就只能调用MHeap_Grow函数从OS中去申请,填充heap,再试图分配。拿到的span所含的page数目大于了请求的page数目,并不会将这个span返回,而是将其拆分成两个span,将剩余span重新放回free或large链表中去。全部返回就太浪费了。
heap的释放:central交还heap内存的时候,主要也就是将span放到free或large链表中去。
heap复杂的不是分配内存,而是要维护很多元数据,比如图2中的map域,维护的就是page到span的映射,也就是任何一块内存在算出page后也就知道它属于哪个span了,这样才能做到正确回收内存。bitmap结构,用来标记内存,为gc服务。
总的来说,小对象的分配顺序是:
canche(返回一个内存块)->central(返回一个包含一系列内存块的链表)->heap(返回一个san)->MM(返回span)
MSpan:
MSPan是内存分配器的基础工具组件,简称span,是用来管理一组组page对象。page就是一个4k大小的内存块。span将这一个个连续的page给管理起来。
MSPan结构体定义在malloc.h头文件中,
struct MSpan{
MSpan*next;// in a span linked list
MSpan*prev;// in a span linked list
PageID start;// starting page number
uintpt npages;// number of pages in span
MLink*freelist;// list of free objects
uint32 ref;// number of allocated objects in this span
int32 sizeclass;// size class
uintptr elemsize;// computed from sizeclass or from npages
uint32 state;// MSpanInUse etc
int64 unusedsince;// First time spotted by GC in MSpanFree state
uintptr npreleased;// number of pages released to the OS
byte*limit;// end of data in span
MTypes types;// types of allocated objects in this span
};
从结构中可以看出,span是一个双向链表(pre/next)。span可能用于分配小对象,也可以用来分配大对象。分配不同的对象,span管理的元数据不相同。npages表示此span存储的page的个数。start可以看作一个page指针,指向第一个page,这样可以可以很容易的获取后续其它page了,因为它们是连续的。start的类型的pageID,所以可以看出start保存的不是第一个page的起始地址,而是第一个page的id值。这个id是算出来的(page的地址除以4096,当然需要保证每个page按4k对齐)。start是span最重要的字段,小对象时,它维护好所有的page,最后page会被切成一个一个连续的内存,内存块大小是小对象的大小,切分出来的内存块将被链接成一个链表挂在freelist字段上。sizeclass为0时,表示大对象,这个时候freelist就没什么用了。
FixAlloc:
FixAlloc是辅助实现整个内存分配器核心的一个基础工具。是用来分配MCache和MSPan两个特定对象的。
Fixalloc结构中关键的三个字段是alloc,list和chunk,其它字段都是用来统计一些状态数据的,比如分配了多少内存之类。list指针上挂了一个链表,链表每个节点都是一个固定大小的内存块,cachealloc中的list存储的内存块大小为sizeof(MCache),而spanalloc中的list存储的内存块大小为sizeof(MSpan)。chunk指针始终挂载的是一个128k大的内存块。
分配span和cache方法如下:
MCache *mcache;
mcache = (MCache *) runtime·FixAlloc_Alloc(cachealloc);
MSpan *mspan;
mspan = (MSpan *) runtime·FixAlloc_Alloc(spanalloc);
FixAlloc分配过程:先查找list链表,如果list不为空,就直接拿一个内存块返回是使用,如果为空,就把焦点一道chunk上,如果128k的chunk内存中有足够的空间,就切割一块内存出来返回使用。如果chunk内存没有剩余的话,就从OS再申请128k内存替代老的chunk。
FixAlloc释放过程:释放的对象直接放到list中,并不会返回给OS。fixalloc内存可能增长的因素就是span对象太对,mcache基本是稳定的,跟底层线程个数一致。
应用层面用new和用make分配内存
new:内建函数new(T)分配了零值填充的T类型的内存空间,并返回其地址。一个*T类型的值。用go的术语说,它返回了一个指针,指向新分配的类型T的零值。使用者用new创建的一个数据结构的实体并可以直接工作。如bytes.Buffer的文档所述:Buffer的零值是一个准备好了的空缓存。
make:内建函数make(T,args)只能创建slice,map和channel,并返回一个有初始值(非零)的T类型,而不是*T。导致这三个类型有所不同的原因是指向数据结构的引用在使用前必须初始化。如,一个slice,是一个包含指向数据(内部array)的指针,长度和容量的三项描述,在这些项目初始化之前,slice为nil。对于slice,map和channel,make初始化了内部的数据结构,填充适当的值。
务必记得 make 仅适用于 map,slice 和 channel,并且返回的不是指针。应当用 new 获得特定的指针