Golang 内存管理

2,327 阅读36分钟

前言

本文主题是Golang的内存管理,主要包括了内存分配和内存回收,这文章本身也是我自己的学习记录。当然为了各位看官能更好的理解,我会尽量写的由浅入深。本文的目标是介绍Golang内存管理的基本原理及一些实践经验,所以一些过于复杂和细节的知识点文中不会深究,我会贴上原文链接,方便大家扩展阅读。

本文在写作的过程中参考了大量网上、公司Tech平台上的前人智慧,在此表示感谢,大家有兴趣可以看看参考文档。由于本人才疏学浅,有错漏之处欢迎指正。

预备知识

堆与栈

首先,我们都知道,我们的程序是跑在操作系统上的,不管你是Go程序还是C++程序,都只是操作系统下的一个进程罢了。操作系统会负责给我们的程序分配内存资源。而操作系统会从逻辑层面将进程数据分为5个段。

  • 代码段(text segment): 通常指存放程序执行代码的区域,这部分区域大小在程序运行前就已经确定,并且通常属于只读。代码段中也可能包含一些只读常数变量,例如字符串常量等。

  • bss段(bss segment): 存放未初始化的全局变量和静态变量,可读写。

  • 数据段(data segment):存储初始化的全局变量和初始化的静态变量,可读写。

  • 堆(heap):用于存放进程运行中动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)。

  • **栈(stack):**存放的是函数中的局部变量及调用数据。操作方式与数据结构中的栈类似。当函数执行完成后,该函数的栈帧由操作系统自动从栈区移除。通常栈的大小是固定的,当我们局部变量申请过大,或函数调用太深,就有可能导致栈溢出(StackOverflow)

流程图.jpg

由于栈内存是由操作系统自动管理,在函数执行完成后释放,因此大多数情况下我们并不需要关心栈的内存占用及回收。而堆内存由系统和程序员按需分配,动态分配,生命周期与进程一致。这就需要聪明的程序员们考虑很多问题:

  • 当我的应用程序需要一块内存时,我怎么从堆中分配这一块内存?该分配多少内存?

  • 堆空间并非无限大,当我的应用程序累计申请的内存超过了堆空间怎么办?我是否应该把不再使用的堆内存释放掉?

  • 我该怎么分辨出来哪些内存是有用的,哪些内存可以被回收?

  • .....

堆内存管理是个很复杂的问题,而随着技术发展,各个编程语言都给出了自己的答卷,降低了应用开发的门槛,促进了信息科技的发展。

内存管理

从参与内存管理过程的角色来看,我们可以拆成三个不同部分,用户程序(Mutator)、分配器(Allocator)及回收器(Collector)。当用户程序申请内存时,它会通过内存分配器申请新内存,而分配器会负责从堆中初始化相应的内存区域。而回收器会负责回收内存。

但其实,并非所有的编程语言都将这三种不同角色区分处理。以C语言为例,C语言的用户程序开发者需要自己手动释放申请的堆内存,因此用户程序同时也承担了回收器的角色。开发者们需要小心的释放数据内存,如果内存使用完后没有释放,就会有内存泄露问题。而高级编程语言如java,go等,都将分配器与回收器在语言层面进行了实现,应用程序开发者无需手动管理堆内存,回收器会适时回收不再使用的内存。

内存分配

编程语言的内存分配器一般包含两种分配器,一种是线性分配器(Sequential Allocator),一种是空闲链表分配器(Free-List Allocator)。

线性分配器

线性分配是指申请顺序从前至后分配堆内存。我们只需要维护一个指向内存当前分配位置的指针,如果用户程序向分配器申请内存,分配器只需要检查剩余空间内存,返回分配的内存区域后,修改指针为分配后的位置。如图所示,当分配了Object C后,指针也对应移动。

流程图 (2).jpg

大家可以看到,线性分配的逻辑非常简单,但是也带来了很多问题:

  • 当已分配的内存被回收后,无法重用这部分内存。例如图中Object A被释放后,指针无法找到Object A 所在的位置。

  • 所有线程都在向堆申请内存,需要加锁避免冲突,性能是个大问题。

  • 需要频繁整理内存。因为线性分配是线性扩张,内存很容易就被分配到末端,这时就需要整理内存空间。我们需要通过标记-整理(Mark-Compact)、复制(Coping)或分代回收(Generational Collection)等回收算法,将存活对象整理至一端。

流程图 (3).jpg

因为标记-压缩、复制等回收算法会直接修改对象的内存地址,因此对于C、C++这种可以直接操作指针的语言不太适用。毕竟谁也不想回收一次后,自己对象的地址就全变了吧,哈哈。

空闲链表分配器

相比线性分配器的高局限性,现在使用更为广泛的是空闲链表分配器。空闲链表的思想也很朴素,前面我们讲到,线性分配只有一个指针,没办法维护空闲的内存。那么好办,一个指针解决不了,那就X个(狗头)。空闲链表分配会在内部维护一个空闲内存块的链表。当用户申请内存的时候,只需要从链表中找到合适的内存块即可。

流程图 (4).jpg

当然,我们可以有多种策略来从链表中找到合适的内存块。

  • 首次适应:从链表头开始遍历,选择第一个大小大于申请内存的内存块;

  • 循环首次适应:从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;

  • 最优适应:从链表头遍历整个链表,选择最合适的内存块;

空闲链表分配器成功解决了线性分配器的问题。我们可以重用空闲内存,也无需频繁压缩空间。那么是不是这样就够了呢?

追求极致的程序员们肯定不会满足于此。上面的三种查询策略都需要遍历链表,时间复杂度为O(n)。根据通用的“分而治之”思想,我们可以更进一步,这就是隔离适应策略。

