Go内存分配那些事就这么简单

50次阅读

共计 12834 个字符,预计需要花费 33 分钟才能阅读完成。

原文链接:https://mp.weixin.qq.com/s/3g…

新老朋友好久不见,我是大彬,这篇文章准备了很久,不是在拖延,而是中间做了一些其他事情,耽搁了一些。

这篇文章 主要介绍 Go 内存分配和 Go 内存管理,会轻微涉及内存申请和释放,以及 Go 垃圾回收。

从非常宏观的角度看,Go 的内存管理就是下图这个样子,我们今天主要关注其中标红的部分。

友情提醒:

文章有点长,建议先收藏,后阅读,绝对是学习内存管理的好资料。

本文基于 go1.11.2,不同版本 Go 的内存管理可能存在差别,比如 1.9 与 1.11 的 mheap 定义就是差别比较大的,后续看源码的时候,请注意你的 go 版本,但无论你用哪个 go 版本,这都是一个优秀的资料,因为内存管理的思想和框架始终未变。

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

我们不需要精通内存的管理,因为它确实很复杂,但掌握内存的管理,可以让你写出更高质量的代码,另外,还能助你定位 Bug。

这篇文章采用层层递进的方式,依次会介绍关于存储的基本知识,Go 内存管理的“前辈”TCMalloc,然后是 Go 的内存管理和分配,最后是总结。这么做的目的是,希望各位能通过全局的认识和思考,拥有更好的编码思维和架构思维。

最后,这不是一篇源码分析文章,因为 Go 源码分析的文章已经有很多了,这些源码文章能够帮助你去学习具体的工程实践和奇淫巧计了,文章的末尾会推荐一些优秀文章,如果你对内存感兴趣,建议每一篇都去看一下,挑出自己喜欢的,多花时间研究下。

1. 存储基础知识回顾

这部分我们简单回顾一下计算机存储体系、虚拟内存、栈和堆,以及堆内存的管理,这部分内容对理解和掌握 Go 内存管理比较重要,建议忘记或不熟悉的朋友不要跳过。

存储金字塔

这幅图表达了计算机的存储体系,从上至下依次是:

  • CPU 寄存器
  • Cache
  • 内存
  • 硬盘等辅助存储设备
  • 鼠标等外接设备

从上至下,访问速度越来越慢,访问时间越来越长。

你有没有思考过下面 2 个简单的问题,如果没有不妨想想:

  1. 如果 CPU 直接访问硬盘,CPU 能充分利用吗?
  2. 如果 CPU 直接访问内存,CPU 能充分利用吗?

CPU 速度很快,但硬盘等持久存储很慢,如果 CPU 直接访问磁盘,磁盘可以拉低 CPU 的速度,机器整体性能就会低下,为了弥补这 2 个硬件之间的速率差异,所以在 CPU 和磁盘之间增加了比磁盘快很多的内存。

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

不要以为有了 Cache 就万事大吉了,CPU 的速率还在不断增大,Cache 也在不断改变,从最初的 1 级,到后来的 2 级,到当代的 3 级 Cache,(有兴趣看 cache 历史)

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

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

看了分层设计,我们看一下内存,毕竟我们是介绍内存管理的文章。

虚拟内存

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

上图展示了某进程访问数据,当 Cache 没有命中的时候,访问虚拟内存获取数据的过程。

访问内存,实际访问的是虚拟内存,虚拟内存通过页表查看,当前要访问的虚拟内存地址,是否已经加载到了物理内存,如果已经在物理内存,则取物理内存数据,如果没有对应的物理内存,则从磁盘加载数据到物理内存,并把物理内存地址和虚拟内存地址更新到页表。

有没有 Get 到:物理内存就是磁盘存储缓存层

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

栈和堆

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

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

  • 栈在高地址,从高地址向低地址增长。
  • 堆在低地址,从低地址向高地址增长。

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

  1. 栈的内存管理简单,分配比堆上快。
  2. 栈的内存不需要回收,而堆需要,无论是主动 free,还是被动的垃圾回收,这都需要花费额外的 CPU。
  3. 栈上的内存有更好的局部性,堆上内存访问就不那么友好了,CPU 访问的 2 块数据可能在不同的页上,CPU 访问数据的时间可能就上去了。

