什么是 GC

当程序向操作系统申请的内存不再需要时,垃圾回收主动将其回收并供其他代码进行内存申请时候复用,或者将其归还给操作系统,这种针对内存级别资源的自动回收过程,即为垃圾回收。而负责垃圾回收的程序组件,即为垃圾回收器。

从进程虚拟地址空间来看,程序要执行的指令在代码段。全局变量,静态数据等都会分配在数据段,而函数的局部变量,参数和返回值都可以在函数栈帧中找到,但是由于函数调用栈会在函数返回后销毁。如果不能在编译阶段确定数据对象的大小。或者对象生命周期会超出当前所有函数,那就不适合分配在栈上,而应该分配在堆上。随着程序的运行,有的在栈上的数据不会被用到了,会随着函数调用栈的销毁释放自身占用的内存。而这些在堆上分配的数据就不一样了,他们占用的内存需要程序主动释放后才可以重新使用,否则就会成为垃圾。

image.png

自动垃圾回收怎么区分哪些数据对象是垃圾?

还得从虚拟地址空间来看,可以确定的是程序中用得到的数据,一定是从栈、数据段这些跟节点追踪得到的数据。
image.png
虽然能够追踪得到不代表后续一定会用到,但这些从跟节点追踪不到的数据 一定不会被用到,也就一定是垃圾。所以目前主流的垃圾回收算法都是使用“可达性” 近似等价于“存活性” 的,要识别存活对象,可以把栈,数据段上的数据对象作为root,基于它们进一步追踪。把能追踪到的数据都进行标记,剩下的追踪不到的就是垃圾了。这就是标记清扫算法的核心思想。

通常,垃圾回收器的执行过程被划分为两个半独立的组件:

  • 赋值器(Mutator):这一名称本质上是在指代用户态的代码。因为对垃圾回收器而言,用户态的代码仅仅只是在修改对象之间的引用关系,也就是在对象图(对象之间引用关系的一个有向图)上进行操作。
  • 回收器(Collector):负责执行垃圾回收的代码。

根对象到底是什么?

根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:

  1. 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
  2. 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
  3. 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

Go GC历史版本变更

  • go1.1,提高效率和垃圾回收精确度。
  • go1.3,提高了垃圾回收的精确度。
  • go1.4,之前版本的runtime大部分是使用C写的,这个版本大量使用Go进行了重写,让GC有了扫描stack的能力,进一步提高了垃圾回收的精确度。
  • go1.5,目标是降低GC延迟,采用了并发标记和并发清除,三色标记write barrier,以及实现了更好的回收器调度,设计文档1文档2,以及这个版本的Go talk
  • go1.6,小优化,当程序使用大量内存时,GC暂停时间有所降低。
  • go1.7,小优化,当程序有大量空闲goroutine,stack大小波动比较大时,GC暂停时间有显著降低。
  • go1.8write barrier切换到hybrid write barrier,以消除STW中的re-scan,把STW的最差情况降低到50us,设计文档
  • go1.9,提升指标比较多,1)过去 runtime.GC, debug.SetGCPercent, 和 debug.FreeOSMemory都不能触发并发GC,他们触发的GC都是阻塞的,go1.9可以了,变成了在垃圾回收之前只阻塞调用GC的goroutine。2)debug.SetGCPercent只在有必要的情况下才会触发GC。
  • go.1.10,小优化,加速了GC,程序应当运行更快一点点
  • go1.12,显著提高了堆内存存在大碎片情况下的sweeping性能,能够降低GC后立即分配内存的延迟。

常见的 GC 实现方式有哪些?Go 语言的 GC 使用的是什么?

所有的 GC 算法其存在形式可以归结为追踪(Tracing)和引用计数(Reference Counting)这两种形式的混合运用。

  • 追踪式 GC
    从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定需要保留的对象,从而回收所有可回收的对象。Go、 Java、V8 对 JavaScript 的实现等均为追踪式 GC。
  • 引用计数式 GC
    每个对象自身包含一个被引用的计数器,当计数器归零时自动得到回收。因为此方法缺陷较多,在追求高性能时通常不被应用。Python、Objective-C 等均为引用计数式 GC。