隔离适应策略是将内存分割为多个链表,称之为分箱(bins),每个分箱的内存块大小相同,申请内存时先找到满足条件的分箱,再从分箱中找到空闲内存块。

流程图 (5).jpg

目前大多数的编程语言都采用了空闲链表分配器+隔离适应策略来管理内存分配。我这里列举一些常用分配器的实现:

  • dlmalloc:第一个被广泛使用的通用动态内存分配器,最早由Doug Lea在1980s年代编写,出现后即得到了广泛应用,同时也启发了后代很多优秀分配器如google的tcmaclloc,freeBSD的jemalloc。dlmalloc将小于256字节的内存分为32个分箱,将256字节以上的用树级分箱来管理。有兴趣的同学可以看下 dlmalloc

  • ptmalloc/ptmallocX:起源于dlmalloc,由Wolfram Gloger改进得可以支持多线程。是glibc 内置分配器的原型(也就是Linux系统默认分配器)。ptmalloc一共维护了128个分箱,分为四种方式来进行管理。可参考 ptmalloc

  • tcmalloc:出自google,使用于WebKit/Chrome之中,同时也是Golang分配器的原型。

  • jemalloc:出自facebook推出的,使用于firefox/android 5.0/FreeBSD等。jemalloc使用了三种分类来管理他的分箱。参考 jemalloc

  • pymalloc:python语言分配器,大于256k对象调用C标准库分配器分配,小于256k对象再自行通过分箱等进行分配。参考 pymalloc

Golang分配器基于tcmalloc,我们后面会详细介绍golang分配器的设计。

内存回收

内存回收,也有另一个名字叫垃圾收集(Garbage Collection,GC)。前面我们提到了,内存分配是在堆上进行,堆内存又并非是无穷尽的,那么就需要合理的使用堆内存。主要涉及到几个点:

  • 合理的申请内存,尽量少浪费。

  • 及时清理掉不再使用的内存。

当然第一点是由用户程序控制的,语言层面没办法干预。语言层面可以在第二点上多做些工作,使得无效内存识别更准确,清理更及时,对应用程序影响最小。

不同编程语言对内存回收的处理也不太一样。目前分为两种流派,一种以C/C++/Rust为代表,主张把内存回收的方式交给用户程序,由用户自行决定哪些内存需要回收。另一种则是以Lisp/Java/Go为代表,从语言层面实现了垃圾收集器,通过精心设计的算法自动收集及回收垃圾,将用户程序从繁重的垃圾管理工作中解脱出来。

可能有些同学会说,看起来垃圾收集是个很有必要的功能啊,为什么C/C++这类语言不提供支持呢?这里引用C++设计者Bjarne Stroustrup的一段话

“我有意这样设计C++,使它不依赖于自动垃圾回收(通常就直接说垃圾回收)。这是基于自己对垃圾回收系统的经验,我很害怕那种严重的空间和时间开销,也害怕由于实现和移植垃圾回收系统而带来的复杂性。”

“并不是每个程序都需要永远无休止的运行下去;并不是所有的代码都是基础性的库代码;对于许多应用而言,出现一点存储流失是可以接受的;许多应用可以管理自己的存储,而不需要垃圾回收或者其他与之相关的技术,如引用计数等。”

我们后面主要讨论第二种类型,即垃圾收集器的原理。

垃圾识别

垃圾收集器首先需要判断出来,哪些数据还存活着,而哪些数据是不再被需要的,也就是垃圾。这里又存在两种方式。

  1. Reference Counting:引用计数,即没有再被任何东西引用的对象,就是可回收垃圾。python就使用了引用计数法。

简单来说就是:

  • 所有对象都存在一个记录引用计数的计数器。

  • 所有对象在创建的时候或被其他对象引用的时候,引用计数为1。

  • 当别的对象进行引用变更时,原先被引用的对象引用计数-1。

  • 当引用计数为0的时候,回收对象所占用的内存空间

当然,引用计数很容易遇到循环引用的问题。例如A引用B,B引用A,除此之外没有任何别的对象指向他们俩,而他俩却因为计数不为0一直无法被回收。

循环引用的解法:

  • 利用垃圾收集算法,如python中利用标记-清除算法解决循环引用问题。

  • 在程序设计语言层面提供一些办法,由程序员来解决。如C++智能指针中的weak_ptr(引用时不+1)

  • 借助Tracing GC,找到没有被环以外的对象引用的环,把它们回收掉

  1. Tracing** **GC:将程序的内存占用分为GC root和GC head两部分。以roots集合作为起始点进行图的遍历(顺着指针递归去找),如果从roots到某个对象是可达的,则该对象称为“可达对象”。否则就是不可达对象,其内存空间可以被回收。

image

目前java,go等主流编程语言都全部或部分使用了Tracing GC的方式。

垃圾收集

基于Tracing GC的垃圾识别方法,常见的垃圾收集算法有标记-清除算法(Mark-Sweep)复制算法(Coping)标记-整理算法(Mark-Compact)分代清理(Generational Collection)。

标记-清除算法

首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。它是最基础的收集算法,后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

缺点:会产生内存碎片问题,可能导致大内存对象无法分配。

image

复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

复制算法解决了标记-清除的内存碎片问题,缺点是可用内存变为一半,经常搬运长生命周期的对象导致效率下降。

image

标记-整理算法

标记过程仍然与"标记-清除"算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。这样就解决了复制算法中可用内存只有一半的问题。

image

分代算法

GC分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。

分代算法将堆内存分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用"标记-清理"或"标记-整理"算法来进行回收。

Golang采用的是标记-清除算法,内存碎片的问题主要是通过分配器分配方式来解决。

Go内存管理

内存分配