堆内存管理

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

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

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

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

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

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

2. TCMalloc

TCMalloc 是 Thread Cache Malloc 的简称,是 Go 内存管理的起源 ,Go 的内存管理是借鉴了 TCMalloc,随着 Go 的迭代,Go 的内存管理与 TCMalloc 不一致地方在不断扩大,但 其主要思想、原理和概念都是和 TCMalloc 一致的,如果跳过 TCMalloc 直接去看 Go 的内存管理,也许你会似懂非懂。

掌握 TCMalloc 的理念,无需去关注过多的源码细节,就可以为掌握 Go 的内存管理打好基础,基础打好了,后面知识才扎实。

在 Linux 里,其实有不少的内存管理库,比如 glibc 的 ptmalloc,FreeBSD 的 jemalloc,Google 的 tcmalloc 等等,为何会出现这么多的内存管理库?本质都是 在多线程编程下,追求更高内存管理效率:更快的分配是主要目的。

那如何更快的分配内存?

我们前面提到:

引入虚拟内存后,让内存的并发访问问题的粒度从多进程级别,降低到多线程级别。

这是 更快分配内存的第一个层次

同一进程的所有线程共享相同的内存空间,他们申请内存时需要加锁,如果不加锁就存在同一块内存被 2 个线程同时访问的问题。

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

  1. 为线程预分配缓存需要进行 1 次系统调用,后续线程申请小内存时,从缓存分配,都是在用户态执行,没有系统调用,缩短了内存总体的分配和释放时间,这是快速分配内存的第二个层次
  2. 多个线程同时申请小内存时,从各自的缓存分配,访问的是不同的地址空间,无需加锁,把内存并发访问的粒度进一步降低了,这是快速分配内存的第三个层次

基本原理

下面就简单介绍下 TCMalloc,细致程度够我们理解 Go 的内存管理即可。

声明:我没有研究过 TCMalloc,以下介绍根据 TCMalloc 官方资料和其他博主资料总结而来,错误之处请朋友告知我。

结合上图,介绍 TCMalloc 的几个重要概念:

  1. Page:操作系统对内存管理以页为单位,TCMalloc 也是这样,只不过 TCMalloc 里的 Page 大小与操作系统里的大小并不一定相等,而是倍数关系。《TCMalloc 解密》里称 x64 下 Page 大小是 8KB。
  2. Span:一组连续的 Page 被称为 Span,比如可以有 2 个页大小的 Span,也可以有 16 页大小的 Span,Span 比 Page 高一个层级,是为了方便管理一定大小的内存区域,Span 是 TCMalloc 中内存管理的基本单位。
  3. ThreadCache:每个线程各自的 Cache,一个 Cache 包含多个空闲内存块链表,每个链表连接的都是内存块,同一个链表上内存块的大小是相同的,也可以说按内存块大小,给内存块分了个类,这样可以根据申请的内存大小,快速从合适的链表选择空闲内存块。由于每个线程有自己的 ThreadCache,所以 ThreadCache 访问是无锁的。
  4. CentralCache:是所有线程共享的缓存,也是保存的空闲内存块链表,链表的数量与 ThreadCache 中链表数量相同,当 ThreadCache 内存块不足时,可以从 CentralCache 取,当 ThreadCache 内存块多时,可以放回 CentralCache。由于 CentralCache 是共享的,所以它的访问是要加锁的。
  5. PageHeap:PageHeap 是堆内存的抽象,PageHeap 存的也是若干链表,链表保存的是 Span,当 CentralCache 没有内存的时,会从 PageHeap 取,把 1 个 Span 拆成若干内存块,添加到对应大小的链表中,当 CentralCache 内存多的时候,会放回 PageHeap。如下图,分别是 1 页 Page 的 Span 链表,2 页 Page 的 Span 链表等,最后是 large span set,这个是用来保存中大对象的。毫无疑问,PageHeap 也是要加锁的。

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

  1. 小对象大小:0~256KB
  2. 中对象大小:257~1MB
  3. 大对象大小:>1MB

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

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

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

通过本节的介绍,你应当对 TCMalloc 主要思想有一定了解了,我建议再回顾一下上面的内容。