目前比较常见的 GC 实现方式包括:

  • 追踪式,分为多种不同类型,例如:
    • 标记清扫:从根对象出发,将确定存活的对象进行标记,并清扫可以回收的对象。
    • 标记整理:为了解决内存碎片问题而提出,在标记过程中,将对象尽可能整理到一块连续的内存上。
    • 增量式:将标记与清扫的过程分批执行,每次执行很小的部分,从而增量的推进垃圾回收,达到近似实时、几乎无停顿的目的。
    • 增量整理:在增量式的基础上,增加对对象的整理过程。
    • 分代式:将对象根据存活时间的长短进行分类,存活时间小于某个值的为年轻代,存活时间大于某个值的为老年代,永远不会参与回收的对象为永久代。并根据分代假设(如果一个对象存活时间不长则倾向于被回收,如果一个对象已经存活很长时间则倾向于存活更长时间)对对象进行回收。
  • 引用计数:根据对象自身的引用计数来回收,当引用计数归零时立即回收。

Go 的 GC 目前使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。原因[1]在于:

  1. 对象整理的优势是解决内存碎片问题以及“允许”使用顺序内存分配器。但 Go 运行时的分配算法基于 tcmalloc,基本上没有碎片问题。 并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于 tcmalloc 的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。
  2. 分代 GC 依赖分代假设,即 GC 将主要的回收目标放在新创建的对象上(存活时间短,更倾向于被回收),而非频繁检查所有对象。但 Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说,分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当 goroutine 死亡后栈也会被直接回收,不需要 GC 的参与,进而分代假设并没有带来直接优势。并且 Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 CPU 来执行垃圾回收),而非减少停顿时间这一单一目标上。

GOV.3之前的标记-清除(mark and sweep)方法

此算法主要有两个主要的步骤:

  • 标记(Mark phase)
  • 清除(Sweep phase)

第一步,暂停程序业务逻辑, 找出不可达的对象,然后做上标记。第二步,回收标记好的对象。
操作非常简单,但是有一点需要额外注意:mark and sweep算法在执行的时候,需要程序暂停!即 STW(stop the world)。也就是说,这段时间程序会卡在哪儿。

image.png
第二步, 开始标记,程序找出它所有可达的对象,并做上标记。如下图所示:

image.png
第三步, 标记完了之后,然后开始清除未标记的对象. 结果如下.

image.png

第四步, 停止暂停,让程序继续跑。然后循环重复这个过程,直到process程序生命周期结束。

标记-清扫(mark and sweep)的缺点

  • STW,stop the world;让程序暂停,程序出现卡顿 (重要问题)
  • 标记需要扫描整个heap
  • 清除数据会产生heap碎片

image.png
Go是如何面对并这个问题的呢?接下来G V1.5版本 就用三色并发标记法来优化这个问题.

三色标记法是什么?

理解三色标记法的关键是理解对象的三色抽象以及波面(wavefront)推进这两个概念。三色抽象只是一种描述追踪式回收器的方法,在实践中并没有实际含义,它的重要作用在于从逻辑上严密推导标记清理这种垃圾回收方法的正确性。也就是说,当谈及三色标记法时,通常指标记清扫的垃圾回收。

三色并发标记法

概括(以下是详情)

  1. 有黑白灰三个集合. 初始时所有对象都是白色
  2. 从Root对象开始标记, 将所有可达对象标记为灰色
  3. 从灰色对象集合取出对象, 将其引用的对象标记为 灰色, 放入灰色集合, 并将自己标记为黑色
  4. 重复第三步, 直到灰色集合为空, 即所有可达对象都 被标记
  5. 标记结束后, 不可达的白色对象即为垃圾. 对内存进 行迭代清扫, 回收白色对象.
  6. 重置GC状态

从垃圾回收器的视角来看,三色抽象规定了三种不同类型的对象,并用不同的颜色相称:

  • 白色对象(可能死亡):未被回收器访问到的对象。在回收开始阶段(只要是新创建的对象),所有对象均为白色,当回收结束后,白色对象均不可达。
  • 灰色对象(波面):已被回收器访问到的对象,但回收器需要对其中的一个或多个指针进行扫描,因为他们可能还指向白色对象。
  • 黑色对象(确定存活):已被回收器访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象。