Tcmalloc算法,全称Thread-Caching Malloc,是google推出的内存分配器,用来替代传统的malloc内存分配函数。它有减少内存碎片,适用于多核,更好的并行性支持等特性。

Golang的分配器就借鉴了 TCMalloc 的设计实现高速的内存分配,它的核心理念是使用多级缓存将对象根据大小分类,并按照类别实施不同的分配策略。

内存管理单元

我们这里先介绍下Golang内存分配的基本单位。

  • Page:与操作系统管理内存的方式类似,Golang将虚拟内存空间以page作为单位进行划分,每个page默认8k。

  • Span:连续的N个Page称为一个Span。

  • Object:应用程序以Object作为整体在Page上分配内存。大对象甚至会横跨多个Page。

Golang以Span为单位向系统申请内存,申请到的Span可能只有一个Page,也可能有N个Page。Span中的Page可以被划分为一系列小对象,也可以整体当做中对象或者大对象分配。

因此三者的关系,可以用下面的图来描述。

流程图 (6).jpg

SpanClass

对于不同大小的Object,Golang也按大小进行了分级,根据分级来制定不同的分配策略。因为程序中绝大多数都是小对象,分级处理有利于提升效率。

  • 微对象:小于16B。

  • 小对象:大于16B,小于32KB。

  • 大对象:大于32KB。

针对小对象,Golang还更细致的将对象大小分成了68级,称为Size Class。每个待分配的对象都会向上取整到一个更大的Size Class。例如我们要分配一个9B的对象,他就会被取整为16B,浪费率为43.75%。

更详细的分类见 sizeclasses.go,我这里省略了0的情况。一个Page大小为8192。

class字节数(B)对应span的大小(B)占用page数每个span可分配数最大浪费
1881921102487.50%
2168192151243.75%
3248192134129.24%
...............
662867257344724.91%
6732768327684112.50%

Size Class在Golang中的体现是SpanClass,定义在mheap.go/spanClass。spanClass是一个int8,前面7位存储size class的级数信息,最后一位存储noscan,用于记录有无指针,1为无指针,0为有指针,有指针的时候需要参与到内存回收扫描过程。

type spanClass uint8

const (
   numSpanClasses = _NumSizeClasses << 1
   tinySpanClass  = spanClass(tinySizeClass<<1 | 1)
)

func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
   return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

func (sc spanClass) sizeclass() int8 {
   return int8(sc >> 1)
}

func (sc spanClass) noscan() bool {
   return sc&1 != 0
}

例如我们要分配一个9B的对象,他就会被取整为16B,级数信息是2,如果这个对象没有指针,那noscan就是1,这个对象就会被分配到SpanClass为 00000101 的span中。

Span

前面我们讲到了,Golang会对整个虚拟内存空间划分Page,而不只是对堆内存。每个Page都有一个PageId,从0开始递增。对于内存中的任意地址,都可以通过简单的移位操作来找到对应的Page。

static const size_t kPageShift  = 13; // page大小:1 << 13 = 8KB
const PageID p = reinterpret_cast<uintptr_t>(ptr) >> kPageShift;

而Span是由1个或多个连续Page组成,每个Span对象都有一个起始Page地址以及包含的Page数量。同时Span还有prev和next两个指针,方便组成双向链表。这里的spanClass就是我们上面所说的分级,每个span都只会服务于一种SpanClass。

Span定义在golang中是mheap.go/mspan。

type mspan struct {
   // 第一部分:关联性字段
   next        *mspan     // 下个span的指针
   prev        *mspan     // 上个span的指针
   // 第二部分:span标识、统计性字段
   startAddr   uintptr    // span首地址
   npages      uintptr    // 该span占用了多少page
   spanclass   spanClass  // size class定义
   state       mSpanStateBox   // 当前span的状态:使用中,空闲,回收。
   nelems      uintptr      // 记录这个Span被spanClass切割成了多少份,即可以存放多少个对象 
   allocCount  uint16   // 记录已经分配对象个数
   
   // 第三部分:对象分配字段
   allocBits  *gcBits  // 位图,记录已经分配的对象
   gcmarkBits *gcBits  // 位图,记录内存的回收情况,用于垃圾回收
   freeindex   uintptr // span中空闲对象扫描的初始index,与allocCache配置使用
   allocCache  uint64  // allocBits的缓存
}

这里比较巧妙的是,freeindex与allocCache,allocBits的设计。

我们简单来个图,实际感受下span设计的有趣之处。我们假设这个Span的spanClass为32B,只有一个Page,那么一个Span可以放256个对象,我们称为有256个slot,目前有4个slot是处于使用状态。allocCache中会存放从freeindex开始的64个slot的分配情况,当需要分配内存时,allocCache可以快速使用CTZ方法获取到一个可用的位(这个方法基于deBruijn序列,有兴趣的同学可以了解下),当没有可用的数据时从allocBits中获取。

流程图 (7).jpg

多级缓存

我们先看看tcmalloc的整体框架。来自 tcmalloc/design

image

这里的User Code就是我们日常写的用户程序,OS是操作系统。Tcmalloc作为中间层又分了三级,Front-End(前端)、Middle-End(中端)、Back-End(后端)。简单来说三者主要的角色分工是:

  1. 前端是一个高速缓存,为应用程序提供快速的内存分配和内存释放;

  2. 中端负责填充前端缓存;

  3. 后端负责从操作系统中申请内存;

Golang基本复用了tcmalloc的三层框架,对应的三层实现分别是 mcache(前端),mcentral(中端),mheap(后端)。

mcache

mcache作为一个缓存,里面存放了大量已分配或未分配的Span。当用户程序申请小对象内存时,mcache会查找自己管理的内存块,如果有符合条件的就直接返回,否则向中端请求一批内存来重新填充。当内存被释放后,将会重新加入到缓存中。大部分情况下,前端缓存都能满足用户程序的需求。