本节图片皆来自《TCMalloc 解密》,图片版权归原作者所有。

精彩文章推荐

本文对于 TCMalloc 的介绍并不多,重要的是 3 个快速分配内存的层次,如果想了解更多,可阅读下面文章。

  1. TCMalloc

必读,通过这篇你能掌握 TCMalloc 的原理和性能,对掌握 Go 的内存管理有非常大的帮助,虽然如今 Go 的内存管理与 TCMalloc 已经相差很大,但是,这是Go 内存管理的起源和“大道”,这篇文章顶看十几篇 Go 内存管理的文章。

  1. TCMalloc 解密

可选 异常详细,包含大量精美图片,看完得花小时级别,理解就需要更多时间了,看完这篇不需要看其他 TCMalloc 的文章了。

  1. TCMalloc 介绍

可选,算是 TCMalloc 的文档的中文版,多数是从英文版翻译过来的,如果你英文不好,看看。

3. Go 内存管理

前面铺垫了那么多,终于到了本文核心的地方。前面的铺垫不是不重要,相反它们很重要,Go 语言内存管理源自前面的基础知识和内存管理思维,如果你跳过了前面的内容,建议你回头看一看,它可以帮助你更好的掌握 Go 内存管理。

前文提到Go 内存管理源自 TCMalloc,但它比 TCMalloc 还多了 2 件东西:逃逸分析和垃圾回收,这是 2 项提高生产力的绝佳武器。

这一大章节,我们先介绍 Go 内存管理和 Go 内存分配,最后涉及一点垃圾回收和内存释放。

Go 内存管理的基本概念

前面计算机基础知识回顾,是一种自上而下,从宏观到微观的介绍方式,把目光引入到今天的主题。

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

Page

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

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 class 对 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 申请,向 OS 的内存申请是按页来的,然后把申请来的内存页生成 Span 组织起来,同样也是需要加锁访问的。

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

大小转换

除了以上内存块组织概念,还有几个重要的大小概念,一定要拿出来讲一下,不要忽视他们的重要性,他们是内存分配、组织和地址转换的基础。

  1. object size:代码里简称size,指申请内存的对象大小。
  2. size class:代码里简称 class,它是 size 的级别,相当于把 size 归类到一定大小的区间段,比如 size[1,8] 属于 size class 1,size(8,16]属于 size class 2。
  3. span class:指 span 的级别,但 span class 的大小与 span 的大小并没有正比关系。span class 主要用来和 size class 做对应,1 个 size class 对应 2 个 span class,2 个 span class 的 span 大小相同,只是功能不同,1 个用来存放包含指针的对象,一个用来存放不包含指针的对象,不包含指针对象的 Span 就无需 GC 扫描了。
  4. num of page:代码里简称npage,代表 Page 的数量,其实就是 Span 包含的页数,用来分配内存。

在介绍这几个大小之间的换算前,我们得先看下图这个表,这个表决定了映射关系。

最上面 2 行是我手动加的,前 3 列分别是 size class,object size 和 span size,根据这 3 列做 size、size class 和 num of page 之间的转换。

仔细看一遍这个表,再向下看转换是如何实现的。

在 Go 内存大小转换那幅图中已经标记各大小之间的转换,分别是数组:class_to_sizesize_to_class*class_to_allocnpages,这 3 个数组内容,就是跟上表的映射关系匹配的。比如class_to_size,从上表看 class 1 对应的保存对象大小为 8,所以class_to_size[1]=8,span 大小为 8192Byte,即 8KB,为 1 页,所以class_to_allocnpages[1]=1

为何不使用函数计算各种转换,而是写成数组?

有 1 个很重要的原因:空间换时间 。你如果仔细观察了,上表中的转换,并不能通过简单的公式进行转换,比如 size 和 size class 的关系,并不是正比的。这些数据是使用较复杂的公式计算出来的,公式在makesizeclass.go 中,这其中存在指数运算与 for 循环,造成每次大小转换的时间复杂度为 O(N*2^N)。另外,对一个程序而言,内存的申请和管理操作是很多的,如果不能快速完成,就是非常的低效。把以上大小转换写死到数组里,做到了把大小转换的时间复杂度直接降到 O(1)。

其他转换表字段