可以清晰的展现追踪式回收过程中对象状态的变化过程

  1. 垃圾回收开始会把所有数据都标记为白色;

image.png

image.png

  1. 每次GC回收开始, 然后从根节点开始遍历所有对象,灰色代表基于当前节点展开的追踪还未完成;

image.png

  1. 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合,表示它是有用数据,而且无需基于它再次进行追踪了。

image.png

  1. 重复第三步, 直到灰色中无任何对象. 此时有用数据都为黑色,垃圾都为白色,在清除阶段回收这些白色的垃圾即可。

image.png

image.png

  1. 回收所有的白色标记表的对象. 也就是回收垃圾.

image.png
Go是如何解决标记-清除(mark and sweep)算法中的卡顿(stw,stop the world)问题?

没有STW的三色标记法

他是一定要依赖STW的. 因为如果不暂停程序, 程序的逻辑改变对象引用关系, 这种动作如果在标记阶段做了修改,会影响标记结果的正确性。我们举一个场景.
如果三色标记法, 标记过程不使用STW将会发生什么事情?

image.png

image.png

image.png

image.png

image.png

可以看出,有两个问题, 在三色标记法中,是不希望被发生的

  • 条件1: 一个白色对象被黑色对象引用(白色被挂在黑色下)
  • 条件2: 灰色对象与它之间的可达关系的白色对象遭到破坏(灰色同时丢了该白色)

当以上两个条件同时满足时, 就会出现对象丢失现象!

当然, 如果上述中的白色对象3, 如果他还有很多下游对象的话, 也会一并都清理掉.
为了防止这种现象的发生,最简单的方式就是STW,直接禁止掉其他用户程序对对象引用关系的干扰,但是STW的过程有明显的资源浪费,对所有的用户程序都有很大影响,如何能在保证对象不丢失的情况下合理的尽可能的提高GC效率,减少STW时间呢?
只要使用一个机制,来破坏上面的两个条件就可以了.

屏障机制

概括

三色标记需要维护(弱)不变性条件: 黑色对象不能引用无法被灰色对象 可达的白色对象.

并发标记时, 如果没有做正确性保障措施, 可能会导致漏标记对象, 导 致实际上可达的对象被清扫掉.

让GC回收器,满足下面两种情况之一时,可保对象不丢失. 所以引出两种方式.

(1) “强-弱” 三色不变式

  • 强三色不变式

不存在黑色对象引用到白色对象的指针。

image.png

  • 弱三色不变式

所有被黑色对象引用的白色对象都处于灰色保护状态

image.png

为了遵循上述的两个方式,Golang团队初步得到了如下具体的两种屏障方式“插入屏障”, “删除屏障”.

(2) 插入屏障

具体操作: 在A对象引用B对象的时候,B对象被标记为灰色。(将B挂在A下游,B必须被标记为灰色)
满足: 强三色不变式. (不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)
伪码如下:

  1. 添加下游对象(当前下游对象slot, 新下游对象ptr) {
  2. //1
  3. 标记灰色(新下游对象ptr)
  4. //2
  5. 当前下游对象slot = 新下游对象ptr
  6. }

场景:

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

这段伪码逻辑就是写屏障,. 我们知道,黑色对象的内存槽有两种位置, . 栈空间的特点是容量小,但是要求相应速度快,因为函数调用弹出频繁使用, 所以“插入屏障”机制,在栈空间的对象操作中不使用. 而仅仅使用在堆空间对象的操作中.

image.png

image.png

image.png

image.png

image.png

image.png
但是如果栈不添加,当全部三色标记扫描之后,栈上有可能依然存在白色对象被引用的情况(如上图的对象9). 所以要对栈重新进行三色标记扫描, 但这次为了对象不丢失, 要对本次标记扫描启动STW暂停. 直到栈空间的三色标记结束.

image.png

image.png

image.png

最后将栈和堆空间 扫描剩余的全部 白色节点清除. 这次STW大约的时间在10~100ms间.

image.png


(3) 删除屏障

具体操作: 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。
满足: 弱三色不变式. (保护灰色对象到白色对象的路径不会断)
伪代码:

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

场景:

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

用几张图,来模拟整个一个详细的过程,

image.png

image.png

image.png

image.png