mcache有个很重要的优化点,就是它只会让一个线程访问,所以不需要加锁,避免了加锁带来的性能损耗。对应到Golang的GMP模型,就是每个mcache都会与一个P进行绑定,只有这个P能访问mcache中的对象。

大家也可以想想,为啥前端缓存是跟P绑定,而不是跟M绑定?

mcache在Golang中的定义在mcache.go/mcache。

type mcache struct {
    alloc [numSpanClasses]*mspan // mspan的数组
}

numSpanClasses = _NumSizeClasses << 1  // size Class数目的两倍=136.

这里mcache是基于各种SpanClass维护了一个span的数组。前面我们讲到SpanClass由两部分组成,前七位是size class的级数,共有68个,最后一位是noscan表示是否有指针。因此这里mcache实际上是将有指针和无指针的span也进行了区分,主要是方便进行内存回收(noscan的span就不会进行GC标记了),共136个。示意图如下。

流程图 (8).jpg

当mcache资源不足时,会从中端缓存mcentral中获取Span,加入到对应的Span列表中。

mcentral

mcentral的定义在mcentral.go/mcentral中。

type mcentral struct {
   spanclass spanClass
   partial [2]spanSet // list of spans with a free object
   full    [2]spanSet // list of spans with no free objects
}

我们可以从代码中看到,一个mcentral只会有一个spanClass,聪明的你可能会想到,系统中是不是也有136个mcentral在维护136种span?答案是没错。

每一个mcentral只负责管理一种spanClass类型的mspan,并且管理的单元就是mspan,如果mcache中缓存的某种spanClass类型的Span没有空闲的了,就会向对应spanClass类型的mcentral申请。我们可以把mcache当做你家本地的小超市,这个小超市各种货都有买,同时也只能服务附近的居民。mcentral就是超市里某种货的供货商,当超市少了某种货时,就会找供应商进货。

partial和full是实际存储span的数组,这里设置为数组是将 GC清理过 和 GC未清理过 分拆成两份,便于管理。

mcache向mcentral申请内存的流程如下,有兴趣的同学可以看mcentral.go/cacheSpan方法:

  1. 从清理过的、包含空闲空间的partial列表中查找可以使用的Span;

  2. 从未清理过的、包含空闲空间的partial列表中查找可以使用的Span;

  3. 从未清理过的、不包含空闲空间的full列表中获取span并通过sweep()函数清理它的内存空间;

    1. 清理过后如果获取到了内存,则进行分配;

    2. 如果没获取到内存,就将这个span加到 清理过的、不包含空闲空间的full列表中。同时进入第4步

  4. 通过grow()方法向mheap申请内存。

  5. 更新从mheap中新申请到的内存的allocCache。

mheap

最后我们介绍mheap,mheap主要完成三个工作:

  • 作为一个全局变量,管理所有从系统中申请到的堆内存。

  • 当无内存可用时,向操作系统申请内存

  • 将不需要的内存返还给操作系统

大家回想我们前面讲到的堆与栈在进程内存中的分布情况,堆在内存中是一段连续空间,那么按理说,mheap也应该是一段连续的空间,所有被程序申请到的内存都以Page的方式分割开来,类似于这样。

流程图 (9).jpg

但是这种线性分配内存的方法,在C,GO混用的时候,存在一些问题:

  • 分配的内存地址会发生冲突,导致堆的初始化和扩容失败。issue

  • 没被预留的大块内存可能会被分配给C语言,导致扩容后的堆不连续。issume

因此,Golang从Go 1.11以后,就开始将线性内存管理,转变为了稀疏内存管理。通过一个heapArena数组维护占用的部分区域内存。

流程图 (10).jpg

我们先来看看mheap的定义,在mheap.go/mheap中。可以看到定义了heapArena数组集合,同时维护了136个SpanClass的mcentral。每个heapArena在64位机器上是64M大小。

type mheap struct {
   lock  mutex
   pages pageAlloc // page分配的结构   
   allspans []*mspan   // 所有的span
   
   arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena  // arenas数组集合,管理各个heapArena
   allArenas []arenaIdx   // 所有arena的序号集合,可以根据arenaIdx算出对应于arenas中的哪个heapArena
   
   central [numSpanClasses]struct {   // 各个mspanClass的central对象
       mcentral mcentral
       pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
   }
}

type heapArena struct {
    bitmap [heapArenaBitmapBytes]byte  // 映射内存地址中是否有对象,以及是否被gc标记
    spans [pagesPerArena]*mspan     // 该heapArena管理的span
    pageInUse [pagesPerArena / 8]uint8  // 正在使用的page的位图
    pageMarks [pagesPerArena / 8]uint8  // GC标记页的位图
}

这里每个heapArena都用一个bitmap来维护这个heapArena的对象分配及GC标记情况,用spans数组维护实际管理的span,pageInUse和pageMarks数组用于标记。

小结

上面的逻辑比较多,大家看完可能也云里雾里的。所谓一图胜前言,我们接下来用一个大图来描述我们上面讲到的三级缓存的关系。

流程图 (11).jpg

分配逻辑

前面我们讲到,Golang将对象按大小分成了三类:

  • 微对象:小于16B。

  • 小对象:大于16B,小于32KB。

  • 大对象:大于32KB。

每一类的分配逻辑,又不太相同。

微对象分配器

微对象分配器主要是处理 小于16B,且没有指针 的小对象。微对象分配器处于mcache上,主要用来分配较小的字符串和逃逸的临时变量。他可以将多个较小的内存分配合入同一个内存块中,只有当内存块中所有对象都要被回收时,整片内存才会被回收。

