golang是一门自带垃圾回收的语言,它的内存分配器和tmalloc(thread-caching malloc)很像,大多数情况下是不需要用户自己管理内存的。最近了解了一下golang内存管理,写出来分享一下,不正确的地方请大佬们指出。
那么,如果自己想要实现一门语言的内存管理,应该怎么设计呢?
1.内存池:
应该有一个主要管理内存分配的部分,向系统申请大块内存,然后进行管理和分配。
2.垃圾回收:
当分配的内存使用完之后,不直接归还给系统,而是归还给内存池,方便进行下一次复用。至于垃圾回收选择标记回收,还是分代回收算法应该符合语言设计初衷吧。
3.大小切分:
使用单独的数组或者链表,把需要申请的内存大小向上取整,直接从这个数组或链表拿出对应的大小内存块,方便分配内存。大的对象以页申请内存,小的对象以块来申请,避免内存碎片,提高内存使用率。
4.多线程管理:
每个线程应该有自己的内存块,这样避免同时访问共享区的时候加锁,提升语言的并发性,线程之间通信使用消息队列的形式,一定不要使用共享内存的方式。提供全局性的分配链,如果线程内存不够用了,可向分配链申请内存。
这样的内存分配设计涵盖了大部分语言的,上面的想法其实是把golang语言内存分配抽象出来。其实Java语言也可以以同样的方式理解。内存池就是JVM堆,主要负责申请大块内存;多线程管理方面是使用栈内存,每个线程有自己独立的栈内存进行管理。
golang内存分配器
golang内存分配器主要包含三个数据结构:MHeap,MCentral以及MCache
1.MHeap:分配堆,主要是负责向系统申请大块的内存,为下层MCentral和MCache提供内存服务。他管理的基本单位是MSpan(若干连续内存页的数据结构)
type MSpan struct
{
MSpan *next;
MSpan *prev;
PageId start; // 开始的页号
uintptr npages; // 页数
…..
};
可以看出MSpan是一个双端链表的形式,里面存储了它的一些位置信息。
通过一个基地址+(页号*页大小),就可以定位到这个MSpan的实际内存空间。
type MHeap struct
{
lock mutex;
free [_MaxMHeapList] mSpanList // free lists of given length
freelarge mSpanList // free lists length >= _MaxMHeapList
busy [_MaxMHeapList] mSpanList // busy lists of large objects of given length
busylarge mSpanList
};
free数组以span为序号管理多个链表。当central需要时,只需从free找到页数合适的链表。large链表用于保存所有超出free和busy页数限制的MSpan。
MHeap示意图:
2.MCache:运行时分配池,不针对全局,而是每个线程都有自己的局部内存缓存MCache,他是实现goroutine高并发的重要因素,因为分配小对象可直接从MCache中分配,不用加锁,提升了并发效率。
type MCache struct
{
tiny byte*; // Allocator cache for tiny objects w/o pointers.
tinysize uintptr;
alloc[NumSizeClasses] MSpan*; // spans to allocate from
};
尽可能将微小对象组合到一个tiny块中,提高性能。
alloc[]用于分配对象,如果没有了,则可以向对应的MCentral获取新的Span进行操作。
线程中分配小对象(16~32K)的过程:
对于 size 介于 16 ~ 32K byte 的内存分配先计算应该分配的 sizeclass,然后去 mcache 里面 alloc[sizeclass] 申请,如果 mcache.alloc[sizeclass] 不足以申请,则 mcache 向 mcentral 申请mcentral 给 mcache 分配完之后会判断自己需不需要扩充,如果需要则想 mheap 申请。
每个线程内申请内存是逐级向上的,首先看MCache是否有足够空间,没有就像MCentral申请,再没有就像MHeap,MHeap向系统申请内存空间。
3.MCentral:作为MHeap和MCache的承上启下的连接。承上,从MHeap申请MSpan;启下,将MSpan划分为各种尺寸的对象提供给MCache使用。
type MCentral struct
{
lock mutex;
sizeClass int32;
noempty mSpanList;
empty mSpanList;
int32 nfree;
……
};
type mSpanList struct {
first *mSpan
last *mSpan
};
sizeclass: 也有成员 sizeclass,用于将MSpan进行切分。
lock: 因为会有多个 P 过来竞争。
nonempty: mspan 的双向链表,当前 mcentral 中可用的 mSpan list。
empty: 已经被使用的,可以认为是一种对所有 mSpan 的 track。MCentral存在于MHeap内。
给对象 object 分配内存的主要流程:
1.object size > 32K,则使用 mheap 直接分配。
2.object size < 16 byte,使用 mcache 的小对象分配器 tiny 直接分配。 (其实 tiny 就是一个指针,暂且这么说吧。)
3.object size > 16 byte && size <=32K byte 时,先使用 mcache 中对应的 size class 分配。
4.如果 mcache 对应的 size class 的 span 已经没有可用的块,则向 mcentral 请求。
5.如果 mcentral 也没有可用的块,则向 mheap 申请,并切分。
6.如果 mheap 也没有合适的 span,则想操作系统申请。
tcmalloc内存分配器介绍
tcmalloc(thread-caching mallo)是google推出的一种内存分配器。
具体策略:全局缓存堆和进程的私有缓存。
1.对于一些小容量的内存申请试用进程的私有缓存,私有缓存不足的时候可以再从全局缓存申请一部分作为私有缓存。
2.对于大容量的内存申请则需要从全局缓存中进行申请。而大小容量的边界就是32k。缓存的组织方式是一个单链表数组,数组的每个元素是一个单链表,链表中的每个元素具有相同的大小。
golang语言中MHeap就是全局缓存堆,MCache作为线程私有缓存。
在文章开头说过,内存池就是利用MHeap实现,大小切分则是在申请内存的时候就做了,同时MCache分配内存时,可以用MCentral去取对应的sizeClass,多线程管理方面则是通过MCache去实现。
总结:
1.MHeap是一个全局变量,负责向系统申请内存,mallocinit()函数进行初始化。如果分配内存对象大于32K直接向MHeap申请。
2.MCache线程级别管理内存池,关联结构体P,主要是负责线程内部内存申请。
3.MCentral连接MHeap与MCache的,MCache内存不够则向MCentral申请,MCentral不够时向MHeap申请内存。