第 4 列 num of objects 代表是当前 size class 级别的 Span 可以保存多少对象数量,第 5 列 tail waste 是 span%obj 计算的结果,因为 span 的大小并不一定是对象大小的整数倍。

最后一列 max waste 代表最大浪费的内存百分比,计算方法在 printComment 函数中:

func printComment(w io.Writer, classes []class) {fmt.Fprintf(w, "// %-5s  %-9s  %-10s  %-7s  %-10s  %-9s\n", "class", "bytes/obj", "bytes/span", "objects", "tail waste", "max waste")
    prevSize := 0
    for i, c := range classes {
        if i == 0 {continue}
        spanSize := c.npages * pageSize
        objects := spanSize / c.size
        tailWaste := spanSize - c.size*(spanSize/c.size)
        maxWaste := float64((c.size-prevSize-1)*objects+tailWaste) / float64(spanSize)
        prevSize = c.size
        fmt.Fprintf(w, "// %5d  %9d  %10d  %7d  %10d  %8.2f%%\n", i, c.size, spanSize, objects, tailWaste, 100*maxWaste)
    }
    fmt.Fprintf(w, "\n")
}

Span 最浪费内存的场景是:Span 内的每一个对象空间保存的对象,实际占用内存是前一个 class 中对象的大小加 1,这样无法占用低一级的 Span。一个对象空间未被占用的内存就被浪费了,所以一个 Span 内对象空间所浪费的内存为:所有对象空间浪费的内存之和 +tail waste。

((c.size – (preSize+1)) * objects + tailWaste) / spanSize

感谢 foobar 的提醒 max waste 的计算。

Go 内存分配

涉及的概念已经讲完了,我们看下 Go 内存分配原理。

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

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

小对象分配

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

上文提到 1 个 size class 对应 2 个 span class:

numSpanClasses = _NumSizeClasses * 2

numSpanClasses为 span class 的数量为 134 个,所以 span class 的下标是从 0 到 133,所以上图中 mcache 标注了的 span class 是,span class 0span class 133。每 1 个 span class 都指向 1 个 span,也就是 mcache 最多有 134 个 span。

为对象寻找 span

寻找 span 的流程如下:

  1. 计算对象所需内存大小 size
  2. 根据 size 到 size class 映射,计算出所需的 size class
  3. 根据 size class 和对象是否包含指针计算出 span class
  4. 获取该 span class 指向的 span。

以分配一个不包含指针的,大小为 24Byte 的对象为例。

根据映射表:

// 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         32        8192      256           0     46.88%
//     4         48        8192      170          32     31.52%

size class 3,它的对象大小范围是(16,32]Byte,24Byte 刚好在此区间,所以此对象的 size class 为 3。

Size class 到 span class 的计算如下:

// noscan 为 true 代表对象不包含指针
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

所以,对应的 span class 为:

span class = 3 << 1 | 1 = 7

所以该对象需要的是 span class 7 指向的 span。

从 span 分配对象空间

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

随着内存的分配,span 中的对象内存块,有些被占用,有些未被占用,比如上图,整体代表 1 个 span,蓝色块代表已被占用内存,绿色块代表未被占用内存。

当分配内存时,只要快速找到第一个可用的绿色块,并计算出内存地址即可,如果需要还可以对内存块数据清零。

span 没有空间怎么分配对象

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

mcentral 向 mcache 提供 span

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

  1. nonempty:这个链表里的 span,所有 span 都至少有 1 个空闲的对象空间。这些 span 是 mcache 释放 span 时加入到该链表的。
  2. empty:这个链表里的 span,所有的 span 都不确定里面是否有空闲的对象空间。当一个 span 交给 mcache 的时候,就会加入到 empty 链表。

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

实际代码中每 1 个 span class 对应 1 个 mcentral,图里把所有 mcentral 抽象成 1 个整体了。

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

mheap 的 span 管理

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

  1. free:free 中保存的 span 是空闲并且非垃圾回收的 span。
  2. scav:scav 中保存的是空闲并且已经垃圾回收的 span。

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

mheap 中还有 arenas,有一组 heapArena 组成,每一个 heapArena 都包含了连续的 pagesPerArena 个 span,这个主要是为 mheap 管理 span 和垃圾回收服务。