type mcache struct {
    tiny       uintptr   //指向分配微对象的首地址
    tinyoffset uintptr   //指向已分配对象的尾地址,也就是下一个可以分配的首地址
    tinyAllocs uintptr   // 记录微对象分配其中已分配对象的个数
}

微对象分配流程如下:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
    if size <= maxSmallSize {
            if noscan && size < maxTinySize {
                off := c.tinyoffset
                // 省略部分取整代码
                if off+size <= maxTinySize && c.tiny != 0 {
                    // 将对象分配到微对象分配器中
                    x = unsafe.Pointer(c.tiny + off)
                    // 更新微对象分配器中的状态
                    c.tinyoffset = off + size
                    c.tinyAllocs++
                    mp.mallocing = 0
                    releasem(mp)
                    return x
                }
                // 如果微对象分配器中的内存不足时,使用span进行分配.
                span = c.alloc[tinySpanClass]
                //调用mcache中缓存的mspan获取内存.
                v := nextFreeFast(span)
                if v == 0 {
                    // mcache中获取不到,所以要走mcentral,乃至mheap中去申请内存
                    v, span, shouldhelpgc = c.nextFree(tinySpanClass)
                }
                // 返回对应内存的指针
                x = unsafe.Pointer(v)
                (*[2]uint64)(x)[0] = 0
                (*[2]uint64)(x)[1] = 0  // 如果微对象分配器没有初始化,则将当前对象申请的空间作为微对象分配器的空间if size < c.tinyoffset || c.tiny == 0 {
                    c.tiny = uintptr(x)
                    c.tinyoffset = size
            }
            size = maxTinySize
    }
    ...
}

逻辑比较简单,就是先从微对象分配器中进行分配,如果微对象分配器中内存不足时,则走小对象分配器进行分配。比如我们下面的一个微对象16B中已经分配了12B,如果接下来要申请5B的内存,那就无法分配,只能走小对象分配了。

流程图 (12).jpg

小对象分配

小对象的分配方式我们前面也讨论了很多,整体可以分为三个步骤:

  1. 确定分配对象的大小和对应的SpanClass

  2. 从线程缓存mcache,中心缓存mcentral中获取Span,并从Span中通过allocCache和freeindex找到空闲的内存空间。

  3. 调用memclrNoHeapPointers方法清空空闲内存中的所有数据。

// 确定SpanClass
var sizeclass uint8
if size <= smallSizeMax-8 {
    sizeclass = size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]
} else {
    sizeclass = size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]
}
// 通过对应的spanClass类型再反向获取内存大小
size = uintptr(class_to_size[sizeclass])
// 通过对应大小以及有无指针获取到spanClass
spc := makeSpanClass(sizeclass, noscan)
// 最后通过spanClass获取mcache中缓存的mspan
span := c.alloc[spc]
// 调用mcache中缓存的mspan获取内存.
v := nextFreeFast(span)
if v == 0 {
    // mcache中获取不到,所以要走mcentral,乃至mheap中去申请内存
    v, span, _ = c.nextFree(spc)
}
// 转为指针返回
x = unsafe.Pointer(v)
if needzero && span.needzero != 0 {
    // 清空空闲内存中的所有数据;
    memclrNoHeapPointers(unsafe.Pointer(v), size)
}

这里的nextFreeFast主要负责从mcache中获取span,而nextFree负责从mcentral,乃至mheap中去申请内存,申请到之后会放入mcache中。

大对象分配

对于大于32KB的大对象,Golang会单独处理,不会经过mcache和mcentral这两层,而是直接从heap上分配对象。

if size <= maxSmallSize {
    if noscan && size < maxTinySize {
        //微对象分配
        ...
    } else {
        //小对象分配
        ...
    }
} else {
    // 大对象分配
    var s *mspan
    span = c.allocLarge(size, needzero, noscan)
    span.freeindex = 1
    span.allocCount = 1
    x = unsafe.Pointer(span.base())
    size = span.elemsize
}

而allocLarge方法会调用mheap直接进行分配,并生成一个spanClass为0的对象,同时会将这个对象放入spanClass为0的对应的mcentral中,便于GC清除。

func (c *mcache) allocLarge(size uintptr, needzero bool, noscan bool) *mspan {
        //通过大小获取需要分配的Page数量
        npages := size >> _PageShift
        if size&_PageMask != 0 {
                npages++
        }
        ...
        //直接调用mheap进行分配.
        spc := makeSpanClass(0, noscan)
        s := mheap_.alloc(npages, spc, needzero)
        ...
        // 将大对象放入到spanClass为0的mcentral中,便于GC清除。
        mheap_.central[spc].mcentral.fullSwept(mheap_.sweepgen).push(s)
        s.limit = s.base() + size
        heapBitsForAddr(s.base()).initSpan(s)
        return s
}

总结

Golang借鉴tcmalloc的分配策略,将对象根据大小分类,并设计了mcache,mcentral,mheap这三级缓存组件,极大的提升了内存分配效率。在阅读代码中发现的一些奇妙的设计点,也值得我们慢慢品味。

内存回收

前面我们在 预备知识#内存回收 这节介绍了垃圾识别的两种流派:引用计数法和Tracing法,也介绍了常见的垃圾收集算法即标记-清除算法,复制算法,标记-整理算法,分代清理算法。接下来我们看Golang中是怎么实现的。

Golang GC采用Tracing法来扫描对象,使用不分代(对象没有分代)、不整理(回收过程中不移动对象)、并发(与用户代码并发执行)的三色标记-清除算法来清理对象。