image.png

image.png

image.png

这种方式的回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。

Go V1.8的混合写屏障(hybrid write barrier)机制

插入写屏障和删除写屏障的短板:

  • 插入写屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活;
  • 删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。

Go V1.8版本引入了混合写屏障机制(hybrid write barrier),避免了对栈re-scan的过程,极大的减少了STW的时间。结合了两者的优点。

(1) 混合写屏障规则

具体操作:
1、GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW),
2、GC期间,任何在栈上创建的新对象,均为黑色。
3、被删除的对象标记为灰色。
4、被添加的对象标记为灰色。
满足: 变形的弱三色不变式.
伪代码:

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

注意, 屏障技术是不在栈上应用的,因为要保证栈的运行效率。

(2) 混合写屏障的具体场景分析

接下来,我们用几张图,来模拟整个一个详细的过程, 希望您能够更可观的看清晰整体流程。

注意混合写屏障是Gc的一种屏障机制,所以只是当程序执行GC的时候,才会触发这种机制。

GC开始:扫描栈区,将可达对象全部标记为黑

image.png

image.png

场景一: 对象被一个堆对象删除引用,成为栈对象的下游
  1. //前提:堆对象4->对象7 = 对象7; //对象7 被 对象4引用
  2. 栈对象1->对象7 = 堆对象7 //将堆对象7 挂在 栈对象1 下游
  3. 堆对象4->对象7 = null //对象4 删除引用 对象7

image.png

image.png

场景二: 对象被一个栈对象删除引用,成为另一个栈对象的下游
  1. new 栈对象9
  2. 对象8->对象3 = 对象3 //将栈对象3 挂在 栈对象9 下游
  3. 对象2->对象3 = null //对象2 删除引用 对象3

image.png

image.png

image.png

场景三:对象被一个堆对象删除引用,成为另一个堆对象的下游
  1. 堆对象10->对象7 = 堆对象7 //将堆对象7 挂在 堆对象10 下游
  2. 堆对象4->对象7 = null //对象4 删除引用 对象7

image.png

image.png

image.png

场景四:对象从一个栈对象删除引用,成为另一个堆对象的下游
  1. 堆对象10->对象7 = 堆对象7 //将堆对象7 挂在 堆对象10 下游
  2. 堆对象4->对象7 = null //对象4 删除引用 对象7

image.png

image.png

image.png

Golang中的混合写屏障满足弱三色不变式,结合了删除写屏障和插入写屏障的优点,只需要在开始时并发扫描各个goroutine的栈,使其变黑并一直保持,这个过程不需要STW,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行re-scan操作了,减少了STW的时间。

总结

以上便是Golang的GC全部的标记-清除逻辑及场景演示全过程。
GoV1.3- 普通标记清除法,整体过程需要启动STW,效率极低。
GoV1.5- 三色标记法, 堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要STW),效率普通
GoV1.8-三色标记法,混合写屏障机制, 栈空间不启动,堆空间启动。整个过程几乎不需要STW,效率较高。

标记清除算法实现起来相对简单,但是比较容易造成内存碎片化,而碎片化会影响内存分配与程序执行的效率。

这一问题,可以配合相应的内存管理模型来缓解。例如Tcmalloc内存管理模型这样,把内存块分成不同的规格进行统一管理,可以很好的应对碎片化问题。还有人提出了标记——压缩(整理)算法。

主流垃圾回收算法

01 标记——清除

顾名思义,先把垃圾都标记出来,然后再把垃圾都清除掉。

标记的过程需要扫描数据段和栈上的数据,把能够直接追踪到的数据作为root,基于这些root进一步追踪,把能追踪到的数据都进行标记,那剩下的没追踪到的就是垃圾了。

  • 三色抽象

02. 标记——压缩(整理)

标记——压缩算法的标记阶段与标记——清除算法相同,不同的是,它会在完成标记工作后对堆内存的使用进行压缩。

所谓的压缩,就是移动非垃圾数据,使它们尽可能紧凑的放在内存中。

虽然标记——压缩算法有效解决了内存碎片化的问题,但是带来的多次扫描与移动开销也不容小觑。

标记——压缩算法比较鲜明的特点便是它会移动数据来减少碎片化,还有一种复制式回收算法,也会移动数据。

