【3-1 Golang】GC—内存管理

tomato01 · · 1910 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

  Go语言为我们做了很多,创建对象不再需要我们手动申请内存,也不用考虑对象使用完后释放内存,这些对开发者来说都是透明的;但是作为一名Go开发者,内存管理和垃圾回收还是有必要深入研究的。毕竟,内存与CPU是程序高效运行的基础。 ## 虚拟内存   Linux为每个进程维护一个单独的虚拟内存空间(组织为一些区域/段的集合,如代码段,数据段,堆,共享库段,以及用户栈都是不同的区域),如下图所示: ![3-1-1.png](https://static.golangjob.cn/221010/ba751d88e5b5d59dcf647e2aa9a9476a.png)   说到这里就不得不提一下系统调用mmap,其要求内核创建一个新的虚拟内存区域(注意是新的区域,和堆是平级关系,即mmap函数并不是在堆上分配内存的);最好是从地址addr开始(一般传null),并将文件描述fd符指定的对象的一个连续的chunk(大小为len,从文件偏移offset开始)映射到这个新的区域;当fd传-1时,可用于申请分配内存。   函数mmap声明如下(munmap函数释放该虚拟内存区域): ``` void* mmap ( void * addr , size_t len , int prot , int flags , int fd , off_t offset ) int munmap(void *addr, size_t length); ```   参数prot描述这个区域的访问控制权限,可以取以下值: ``` PROT_EXEC //页内容可以被执行 PROT_READ //页内容可以被读取 PROT_WRITE //页可以被写入 PROT_NONE //页不可访问 ```   参数flags由描述被映射对象类型的位组成,如MAP_SHARED 表示与其它所有映射这个对象的进程共享映射空间;MAP_PRIVATE 表示建立一个写入时拷贝的私有映射,内存区域的写入不会影响到原文件。   Go语言底层在向操作系统申请内存时,一次申请64M内存,就是通过mmap函数申请的。另外注意,操作系统分配内存通常是按页(4K)分配的,也就是说即使进程申请3K内存,操作系统会分配4K字节。 ## 如何设计动态内存分配器   Go进程向操作系统一次申请64M内存,那么业务代码需要内存时怎么分配呢?要知道业务申请内存是灵活多变的,申请与释放时机,申请内存块大小等等都是随机的。因此,需要设计一个内存分配器来实现这一类内存分配需求。要实现分配器必须考虑以下几个问题: ``` 1.空闲块组织:如何记录空闲块;如何标记内存块是否空闲; 2.分配:如何选择一个合适的空闲块来处理分配请求; 3.分割:空闲块一般情况会大于实际的分配请求,我们如何处理这个空闲块中的剩余部分; 4.回收:如何处理一个刚刚被释放的块; ``` - 思考1:空闲块组织   内存分配与释放请求时完全随机的,最终会造成堆内存被分割为若干个内存小块,其中有些处于已分配状态,有些处于空闲状态;我们需要额外的空间来标记内存状态以及内存块大小; 下图为malloc设计思路: ![3-1-2.png](https://static.golangjob.cn/221010/03fb2f58c4730e1a41dd81c17da1421b.png)   注:图中显示额外使用4字节记录当前内存块属性,其中3比特记录是否空闲,29比特记录内存块大小;实际malloc头部格式可能会根据版本等调整;不论我们使用malloc分配多少字节的内存,实际malloc分配的内存都会多几个字节; 注:空闲内存块可能会被组织为一个链表结构,由此可以遍历所有空闲内存块,直到查找到一个满足条件的为止; - 思考2:如何选择合适的空闲块   在处理内存分配请求时,需要查找空闲内存链表,找到一个满足申请条件的空闲内存块,选择什么查找算法;而且很有可能存在多个符合条件的空闲内存块,此时如何选择? 目前有很多比较成熟的算法,如首次适配,最佳适配,最差适配等; - 思考3:如何分配   在查找到满足条件的空闲内存块时,此内存一般情况会比实际请求分配的内存空间要大;全部分配给用户,浪费空间;因此一般会将此空闲内存块切割为两个小块内存,一块分配给用户,一块标记为新的空闲内存 - 思考4:如何回收:   当用户调用free()函数释放内存时,需要将此块内存重新标记为空闲内存,并且插入空闲链表;然而需要注意的是,此块内存可能能够与其他空闲内存拼接为更大的空闲内存;此时还需要算法来处理空闲内存的合并; - 思考5:内存分配效率问题:   用户请求分配内存时,需要遍历空闲内存链表,直到查找到一个满足申请条件的空闲内存;由此可见,算法复杂度与链表长度成正比;我们可以将空闲内存按照空间大小组织为多个空闲链表,内存大小相近的形成一个链表;此时只需要根据申请内存大小查找相应空闲链表即可;更进一步的,空闲内存只会被切割为固定大小,如2^n字节,每种字节大小的空闲内存形成一个链表;(用户实际分配的内存是2^n字节,大于用户实际请求)   总结:任何内存分配器都需要额外的空间(数据结构)记录每个内存块大小及其分配状态 ## Go版本内存分配器   Go语言是如何实现这种内存分配器的呢?与上面介绍的malloc一样,在内存块头部额外添加几个字节吗?其实不是的,Go语言是基于bitmap标识的,针对每一个内存块,使用一个比特位表示该内存块空闲与否。   Go语言内存管理的基本单元是mspan,mspan结构包含字段allocBits(就是bitmap),记录着每一个内存块空闲还是已分配: ``` type mspan struct { // 页数,Go定义页大小为8K npages uintptr // number of pages in span //bitmap,记录每一个内存块空闲与否 allocBits *gcBits //bitmap的缓存,缓存64bit,用于快速查找(De Bruijn算法) allocCache uint64 //每种规格mspan负责分配的内存块大小 elemsize uintptr // computed from sizeclass or from npages } ```   另外,为了提升空闲内存查找效率,Go语言定义了多种规格的mspan,每种规格的mspan只负责分配固定大小的内存块(总计67种规格大小),如下: ``` // 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 ...... // 67 32768 32768 1 0 12.50% 8192 ```   这里只截取了前5种规格的定义,如第一种规格负责分配的内存块大小不超过8字节,每一个mspan大小为8K,所以每一个mspan最多只能分配1024个内存块。max waste是什么呢?表示最大内存浪费比例,想想加入每次只申请一字节内存呢?Go内存分配器也会选择第一种规格的mspan,这样最差情况浪费了7/8的内存(87.50%)。   对于一个mspan,需要多少比特位表示内存块空闲状态呢?这就与该mspan划分的内存块数目有关了;我们看一下allocBits初始化逻辑: ``` func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) { // 页*页大小 = 页字节数 nbytes := npages * pageSize //计算mspan最多能分割为多少内存块 s.nelems = nbytes / s.elemsize //申请bitmap s.allocBits = newAllocBits(s.nelems) } //newAllocBits计算bitmap所需字节数 blocksNeeded := uintptr((nelems + 63) / 64) bytesNeeded := blocksNeeded * 8 ```   allocBits存储对应内存块是否空闲,0表示空闲 1表示已分配。参考上面对mspan的介绍,内存申请的逻辑基本也就清楚了:根据申请内存的大小,确定mspan规格,查找bitmap(比特位0),如果找到标记已分配,返回内存块地址。 ``` //内存申请入口 func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { //根据申请内存大小,计算mspan规格 sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)] spc := makeSpanClass(sizeclass, noscan) //获取对应规格的mspan span = c.alloc[spc] //查找空闲内存块 v := nextFreeFast(span) x = unsafe.Pointer(v) return x } ```   查找空闲内存块其实就是在bitmap中搜索0比特,普通搜索算法时间复杂度为O(n),Go语言使用De Bruijn算法提升搜索效率。 ## Go内存管理概述   Go语言内存管理这么简单吗?显然不是。考虑几个问题,Go语言是多线程程序,肯定会出现多线程并发申请内存的情况,每次申请都需要加锁吗?这显然是不合适的。上面提到Go语言底层在向操作系统申请内存时,一次申请64M内存,但是内存管理的基本单位又是mspan,64M内存怎么转化为mspan呢?最后,内存管理还伴随着垃圾回收(内存释放),这就更复杂了(后面篇章会介绍)。 ![3-1-3.png](https://static.golangjob.cn/221010/d6e0ab5e123749c5b6c2ba60b0686b0a.png)   如上图所示,每一个逻辑处理器P都缓存着mspan(缓存在p.mcache,是一个数组),这样协程在申请内存时,只需要在当前P的mcache查找空闲内存就行了,这里缓存了所有规格的mcache,定义如下: ``` type p struct { mcache *mcache } type mcache struct { // mspan数组,numSpanClasses == 136 alloc [numSpanClasses]*mspan // spans to allocate from, indexed by spanClass } ```   alloc数组存储所有规则的mspan,数组长度为136。之前介绍不是说mspan只有67种规格吗?这里需要解释下:为了提升垃圾回收效率,Go语言在分配内存时,会考虑要存储的对象是否包含指针,将有指针和无指针的内存分为了两种规格的mspan,这样垃圾回收扫描时,就不会扫描无指针的mspan了。这么一算,mspan规格数应该是67 * 2 = 134,还有两种规格呢?这两种规格的mspan负责大块内存的分配(大于32768 = 4 * 8192 = 4page),同样分为有指针和无指针。   如果当然逻辑处理器P的缓存mcache已经分配完了呢?这时候就只能从全局的mcentral分配了,mcentral同样分为136种规格,每种规格的mcentral存储着一些mspan(可能有空闲内存,可能没有空闲内存;可能已经被垃圾回收标记-清理了,可能只是被垃圾回收标记了但是还没有清理),接下来就是从对应规格的mcentral查找可用的mspan了。 ``` type mheap struct { //全局mcentral central [numSpanClasses]struct { mcentral mcentral } } type mcentral struct { spanclass spanClass //partial存储有空闲内存的mspan partial [2]spanSet // list of spans with a free object //full存储的mspan没有空闲内存 full [2]spanSet // list of spans with no free objects } ```   注意多个协程可能会并发访问mcentral,所以这里的一些操作是需要加锁的。参考mcentral结构的定义,查找mspan的逻辑应该是怎么样的呢:1)查找partial已被清理的mspan数组,如果有返回mspan;2)查找partia未被清理的mspan数组,如果有则执行清理工作,并返回该mspan;3)查找full未被清理的mspan数组,如果有则执行清理工作,并返回该mspan。另外,查找mcentral也是有次数限制的,最多遍历查找100次,如果查找100次后还没有找到可用的mspan,则申请新的mspan。   如果在全局mcentral没有查找到可用的mspan呢?那只能向pageCache申请新的mspan了。同样的,每一个逻辑处理器P都缓存有pageCache(缓存有64个空闲页),用于处理页的分配请求。注意从p.pageCache分配mspan是不需要加锁的。   如果逻辑处理器P的缓存pageCache也没有足够的空闲页呢?那就通过页分配器pageAlloc分配缓存页呗,页分配器的内存从哪来呢?这里就是从操作系统申请的,而且一次申请64M内存,这块内存称之为heapArena。   内存分配的入口函数是mallocgc,整个函数调用链路如下所示,有兴趣的读者可以阅读学习; ``` //内存分配入口 func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer //查找空闲内存块,如果没有申请mspan func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) //获取新的mspan func (c *mcache) refill(spc spanClass) //mcentral查找可用mspan func (c *mcentral) cacheSpan() *mspan //mcentral申请新的mspan func (c *mcentral) grow() *mspan //申请mspan func (h *mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (s *mspan) //从p.pageCache申请mspan func (c *pageCache) alloc(npages uintptr) (uintptr, uintptr) //pageCache缓存不够,申请新的pageCache func (p *pageAlloc) allocToCache() pageCache //pageAlloc申请内存 func (h *mheap) grow(npage uintptr) (uintptr, bool) func (p *pageAlloc) grow(base, size uintptr) //向操作系统申请heapArena(64M) func (h *mheap) sysAlloc(n uintptr) (v unsafe.Pointer, size uintptr) ``` ## 内存逃逸   什么是内存逃逸呢?一句话解释就是一个对象(变量)本来应该分配在栈上,结果分配到堆内存了。我们都知道一般函数内写的局部变量等,应该是分配在栈上的,随着函数的调用与返回,该局部变量也会同步分配与释放;但是在某些情况下该局部变量是不能分配到栈上的,只能分配到堆内存。   编译阶段可以通过-m分析内存逃逸的情况,如下: ``` // -N 禁止编译优化 -l 禁止内联 -m 输出优化详情(包括逃逸分析),可以多个,-m越多输出信息越多(最多4个) go build -gcflags '-N -m -m -l' test.go ```   举一个例子,假设函数有一个局部变量,同时函数返回了该局部变量的地址,这可以吗?在部分语言如C这种写法是行不通的,因为函数返回后,该局部变量的地址会释放。但是Go语言是允许这种写法的,只是这种情况下,该局部变量会逃逸到堆内存。 ``` package main import "fmt" func main() { ret := test() fmt.Println(ret) } func test() *int { var num = 10 return &num } /* go build -gcflags '-N -m -m -l' test.go # command-line-arguments ./test.go:11:6: num escapes to heap: ./test.go:11:6: flow: ~r0 = &num: ./test.go:11:6: from &num (address-of) at ./test.go:12:9 ./test.go:11:6: from return &num (return) at ./test.go:12:2 ./test.go:11:6: moved to heap: num ./test.go:7:13: ... argument does not escape */ ```   可以很明显的看到,"moved to heap: num",说明变量num逃逸到堆上了。   再比如,假设一个局部变量的赋值给map或者切片呢,而且赋值的不是值而是地址,函数返回后,局部变量也会随之释放,这显然会引起异常,所以这种局部变量也会逃逸到堆内存,如下面程序所示 ``` package main var score = make(map[int]*int, 0) func main() { s := 100 score[0] = &s } /* go build -gcflags '-N -m -m -l' test.go # command-line-arguments ./test.go:6:2: s escapes to heap: ./test.go:6:2: flow: {heap} = &s: ./test.go:6:2: from &s (address-of) at ./test.go:7:13 ./test.go:6:2: from score[0] = &s (assign) at ./test.go:7:11 ./test.go:6:2: moved to heap: s ./test.go:3:17: make(map[int]*int, 0) escapes to heap: ./test.go:3:17: flow: {heap} = &{storage for make(map[int]*int, 0)}: ./test.go:3:17: from make(map[int]*int, 0) (spill) at ./test.go:3:17 ./test.go:3:17: from score = make(map[int]*int, 0) (assign) at ./test.go:3:5 ./test.go:3:17: make(map[int]*int, 0) escapes to heap */ ```   可以很明显的看到,"moved to heap: s",说明变量s逃逸到堆上了。另外,变量score是一个map,make函数底层也是在堆上申请的内存(参考map章节)。   当然还有其他情况,比如局部变量占用内存非常大,这时候就不适合分配到栈上了,等等,这里就不一一介绍了,简单了解即可。 ## 总结   本篇文章从虚拟内存开始,逐步介绍了动态内存分配器的设计思路,引入Go语言内存分配设计,最后整体介绍了Go内存管理整个流程,最后还简单介绍了内存逃逸的基本概念。下一篇将结合内存管理的内容,开始介绍垃圾回收实现过程。

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

1910 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传