这里可能就有些同学会疑惑了,标记-清除不是最原始的GC算法吗,为什么不用标记-整理、分代算法?这里主要有几个原因。

  • 对象整理主要是为了解决内存碎片问题,但是Golang的内存分配法因为进行了对象大小分级,并且拥有多级缓存机制,基本不会出现碎片问题。并且Golang会对用户程序暴露指针,一旦整理对象就会导致指针异常。

  • 分代GC依赖于分代假设,即GC主要回收目标应该是新创建的对象(存活时间短,更容易被回收)。但是Go编译器会通过逃逸分析将大部分新生对象直接存储在栈上,跟随栈的生命周期而被回收,只有需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。因此分代也没有太多优势。

Golang发展史上,有几个版本的改动比较重要。

  • go 1.3以前,Go采用串行式的标记-清除法,每次GC都需要STW(stop the world,也就是将所有程序暂停运行)进行标记和清除,程序很容易出现卡顿。

  • go 1.5,为了降低GC延迟,采用了并发标记和并发清除的三色标记法,加入了write barrier写屏障,实现了更好的回收器调度。

  • go 1.8,引入混合屏障以消除STW中的re-scan,降低了STW的最差耗时。

三色标记法

Golang GC会从根对象开始扫描,扫描过程中对象可能处于不同的状态,三色标记法将对象分为三类,并以不同颜色相称。

  • 白色对象:未被回收器访问到的对象。扫描开始时,所有对象均为白色;扫描结束后,所有白色对象均为不可达对象。

  • 灰色对象:已被回收器访问到的对象,需要进一步对他们所指向的对象进行遍历。

  • 黑色对象:已被回收器访问到的对象,并且该对象中所有的指针都已被扫描。黑色对象中任何一个指针都不可能直接指向白色对象。

image

大家可以看到,随着扫描的进行,黑色对象和白色对象形成了鲜明的两界,而灰色对象作为波面夹在黑白两色之间。值得一提的是,三色标记法这里的三色抽象波面只是为了方便描述及论证方案正确性,实践中并没有实际含义。

标记过程如下:

  1. 初始状态下所有对象都是白色的;

  2. 从根对象出发扫描所有可达对象,标记为灰色,放入待处理队列;

  3. 从待处理队列中取出灰色对象,将其引用的对象标记为灰色放入待处理队列中,然后将自身标记为黑色;

  4. 重复步骤3,直到待处理对象为空。此时白色对象即为不可达对象,可以回收白色对象。

Golang中,根对象主要包括:

  1. 全局变量,编译器确定的那些存在于程序整个生命周期的变量

  2. 执行栈:每个goroutine的执行栈上的变量

  3. 寄存器:寄存器的值可能是个指向堆内存区块的指针

image

并发问题

基于上述的三色标记法流程,我们可以发现,它有一个很重要的前提是,在标记过程中,对象之间的引用不能变,也就是说,需要在标记过程中启动STW,不然标记结果就可能错误。

流程图 (13).jpg

如图所示,如果没开启STW,在对象2的扫描过程中,用户程序变更导致指针A被删除,同时对象4新增了对于对象3的指针。由于对象4已经扫描完成,所以对象3不会再被标灰,同时对象2也没有办法找到对象3,导致对象3被误清除

我们可以看出,在三色标记法中,同时出现下面两种情况时,就会出现对象丢失现象。

  1. 条件一:一个白色对象被黑色对象引用(白色被挂在黑色下)

  2. 条件二:灰色对象与它指向的白色对象的关系遭到破坏(灰色对象丢失了该白色对象)

为了防止这种现象,最简单的方式就是STW,直接禁止掉其他用户程序对对象引用关系的干扰,但是STW本身有明显的资源浪费,并且会影响所有的用户程序。所以我们要在保证对象不丢失的情况下,通过另一种机制来减少STW时间,这个机制就是屏障机制

屏障机制

三色不变式

从对象丢失的两个条件来看,通过某些方式可以破坏这两个条件,不就可以避免对象丢失了嘛。因此Golang提出了两种三色不变式。

  • 强三色不变式:不允许存在黑色对象引用白色对象

流程图 (14).jpg

  • 弱三色不变式:所有被黑色对象引用的白色对象都处于灰色保护状态。换句话说,就是只有在白色对象存在其他灰色对象对他的引用的时候,黑色对象才可以引用白色对象。

流程图 (15).jpg

插入屏障(Dijkstra屏障)

具体操作:在A对象引用B对象的时候,将B对象标记为灰色。

满足:强三色不变式。

伪码如下:

添加下游对象(当前下游对象slot, 新下游对象ptr) {   
  标记灰色(新下游对象ptr)  
  当前下游对象slot = 新下游对象ptr                    
}

A.添加下游对象(nil, B)   //A 之前没有下游, 新添加一个下游对象B, B被标记为灰色
A.添加下游对象(C, B)     //A 将下游对象C 更换为B,  B被标记为灰色

插入屏障主要是破坏了条件一,因为新指向的对象不可能是白色对象,而只能是灰色对象了。然而每次进行赋值操作都需要引入写屏障,这会增加大量性能开销。Golang考虑到栈函数操作频繁对速度要求高的特点,就没有将插入屏障在栈空间的对象操作中使用,而仅仅用于堆空间对象中。

当然,插入屏障也存在几个问题。

  1. 插入屏障是相对保守的算法,它会将可能存活的对象标记为灰色以满足三色不变形。因此在一次回收过程中可能会残留一部分对象没回收成功,需要等下一次才能回收。例如下图,由于对象1的指针变动,扫描完成后不可达的对象3被标记为黑色。

流程图 (16).jpg

  1. 由于没对栈对象执行插入屏障,因此栈对象的变更是无法保证不丢失的,需要在标记终止之后STW,对发生了变更的栈进行重新扫描。 流程图 (17).jpg

删除屏障(Yuasa屏障)

具体操作:被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。