03. 复制式回收

  1. 复制式回收算法会把堆内存划分成两个相等的空间,From和To。程序执行时使用From空间;
  2. 垃圾回收执行时会扫描From空间,把能追踪到的数据复制到To空间。
  3. 当所有有用的数据都复制到To空间后,把From和To空间的角色交换一下。原来的To空间用作From,原来的From空间则可以全部回收作为新的To空间。

image.png

每一轮垃圾回收都是如此,这种复制式回收也不会带来碎片化问题,而且因着使用连续的内存块,可以实现高速的内存分配。但是明显的不足之处就是只有一半的堆内存可以被使用。

为了提高堆内存的使用率,通常会和其它垃圾回收算法搭配使用,只在一部分堆内存中使用复制式回收。例如在分代回收中就经常搭配使用复制式回收。

04. 分代回收

分代回收的提出,主要是基于弱分代假说(weak generational hypothesis):

大部分对象都在年轻时死亡”**

如果我们把新创建的对象称为“新生代对象”,把经受住特定次数的垃圾回收而依然存活的对象称为“老年代对象”。

基于弱分代假说,新生代对象成为垃圾的概率高于老年代对象,所以可以把数据划分为新生代和老年代,降低老年代执行垃圾回收的频率。

对于标记、复制式等追踪类回收算法而言,不用每次都扫描所有数据,将明显提升垃圾回收执行的效率,而且新生代和老年代还可以分别采用不同的回收策略,进一步提升回收效益并减少开销。

分代回收算法大多通过复制式回收来处理新生代对象,只有经历过一定次数的垃圾回收还能依然存活的新生代对象才会被晋升为老年代对象。虽然分代回收算法将回收的注意力主要集中在新生代对象上,但是考虑到

老年代到新生代的引用,也依然做不到只扫描新生代就把回收工作完成的地步。

到目前为止我们介绍的多为追踪式回收,都需要在执行垃圾回收时扫描数据识别垃圾对象,而引用计数式垃圾回收有所不同。

05. 引用计数

引用计数指的是一个数据对象被引用的次数,程序执行过程中会更新对象及其子对象的引用计数。当引用计数更新到0时,就表示这个对象不再有用,可以回收它占用的内存了。

所以,引用计数法不用专门执行扫描任务,因为垃圾识别的任务已经分摊到每一次对数据对象的操作中了。

这样说起来简单,但实现起来却并不容易。虽然引用计数法可以及时回收无用内存,但是高频率的更新引用计数也会造成不小的开销。而且还要专门想办法识别循环引用的情况,因为循环引用会导致引用计数无法更新到0,造成对应的内存无法被回收的情况。

image.png

这样三种不变性所定义的回收过程其实是一个波面不断前进的过程,这个波面同时也是黑色对象和白色对象的边界,灰色对象就是这个波面。

当垃圾回收开始时,只有白色对象。随着标记过程开始进行时,灰色对象开始出现(着色),这时候波面便开始扩大。当一个对象的所有子节点均完成扫描时,会被着色为黑色。当整个堆遍历完成时,只剩下黑色和白色对象,这时的黑色对象为可达对象,即存活;而白色对象为不可达对象,即死亡。这个过程可以视为以灰色对象为波面,将黑色对象和白色对象分离,使波面不断向前推进,直到所有可达的灰色对象都变为黑色对象为止的过程。如下图所示:

image.png

5. STW 是什么意思?

STW 可以是 Stop the World 的缩写,也可以是 Start the World 的缩写。通常意义上指指代从 Stop the World 这一动作发生时到 Start the World 这一动作发生时这一段时间间隔,即万物静止。STW 在垃圾回收过程中为了保证实现的正确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图的一段过程。
在这个过程中整个用户代码被停止或者放缓执行, STW 越长,对用户代码造成的影响(例如延迟)就越大,早期 Go 对垃圾回收器的实现中 STW 长达几百毫秒,对时间敏感的实时通信等应用程序会造成巨大的影响。我们来看一个例子:

Go 语言能够支持实时的,高并发的消息系统,在高达百万级别的消息系统中能够将延迟降低到 100ms 以下,着一切很大一部分需要归功于 Go 的高效的垃圾回收系统。