mheap 本身是一个全局变量,它其中的数据,也都是从 OS 直接申请来的内存,并不在 mheap 所管理的那部分内存内。

mcentral 向 mheap 要 span

mcentral 向 mcache 提供 span 时,如果 emtpy 里也没有符合条件的 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 垃圾回收也是一个很强的主题,计划后面单独写一篇文章介绍。

现在我们关注一下,Go 程序是怎么把内存释放给操作系统的?

释放内存的函数是 sysUnused,它会被mspan.scavenge() 调用:

// MAC 下的实现
func sysUnused(v unsafe.Pointer, n uintptr) {
    // MADV_FREE_REUSABLE is like MADV_FREE except it also propagates
    // accounting information about the process to task_info.
    madvise(v, n, _MADV_FREE_REUSABLE)
}

注释说 _MADV_FREE_REUSABLEMADV_FREE的功能类似,它的功能是给内核提供一个建议:这个内存地址区间的内存已经不再使用,可以回收。但内核是否回收,以及什么时候回收,这就是内核的事情了。如果内核真把这片内存回收了,当 Go 程序再使用这个地址时,内核会重新进行虚拟地址到物理地址的映射。所以在内存充足的情况下,内核也没有必要立刻回收内存。

4. Go 栈内存

最后提一下栈内存。从一个宏观的角度看,内存管理不应当只有堆,也应当有栈。

每个 goroutine 都有自己的栈,栈的初始大小是 2KB,100 万的 goroutine 会占用 2G,但 goroutine 的栈会在 2KB 不够用时自动扩容,当扩容为 4KB 的时候,百万 goroutine 会占用 4GB。

关于 goroutine 栈内存管理,有篇很好的文章,饿了么框架技术部的专栏文章:《聊一聊 goroutine stack》,把里面的一段内容摘录下,你感受下:

可以看到在 rpc 调用 (grpc invoke) 时,栈会发生扩容(runtime.morestack),也就意味着在读写 routine 内的任何 rpc 调用都会导致栈扩容,占用的内存空间会扩大为原来的两倍,4kB 的栈会变为 8kB,100w 的连接的内存占用会从 8G 扩大为 16G(全双工,不考虑其他开销),这简直是噩梦。

另外,再推荐一篇曹大翻译的一篇汇编入门文章,里面也介绍了扩栈:第一章: Go 汇编入门,顺便 入门 一下汇编。

5. 总结

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

  1. 使用缓存提高效率。在存储的整个体系中到处可见缓存的思想,Go 内存分配和管理也使用了缓存,利用缓存一是减少了系统调用的次数,二是降低了锁的粒度,减少加锁的次数,从这 2 点提高了内存管理效率。
  2. 以空间换时间,提高内存管理效率。空间换时间是一种常用的性能优化思想,这种思想其实非常普遍,比如 Hash、Map、二叉排序树等数据结构的本质就是空间换时间,在数据库中也很常见,比如数据库索引、索引视图和数据缓存等,再如 Redis 等缓存数据库也是空间换时间的思想。

6. 参考资料

除了文章中已经推荐的文章,再推荐几篇值得读的文章:

  1. 全成的内存分配文章,有不少帮助:https://juejin.im/post/5c888a…
  2. 异常详细的源码分析文章,看完这篇我就不想写源码分析的文章了:https://www.cnblogs.com/zkweb…
  3. 从硬件讲起的一篇文章,也是有点意思:https://www.infoq.cn/article/…
  4. 这篇文章的总流程图很棒:http://media.newbmiao.com/blo…

7. 彩蛋

在查阅资料时,多篇文章都提到了这本书《The Linux Programming Interface》,关于 Thread Cache 有兴趣去读一下本书第 31 章。


  1. 如果这篇文章对你有帮助,不妨关注下我的 Github,有文章会收到通知。
  2. 本文作者:大彬
  3. 如果喜欢本文,随意转载,但请保留此原文链接:http://lessisbetter.site/2019/07/06/go-memory-allocation/

<div style=”color:#0096FF; text-align:center”> 关注公众号,获取最新 Golang 文章 </div>
<img src=”http://img.lessisbetter.site/…; style=”border:0″ align=center />

正文完
 0