满足:弱三色不变式。(保护灰色对象到白色对象的路径不断)

伪码:

添加下游对象(当前下游对象slot, 新下游对象ptr) {
  //1
  if (当前下游对象slot是灰色 || 当前下游对象slot是白色) {
        标记灰色(当前下游对象slot)  //slot为被删除对象, 标记为灰色
  }
  //2
  当前下游对象slot = 新下游对象ptr
}

A.添加下游对象(B, nil)   //A对象,删除B对象的引用。B被A删除,被标记为灰(如果B之前为白)
A.添加下游对象(B, C)     //A对象,更换下游B变成C。B被A删除,被标记为灰(如果B之前为白)

这里要注意的是,被标记为灰色的对象是之前的被引用对象。如下图,对象1将指针从对象2转为指向对象3后,对象2被标记为灰色,原本可能丢失的白色对象3再次获得了灰色对象2的保护。

流程图 (20).jpg

删除屏障也存在问题,那就是回收精度低。一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,需要在下一轮GC中被清理掉。

流程图 (21).jpg

混合写屏障

由于插入屏障需要在扫描终止阶段STW,将所有栈对象标记为灰色重新扫描,在活跃goroutine非常多的程序中,重新扫描是笔很大的开销。

Golang在v1.8版本引入了Dijkstra屏障和Yuasa屏障组合而成的混合写屏障,避免了对栈重新重新扫描的过程,极大的减少了STW的时间。

具体操作:

  1. GC开始将栈上的对象全部扫描并标记为黑色,这个过程不需要STW(之后不再进行第二次重复扫描)

  2. GC期间,任何在栈上创建的新对象,均为黑色

  3. 被删除的对象标记为灰色

  4. 被添加的对象标记为灰色

**满足:**变形的弱三色不等式

伪码:

添加下游对象(当前下游对象slot, 新下游对象ptr) {
      //1 只要当前下游对象被移走,就标记灰色
      标记灰色(当前下游对象slot)   
      //2 
      标记灰色(新下游对象ptr)
          
      //3
      当前下游对象slot = 新下游对象ptr
}

A.添加下游对象(nil, B)   //A 之前没有下游。新添加一个下游对象B, B被标记为灰色
A.添加下游对象(C, B)     //A 将下游对象C更换为B,B,C均被标记为灰色

流程图 (22).jpg

需要注意的是,Golang只需要在标记初始阶段,将栈上所有对象都标记为黑色,这个过程也不需要STW,因为后续栈上生成的对象都会是黑色的;后续栈上对象的指针变更,均不会使用屏障技术,因为要保证栈的运行效率。

我们这里列举下部分场景下的表现,帮助大家理解混合写屏障:

(1)堆对象被一个堆对象删除引用,成为栈对象的下游

流程图 (23).jpg

(2)栈对象被一个栈对象删除引用,成为另一个栈对象的下游

流程图 (24).jpg

(3)堆对象被一个堆对象删除引用,成为另一个堆对象的下游

流程图 (25).jpg

(4)栈对象被一个栈对象删除引用,成为另一个堆对象的下游

流程图 (26).jpg

混合写屏障结合了删除屏障和插入屏障的优点,避免了在扫描终止阶段STW,减少了STW的时间。

执行过程

Golang GC分为四步:

  • 标记准备阶段:为了打开写屏障,必须停止每个goroutine,垃圾收集器会观察并等待每个goroutine进入安全点(Safe Point),然后停止程序,这时候进入STW状态

  • 标记阶段:

    • 切换GC状态为标记状态(_GCmark),开启写屏障;
    • 恢复用户程序,标记进程开始检查所有goroutine的堆栈以找到根对象,然后从根对象开始进行进行遍历;由于这时候恢复执行程序了,所以用户程序可能会产生新的对象,golang这时候会将一些分配内存过快的goroutine,转去辅助标记进程标记对象。 这样就可以降低新对象生产速度,加快标记速度。
  • 标记终止阶段

    • 暂停程序,进入STW状态,切换GC状态为标记终止态(_GCmarktermination);
    • 清理部分P上的线程缓存
  • 清理阶段:

    • 切换GC状态为关闭状态(_GCoff),这时候会关闭写屏障;
    • 恢复用户程序,所有新创建的对象会标记为白色;
    • 后台并发清理所有的Span,同时,如果有goroutine申请新的Span时,也会触发清理,从而将清理导致的延迟也分摊到了每次内存分配中。

内存逃逸

初次看到内存逃逸这个词的同学可能会一脸懵逼,不太清楚为啥内存还需要逃逸,他不是按照申请的堆栈内存正常分配就行了嘛。让我们先从一些小问题来讲起。

C语言中,由于需要手动管理内存,可爱(ke lian)的程序猿们就经常遇到两个问题:内存泄露悬挂指针。内存泄露咱们前面已经说过是因为内存无法回收导致的,而悬挂指针则是因为内存回收错误导致的。以这段代码为例。

int* suspend_pointer() {
    int i = 2;
    return &i;
}

我们在函数中初始化了一个变量,并将这个变量的地址返回给外部调用方使用。然而由于C语言中,栈变量的作用域只会在函数中,当函数返回时,函数的所有栈变量都被回收了,因此这个暴露出去的指针将会带来不可预知的结果。这就是典型的悬挂指针问题。

由于Golang同样支持操作指针,因此在一定层面上也会面临与C语言一样的问题。而Golang是怎么解决的呢?答案是通过内存逃逸,将原本应该在栈上分配、需要在函数运行后使用的变量分配到堆上**。而本身GC也会带来较大的性能损耗,因此最好将一些原本应分配到堆上、不需要在函数运行后使用的内存分配到栈上。这个决策内存分配位置的过程就是逃逸分析。**