内存中存储对象的数目往往都非常巨大,这就导致 gc 时间常常高达数百毫秒。这就会导致在 GC 的时候整个系统是阻塞的。

Go 的垃圾收集器是和主程序并行的。

并行垃圾回收是如何工作的?

Go 的 GC 是如何实现并行的呢?其中的关键在于三色标记清除算法。该算法能够让系统的 gc 暂停时间成为能够预测的问题。调度器能够在很短的时间内实现 GC 调度,并且对源程序的影响极小。

参考

Golang’s Real-time GC in Theory and Practice

todo remove

https://www.ulovecode.com/2019/08/06/Go/Golang%E8%AF%91%E6%96%87/Go%E5%AE%9E%E6%97%B6GC%E2%80%94%E2%80%94%E4%B8%89%E8%89%B2%E7%AE%97%E6%B3%95%E7%90%86%E8%AE%BA%E4%B8%8E%E5%AE%9E%E8%B7%B5%20/

eggo

有了 GC,为什么还会发生内存泄露?

在一个具有 GC 的语言中,常说的内存泄漏,用严谨的话来说应该是:预期的能很快被释放的内存由于附着在了长期存活的内存上、或生命期意外地被延长,导致预计能够立即回收的内存而长时间得不到回收。
在 Go 中,由于 goroutine 的存在,所谓的内存泄漏除了附着在长期对象上之外,还存在多种不同的形式。

形式1:预期能被快速释放的内存因被根对象引用而没有得到迅速释放

当有一个全局对象时,可能不经意间将某个变量附着在其上,且忽略的将其进行释放,则该内存永远不会得到释放。例如:

  1. var cache = map[interface{}]interface{}{}
  2. func keepalloc() {
  3. for i := 0; i < 10000; i++ {
  4. m := make([]byte, 1<<10)
  5. cache[i] = m
  6. }
  7. }

形式2:goroutine 泄漏

Goroutine 作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象,例如:

  1. func keepalloc2() {
  2. for i := 0; i < 100000; i++ {
  3. go func() {
  4. select {}
  5. }()
  6. }
  7. }

STW为什么会暂停

STW是为了暂停当前所有运行中的goroutine,进行一些准备工作,比如开启WB,把全局变量,以及每个goroutine中的 Root对象 收集起来,Root对象是标记扫描的源头,可以从Root对象依次索引到使用中的对象。

Go何时开始Gc

Go GC Pacer机制决定这些阈值, GC Pacer基于Go程序大致稳定的假定, 并且有负反馈机制.

Go的GC触发与Java不同, Java是当内存某个具体值时(比如2G)触发GC. 而Go与上一次GC之后存活的对象占用的内存Hm(n-1)成 一个比例.

比如Hm(n-1)为1000M, default GOGC=100, 那么大概会在小于但比较接近2000M(比如1850, 1900M) Ht的时候开始GC; 结束GC时, 使得GC堆大小Ha(比如1990, 2010M)趋向于等于目标值Hg 2000M.

GC结束后, 会根据当前数据计算下一次GC触发值Hm(n+1).

在GC结束后, 下一次GC开始前, 需要sweep完所有的span. 有一个后台清扫协程, 同时用户协程也需要辅助sweep.

假设有k page的span需要sweep, 那么距离下一次GC开始还有Ht-Hm(n-1) 的内存可分配, 那么平均分配1bytes内存需要sweep k/(Ht-Hm(n-1)) page的span (会根据sweep进度进行比例更改)

如何保证GC按时结束?

GC在Ht开始, 到达到目标堆大小Hg时, 尽量mark完所有待mark的对象. 除了后台并发的标记协程, 程序分配内存时也需要辅助mark. 从Ht到Hg还有Hg-Ht的内存可分配, 这个时候还有scanWorkExpected的对 象需要scan, 那么平均分配1byte内存, 需要辅助mark scanWorkExpected/(Hg-Ht)的对象(会根据mark进度进行比例更改)

下一次GC啥时候开始?

下一次GC的触发值由反馈公式计算. 定性如下:

如果本次GC mark消耗的CPU高于目标值(30%), 则下次GC提前一点. 如果本次GC的Ha高于Hg, 则下次GC提前一点.