逃逸分析

逃逸分析是静态分析的一种,主要是在编译阶段由编译器进行分析和优化。

逃逸分析遵循两个原则:

  1. 指向栈对象的指针不能存活在堆中;

  2. 指向栈对象的指针不能在栈对象回收后还存活。

流程图 (18).jpg

逃逸分析的步骤是:

  • 在编译器解析了 Go 语言源文件后,它可以获得整个程序的抽象语法树,然后构建带权重的有向图。有向图的顶点表示被分配的变量,边表示变量之间的分配关系,权重表示寻址和取地址的次数。

  • 遍历对象分配图并查找违反两条不变性的变量分配关系,如果堆上的变量指向了栈上的变量,那么该变量需要分配在堆上;

  • 记录从函数的调用参数到堆以及返回值的数据流,增强函数参数的逃逸分析。

为了保证内存的安全,编译器可能会将一些变量错误的分配到堆上,后续会由GC进行回收。

逃逸典型

函数返回局部变量指针

看下面的例子

func Add(x,y int) *int {
    res := 0
    res = x + y
    return &res
}

func main()  {
    Add(1,2)
}

查看逃逸分析结果为

image

可以看到,函数返回的局部变量被放到了heap中。

interface类型逃逸

还是先看例子

func main()  {
    str := "字节彭于晏"
    fmt.Printf("%v",str)
}

编译结果为

image

这里会发生逃逸,主要是因为fmt.Printf的入参是个interface{},因此编译期间无法判断入参类型,这时候为了保证内存安全,也会发生逃逸。但是这个例子的结果是str escapes to heap而不是moved to heap: str,这是因为golang函数是值传递,这里在Printf中传递的只是字节彭于晏,这个string被存储到了堆上,而str本身还是一个栈上的指针对象。

如果我们换一种写法。

func main()  {
    str := "字节彭于晏"
    fmt.Printf("%p", &str)
}

image

这次由于咱们传的是str的指针,逃逸到栈上之后,就违背了逃逸分析第一条原则“指向栈对象的指针不能存活在堆中”, 因此str这个对象本身也逃逸到了堆上。

闭包逃逸

func Increase() func() int {
    n := 0
    return func() int {
        n++
        return n
    }
}

func main() {
    in := Increase()
    fmt.Println(in()) // 1
}

image

因为函数也是一个指针类型,所以匿名函数当作返回值时也发生了逃逸,在匿名函数中使用外部变量n,这个变量 n 会一直存在直到 in 被销毁,所以 n 变量逃逸到了堆上。

变量大小不确定及栈空间不足引发逃逸

我们先使用ulimit -a查看操作系统的栈空间,我电脑的栈空间是8192。

func LessThan8192()  {
    nums := make([]int, 100)
    for i := 0; i < len(nums); i++ {
        nums[i] = rand.Int()
    }
}


func MoreThan8192(){
    nums := make([]int, 1000000)
    for i := 0; i < len(nums); i++ {
        nums[i] = rand.Int()
    }
}


func NonConstant() {
    number := 10
    s := make([]int, number)
    for i := 0; i < len(s); i++ {
        s[i] = i
    }
}

func main() {
    NonConstant()
    MoreThan8192()
    LessThan8192()
}

image

我们可以看到,当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。

同样当我们初始化切片时,没有直接指定大小,而是填入的变量,这种情况为了保证内存的安全,编译器也会触发逃逸,在堆上进行分配内存。

实践优化

本节会介绍一些内存使用优化的技巧,因为本节并不是文档的重点,所以会简略介绍,有兴趣的同学可以自行谷歌。

  1. 容器类优化

    1. slice和map初始化时,建议直接初始化容量为最终大小,可以避免多次扩容;

    2. 拼接字符串时,使用strings.Builder而不是bytes.Buffer,因为bytes.Buffer会存在拷贝和重分配;

    3. 当使用map时只需要key,不需要value时,以string为例,可以将map定义为 map[string]struct{}。这是因为系统对于struct{}做了优化,不会占用空间,指向同一个内存空间。

  2. 内存逃逸相关优化

    1. 返回函数结果时,可以区分对象大小使用不同返回方式。小对象用值,大对象用指针。这是因为指针对象会被逃逸分析分配到堆上,加大GC负担。
  3. 并发相关

    1. 尽量使用atomic来替代锁操作。这是因为atomic是在硬件层面做的支持。

    2. 尽量使用不带缓冲区的channel。这是因为所有通过channel传递的值都会被逃逸分析分配到堆上,而且带缓冲区的channel会在send时发生一次内存拷贝,不带缓冲区的不发生内存拷贝。

    3. 使用goroutine池gopkg/gopool

    4. 使用并发安全的rand库gopkg/rand

  4. GC相关

    1. 通过动态设置 GOGC 参数,使得服务更少的GC,释放计算资源。这个优化适用于空闲内存多,cpu占用高的服务。有兴趣可以看 链接

总结

本文介绍了业界内存管理相关知识点,重点介绍了Golang内存分配和回收的知识。因为篇幅有限,本文只是重点介绍了Golang内存分配的多级缓存机制,内存回收的三色标记法及屏障等知识。更细的知识如内存分配整体流程,内存回收源码分析等并未涉及。敬请期待后续。

参考文档

论文:Dynamic Storage Allocation:A Survey and Critical Review

面向信仰编程

《内存分配器 (Memory Allocator)》

12 他山之石:高性能内存分配器 jemalloc 基本原理.md

怎么解决引用计数 GC 的循环引用问题?

Go内存管理一文足矣

TCMalloc解密 – Wallen's Blog

Runtime: Golang GC源码分析 | 学习笔记

详解Go语言中的内存逃逸

xargin.com