25 变量声明周期与GC机制

变量声明周期与GC机制

一 变量的生命周期

1.1 区分作用域与生命周期

变量的作用域 VS 变量的生命周期

  • 1、作用域是一个编译时的属性:声明位置决定了作用域的层层嵌套关系而不是调用位置
  • 2、生命明周期是一个运行时的概念:运行过程中变量会被回收

1.2 变量生命周期概述

变量的生命周期指的是在程序运行期间变量有效存在的时间间隔

变量生命周期的长短只取决于是其否可达(即可以被引用到)

1、对于在包一级声明的变量来说,它们在整个程序运行过程中都是可达的,所以它们的生命周期和整个程序的运行周期是一致的。

2、而相比之下,局部变量的可达性是:创建之后存在与其相关联的引用了便可达,不存任何与其相关联的引用了就不可达,所以它们的生命周期则是动态的,即从每次创建一个新变量的声明语句开始,直到该变量不再被引用就终止。因此一个循环迭代内部的局部变量的生命周期可能超出其局部作用域,例如被外部作用域的变量引用。同理,局部变量可能在函数返回之后依然存在。

1.3 生命周期结束与垃圾回收概述

变量不可达的变量意味着其生命周期的结束,然后其占用的存储空间可能被回收,有可能是被系统回收、有可能是被GC机制回收(在程序运行时,栈区的管理是由系统负责的,go自带的gc机制管理的是堆区)。

那Go语言的自动圾收集器gc是如何知道一个变量是何时可以被回收的呢?这里我们可以避开完整的技术细节,基本的实现思路是,从每个包级的变量和每个当前运行函数的每一个局部变量开始,通过指针或引用的访问路径遍历,是否可以找到该变量。如果不存在这样的访问路径,那么说明该变量是不可达的,也就是说它是否存在并不会影响程序后续的计算结果,此时该变量就会被gc回收。

此外强调一点:虽然Go的垃圾回收机制会回收不被使用的内存,但是这不包括操作系统层面的资源,比如打开的文件、网络连接。因此我们必须显式的释放这些资源。

Go语言的自动垃圾收集器对编写正确的代码是一个巨大的帮助,但也并不是说你完全不用考虑内存了。你虽然不需要显式地分配和释放内存,但是要编写高效的程序你依然需要了解变量的生命周期。例如,如果将指向短生命周期对象的指针保存到具有长生命周期的对象中,特别是保存到全局变量时,会阻止对短生命周期对象的垃圾回收(从而可能影响程序的性能)。

二 垃圾回收机制

2.1 垃圾回收机制介绍

1、什么是垃圾?

程序创建对象等引用类型实体时会在虚拟内存中分配给它们一块内存空间,如果该内存空间不再被任何引用变量引用时就成为需要被回收的垃圾。

拓展阅读:虚拟内存与物理内存

见:https://www.cnblogs.com/linhaifeng/p/15801949.html

2、什么是垃圾回收机制

垃圾回收Garbage Collection,缩写为GC,是一种自动内存管理机制,用来自动识别并释放回收不使用的内存对象,防止内存泄露

拓展阅读:内存泄露

“内存泄露”(Memory Leak)这个词看似自己很熟悉,可实际上却也从没有看过它的准确含义。

1、什么是内存泄露
内存泄露,是从操作系统的角度上来阐述的,形象的比喻就是“操作系统可提供给所有进程的存储空间(虚拟内存空间)正在被某个进程榨干”,导致的原因就是程序在运行的时候,会不断地动态开辟的存储空间,这些存储空间在在运行结束之后并没有被及时释放掉。应用程序在分配了某段内存之后,由于设计的错误,会导致程序失去了对该段内存的控制,造成了内存空间的浪费。

2、什么时候会泄露
如果程序在内存空间内申请了一块内存,之后程序运行结束之后,没有把这块内存空间释放掉,而且对应的程序又没有很好的gc机制去对程序申请的空间进行回收,这样就会导致内存泄露。

3、泄露后有何影响
从用户的角度来说,内存泄露本身不会有什么危害,因为这不是对用户功能的影响,但是“内存泄露”如果加剧,则会导致内存空间耗尽系统崩溃,用户程序自然不能幸免

4、内存泄露的关注点
对于 C 和 C++ 这种没有 Garbage Collection 的语言来讲,我们主要关注两种类型的内存泄漏:

堆内存泄漏(Heap leak)。对内存指的是程序运行中根据需要分配通过 malloc,realloc new 等从堆中分配的一块内存,再是完成后必须通过调用对应的 free 或者 delete 删掉。如果程序的设计的错误导致这部分内存没有被释放,那么此后这块内存将不会被使用,就会产生 Heap Leak.

系统资源泄露(Resource Leak).主要指程序使用系统分配的资源比如 Bitmap,handle ,SOCKET 等没有使用相应的函数释放掉,导致系统资源的浪费,严重可导致系统效能降低,系统运行不稳定。
内存泄露涉及到的相关问题还有很多,这里暂不展开讨论。

3、为何要有垃圾回收机制?

操作系统会记录一个进程运行时的所占用的内存、CPU和寄存器等资源,只有当进程结束后 ,操作系统才会自动回收其所有资源,但是对于一个运行较长时间的程序,操作系统肯定不会参与回收,如果使用完内存资源后没有及时释放,就会造成内存泄漏甚至系统错误。

这就要求我们必须在必要时对自己的程序进行回收垃圾的操作,但这非常耗费程序员的精力,所以在go、python等语言中都提供了自动的垃圾回收机制

拓展阅读:手垃圾回收 VS 自动垃圾回收

C语言这种较为传统的语言通过malloc和free手动向操作系统申请和释放内存,这种自由管理内存的方式给予程序员极大的自由度,但是也相应地提高了对程序员的要求。

C语言的内存分配和回收方式主要包括三种:
1、函数体内的局部变量:在栈上创建,函数作用域结束后自动释放内存
2、静态变量:在静态存储区域上分配内存,整个程序运行结束后释放(全局生命周期)
3、动态分配内存的变量:在堆上分配,通过malloc申请,free释放

C、C++和Rust等较早的语言采用的是手动垃圾回收,需要程序员通过向操作系统申请和释放内存来手动管理内存,程序员极容易忘记释放自己申请的内存,对于一个长期运行的程序往往是一个致命的缺点。

Python、Java和Golang等较新的语言采取的都是自动垃圾回收方式,程序员只需要负责申请内存,垃圾回收器会周期性释放结束生命周期的变量所占用的内存空间。

4、内存管理构简图如下

在程序的执行过程中,用户程序Mutator通过内存分配器Allocator在堆Heap上申请内存(也可能会改变对象的引用关系,或者创建新的引用),垃圾回收器Collector会定时清理堆上的内存。

也就是说内存分配器和垃圾收集器共同管理着程序中的堆内存空间,本章我们主要介绍垃圾回收器

垃圾回收器主要包括三个目标

1、无内存泄漏
垃圾回收器最基本的目标就是减少防止程序员未及时释放导致的内存泄漏,垃圾回收器会识别并清理内存中的垃圾

2、自动回收无用内存
垃圾回收器作为独立的子任务,不需要程序员显式调用即可自动清理内存垃圾

3、内存整理
如果只是简单回收无用内存,那么堆上的内存空间会存在较多碎片而无法满足分配较大对象的需求,因此垃圾回收器需要重整内存空间,提高内存利用率

2.2 主流垃圾回收算法

储备知识

  • 1、根对象是什么

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

    • 全局变量:程序在编译期就能确定的、那些存在于程序整个生命周期的变量。
    • 执行栈/调用函数产生的栈帧:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
    • 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。
  • 2、STW是什么

    STW英文全称Stop The World,指的是通过STW保证GC期间标记对象的状态不能变化,具体实现是暂停整个程序运行,然后进行垃圾回收,在外部看来程序就会卡顿。

目前比较常见的垃圾回收算法有两种形式、三种算法:

  • 1、引用计数(引用计数式GC)

    • (1)原理简介

    为每个对象维护一个引用计数,当引用该对象的对象销毁时,引用计数 -1,当对象引用计数为 0 时回收该对象,引用计数是渐进式的,能够将内存管理的开销分布到整个程序之中。

    • (2)代表语言

    Python、Objective-C

    • (3)优点

    算法易于实现

    对象回收快(渐进式。内存管理与用户程序的执行交织在一起,将 GC 的代价分散到整个程序。不像标记-清扫算法需要 STW ),

    内存单元能够很快被回收。相比于其他垃圾回收算法,堆被耗尽或者达到某个阈值才会进行垃圾回收。

    • (4)缺点

    原始的引用计数不能处理循环引用。大概这是被诟病最多的缺点了。不过针对这个问题,也除了很多解决方案,比如强引用等。

    维护引用计数降低运行效率。内存单元的更新删除等都需要维护相关的内存单元的引用计数,相比于一些追踪式的垃圾回收算法并不需要这些代价。

  • 2、标记-清除(追踪式GC)

    • (1)原理简介

    标记-清扫算法是基于追踪的垃圾收集算法,算法分两个部分:标记(mark)和清扫(sweep)

    标记阶段从根变量出发递归遍历所有直接或间接引用的对象,将被引用的对象打上标记,代表存活。

    通俗地讲就是,只要有一条从根对象出发能够引用到你的一条路径,那么你就会被标记为存活,

    标记阶段结束后会先STW,即暂停整个程序的全部运行线程,然后将没有被标记的进行回收。

    所以采用标记清除算法,内存单元并不会在变成垃圾立刻回收,而是保持不可达状态,直到到达某个阈值或者固定时间长度时,系统执行 STW挂起用户程序,然后再执行垃圾回收程序。

    • (2)代表语言:

    Go语言的三色标记法就是标记-清除算法的升级版,为解决传统标记清除的STW问题而生

    • (3)优点

    解决了引用计数的缺点,如循环引用问题、维护指针的开销。

    • (4)缺点

    需要 STW,暂时停掉程序运行,效率不高,GO语言的三色标记就是为了解决该问题的。

  • 3、分代收集(追踪式GC)

    原理简介:按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,短的放入新生代,这个过程叫做 promote,不同代有不同的回收算法和回收频率,随着不断 promote,最后新生代的大小在整个堆的占用比例不会特别大。收集的时候集中主要精力在新生代就会相对来说效率更高,STW 时间也会更短。

    代表语言:Java
    优点:回收性能好
    缺点:算法复杂

三 GO的GC机制

3.1 GO语言GC的历史及演进

go语言垃圾回收总体采用的是经典的标记清除(mark and sweep)算法,Go的GC自出生的时候就广受诟病,但在v1.5引入三色标记和v1.8引入混合写屏障后,正常的GC已经缩短到了10us左右,已经非常了不起了

Golang每次改版几乎都伴随着垃圾回收机制的改进,其中里程碑式的改动主要包括:

go v1.1标记-清除法(mark and sweep),整个过程都需要STW

Go v1.3标记-清除法(mark and sweep),标记过程仍然需要STW但清除过程并行化,gc pause约几百ms

GO v1.5之后三色标记法(是对标记-清除的升级,解决STW问题、为提升效率渐进式回收奠定了基础)

GO v1.5-v1.7三色标记法+开启写屏障,仅在堆空间启动插入写屏障,全部扫描后需要STW重新扫描栈空间,gc pause耗时降到10ms以下

Go v1.8三色标记法+结合写屏障和删除屏障的混合写屏障法,仅在堆空间启动混合写屏障,不需要在GC结束后对栈空间重新扫描,gc pause时间降低至0.5ms以下

go v1.14:引入新的页分配器用于优化内存分配的速度

拓展阅读演进详解(不读也行):https://www.cnblogs.com/linhaifeng/p/15849233.html

3.2 标记清除算法

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

  • 1、标记(Mark phase)

  • 2、清除(Sweep phase)

分下面四步进行

  • 1、开启STW,即暂停程序业务逻辑,在STW期间,程序无法响应请求

  • 2、开始标记,程序找出可达内存占用并做标记

  • 3、标记结束,然后开始清除未标记的对象

  • 4、结束STW,让程序继续运行,然后循环该过程,直到main生命周期结束

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

  • 1、STW,stop the world;让程序暂停,程序出现卡顿 (这是其最大的问题)
  • 2、标记需要扫描整个heap
  • 3、清除数据会产生heap碎片

Go V1.3版本之前的流程是

Go V1.3 做了简单的优化,将STW提前, 减少STW暂停的时间范围,Sweep阶段不再STW,可以和程序并发运行。如下所示

这种优化减小了STW的时长:如果运行在多核处理器上,go会试图将gc任务放到单独的核心上运行而尽量不影响业务代码的执行。go team自己的说法是减少了50%-70%的暂停时间。

但是这种粒度的STW对于性能较高的程序还是无法接受,STW会暂停用户逻辑对程序的性能影响依然是非常大的,因此Go1.5采用了三色标记法优化了STW。

3.2 三色标记算法

3.2.1 三色的含义

三色标记算法将程序中的对象分成白色、黑色和灰色三类:

  • 白色对象:潜在的垃圾,其内存可能会被垃圾收集器回收;
  • 黑色对象:活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象;
  • 灰色对象:活跃的对象,属于黑色到白色的中间状态,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象;

3.2.2 三色标记法工作原理

三色标记-清除算法执行分为两个阶段、5个步骤,如下图所示

  • 一、MARK标记阶段

    • 步骤1、初始状态将所有对象都标记为白色,如ABCDEFGH;

    • 步骤2、从root根出发扫描所有对象,将被它引用的对象标记为灰色,如上图中的A与F

    • 步骤3、分析上一步标记的所有灰色对象是否引用了其它对象。

    ​ 如果没有引用其它对象则将该灰色对象标记为黑色;

    ​ 如果有引用则将它变为黑色的同时将它引用的对象也变为灰色,如上图中A与B变黑,BCD变灰。

    步骤4、重复步骤3,直到灰色对象队列为空。此时白色对象即为垃圾对象,进行回收

  • 二、SWEEP清除阶段

    • 步骤5:垃圾收集器只会从灰色对象集合中取出对象开始扫描,当灰色集合中不存在任何对象时,标记阶段就会结束,堆内存中只剩下黑色的存活对象以及白色的垃圾对象,垃圾收集器可以回收这些白色的垃圾

上述原理的通俗解释如下:

三色标记算法的原理就好比是地毯式搜索,简称“把儿子拽过来你就走”,全部扫完后,没有祖宗的孤儿们会被回收,分以下几步

1、所有人全放在一起,放在一个大厅里,都戴着白色的帽子,等着叫号

2、根对象出列,戴上灰色的帽子

3、挨个问一遍戴灰帽子的对象,有儿子没有,没有的则戴上黑色帽子滚,有的则先把你儿子拉过来戴上灰色帽子同时你自己戴上黑色帽子你就可以滚了

4、重复步骤3,直到所有所有人都戴上黑色帽子或白色帽子,那些戴黑色帽子的都是有后代的,那些戴白色帽子的都是没有祖宗的孤儿,就被回收了。

3.2.3 三色标记算法标记垃圾产生的问题

了解完三色标记法的基本原理之后,我们并没有明显地发现它比传统的标记清除高级在哪里,确实如此,事实上三色标记法只是为垃圾回收的进一步优化打好了基础,需要配合一些其他的机制才可以把三色标记的优势发挥出来,如果只是它自己,肯定是有问题的,比如三色标记过程与用户程序并发执行的话,那么就有可能会产生问题

  • 漏标
    原本不是垃圾,但是GC的过程中,用户线程将其引用关系修改,导致GC Roots不可达,成为了垃圾。这种情况还好一点,无非就是产生了一些浮动垃圾,下次GC再清理就好了。
  • 错标,或称悬挂指针
    原本是垃圾,但是GC的过程中,用户线程将引用重新指向了它,这时如果GC一旦将其回收,将会导致程序运行错误。

=====》截图修改就好

浮动垃圾示例

例如:插入写屏障在一个垃圾收集器和用户程序交替运行的场景中可能会出现如下图所示的标记过程

  • 垃圾收集器将根对象指向 A 对象标记成黑色并将 A 对象指向的对象 B 标记成灰色;
  • 用户程序修改 A 对象的指针,将原本指向 B 对象的指针指向 C 对象,这时触发写屏障将 C 对象标记成灰色;
  • 垃圾收集器依次遍历程序中的其他灰色对象,将它们分别标记成黑色;

从上图中不难看出,B对象没有任何从根对象出发到它的引用,它不再存活应该被回收,但实际上它依然为灰色不会被回收,而如果我们在第二和第三步之间将指向 C 对象的指针改回指向 B,垃圾收集器仍然认为 C 对象是存活的,这些被错误标记的垃圾对象只有在下一个循环才会被回收。

所以说,Dijkstra 的插入写屏障是一种相对保守的、简单粗暴的屏障技术,它会将有存活可能的对象都标记成灰色以满足强三色不变性

悬挂指针示例

  • 1、所有灰色对象全被扫描完毕的同时,并行的用户程序新增了黑色对象到白色对象的引用

    详解如下:
    在标记阶段,即在MARK标记执行的过程中,并行的用户程序可能会修改对象的指针,
    如下图所示,在三色标记过程中,准确的说是刚刚扫描完毕所有的灰色对象,即标记阶段刚刚完成,正要准备回收白色对象的同时,用户程序建立了从黑色对象 A 对象到 白色对象D 的引用,但此时因为程序中已经不存在灰色对象了,也就说标记阶段已经结束了,所以对象D虽然被根对象A引用着,但它依然是白色的,所以在接下来的清除阶段 D 对象会被垃圾收集器错误地回收。

  • 2、灰色对象引用着白色对象(如下图对象2引用对象3),在将要把灰变黑、白变灰的时候,用户程序解除灰色对象到白色对象的引用,然后将一个黑色对象指向了该白色对象,如下图解释

上述两种情况下,均会引发本来不应该被回收的对象却被回收,这在内存管理中是非常严重的错误,我们将这种错误称为悬挂指针,即指针没有指向特定类型的合法对象,影响了内存的安全性。

分析三色标记bug的根源所在,主要是因为程序在运行过程中出现了下面俩种情况

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

为了防止这种现象的发生,最简单的方式就是STW,直接禁止掉其他用户程序对对象引用关系的干扰,但是STW的过程有明显的资源浪费,对所有的用户程序都有很大影响,如何能在保证对象不丢失的情况下合理的尽可能的提高GC效率,减少STW时间呢?

答案就是, 那么我们只要使用一个机制,来破坏上面的两个条件就可以了,于是诞生了屏障技术,使得赋值器在进行指针写操作时,同步垃圾回收器,详见下一小节

======》

综上,漏标问题,并不致命,下一次回收即可,真正致命的是错标悬挂指针问题,如何解决呢。。。。。。。。。

3.2.4 屏障机制

(1)什么是三色不变性

想要在并发或者增量的标记算法中保证正确性,我们需要达成以下两种三色不变性(Tri-color invariant)中的任意一种就可以了:

  • 强三色不变性:不允许黑色对象引用白色对象,即黑色对象只能指向灰色对象或者黑色对象;

  • 弱三色不变性:黑色对象可以引用白色,但白色对象一定要存在其他灰色对象对他的引用,或者说对该白色对象引用的链路上存在灰色对象。即所有被黑色对象引用的白色对象都处于灰色保护状态.

为了实现强/弱三色不变性,诞生了屏障机制

补充说明:
内存屏障技术解决的是三色标记法的STW缺点,并不是消除了所有的赋值器挂起问题。
需要分清楚STW方法是全局性的赋值器挂起,而内存屏障技术是局部的赋值器挂起。
(2)屏障技术介绍

内存屏障技术,顾名思义就是在某个事物前设置的屏障或者关卡,想要执行具体的事物,必须先走屏障,它就像是一个hook钩子方法:即在程序的执行过程中加一个判断机制,满足判断机制则触发执行回调函数。

拓展:
目前多数的现代处理器都会乱序执行指令以最大化性能,但是内存屏障技术让 CPU 或者编译器在执行内存相关操作时遵循特定的约束,以能够保证内存操作的顺序性:在内存屏障前执行的操作一定会先于内存屏障后执行的操作
(3)屏障技术分类

根据操作类型的不同,我们可以将它们分成两种

  • 1、读屏障(Read barrier)

  • 2、写屏障(Write barrier)

因为读屏障需要在读操作中加入代码片段,对用户程序的性能影响很大,所以编程语言往往都会采用写屏障保证三色不变性,而写屏障技术主要有两种

  • 1、插入写屏障,实现的是强三色不变式
  • 2、删除写屏障技术,实现的则是弱三色不变式

强调一个非常重要的点:

须知黑色对象的内存槽有两种位置, . 栈空间的特点是容量小,但是要求相应速度快,因为函数调用弹出频繁使用,所以为了保证栈的运行效率,在栈上,寄存器对象的赋值(插入,删除)不能 hook ,屏障只对堆上的内存对象启用,至于栈上的内存回收,会在GC结束后启用STW重新扫描。

(3)插入写屏障

Dijkstra 在 1978 年提出了插入写屏障。

Go1.5版本使用的Dijkstra写屏障就是这个原理,伪代码如下:

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

  //2
  当前下游对象slot = 新下游对象ptr                   
}

流程:在A对象引用B对象的时候,B对象被标记为灰色。

  1. 插入写屏障会在白色对象被黑色对象引用时触发,将白色对象被标记为灰色,这样就不存在黑色对象引用白色对象的情况了,从而实现强三色不变性(永远不会出现黑色对象指向白色对象),示例如下

  • 2、但是由于栈上对象无写屏障(不 hook),如果在gc扫描完所有灰色对象、准备开始回收所有白色对象的同时,在栈上新创建了一个对象,由于栈上对象没有写屏障,所以新建的对象仍为白色节点,会被回收,为了解决这个问题,在GC迭代结束时(所有灰色节点都扫描完毕),必须STW重新扫描栈。这期间会将所有goroutine挂起, goroutine 量大且活跃的场景,延迟不可控,经验值平均 10-100ms,如下图所示

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

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

(4)删除写屏障

Yuasa 在 1990 年提出了删除写屏障,

伪代码如下

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

  //2
  当前下游对象slot = 新下游对象ptr
}

对象被删除时触发,如果自身为灰色或者白色,那么它会被标记为灰色,实现的是弱三色不变性 (保护灰色对象到白色对象的路径不会断,老对象引用的下游对象一定可以被灰色对象引用)

删除写屏障也叫基于快照的写屏障方案,必须在起始时,STW 扫描整个栈(注意了,是所有的 goroutine 栈),保证所有堆上在用的对象都处于灰色保护下。

示例如下

上图四步解释

  1. 垃圾收集器将根对象指向 A 对象标记成黑色并将 A 对象指向的对象 B 标记成灰色;
  2. 用户程序将 A 对象原本指向 B 的指针指向 C,触发删除写屏障,但是因为 B 对象已经是灰色的,所以不做改变;
  3. 用户程序将 B 对象原本指向 C 的指针删除,触发删除写屏障,白色的 C 对象被涂成灰色
  4. 垃圾收集器依次遍历程序中的其他灰色对象,将它们分别标记成黑色;

上述过程中的第三步触发了 Yuasa 删除写屏障的着色,因为用户程序删除了 B 指向 C 对象的指针,所以 C 和 D 两个对象会分别违反强三色不变性和弱三色不变性:

  • 强三色不变性 — 黑色的 A 对象直接指向白色的 C 对象;
  • 弱三色不变性 — 垃圾收集器无法从某个灰色对象出发,经过几个连续的白色对象访问白色的 C 和 D 两个对象;

Yuasa 删除写屏障通过对 C 对象的着色,保证了 C 对象和下游的 D 对象能够在这一次垃圾收集的循环中存活,避免发生悬挂指针以保证用户程序的正确性。

删除写屏障的缺点与插入写屏障一样:同样存在回收精度较低的问题:一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉,如下

(5)混合写屏障

插入屏障和删除屏障各有优缺点

1、Dijkstra的插入写屏障在标记开始时无需STW,可直接开始,并发进行,但结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活;

2、Yuasa的删除写屏障则需要在GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象,但结束时无需STW。

Go1.8版本引入的混合写屏障(hybrid write barrier)结合了Yuasa的删除写屏障和Dijkstra的插入写屏障,避免了对栈re-scan的过程,极大的减少了STW的时间。结合了两者的优点。

伪代码如下:

writePointer(slot, ptr):  // 添加下游对象(当前下游对象slot, 新下游对象ptr)
    shade(*slot)          // 标记灰色(当前下游对象slot),只要当前下游对象被移走,就标记灰色
    if current stack is grey:
        shade(ptr)       // 标记灰色(新下游对象ptr)
    *slot = ptr          // 当前下游对象slot = 新下游对象ptr

这里使用了两个shade操作,shade(*slot)是删除写屏障的变形,例如,一个堆上的灰色对象B,引用白色对象C,在GC并发运行的过程中,如果栈已扫描置黑,而赋值器将指向C的唯一指针从B中删除,并让栈上其他对象引用它,这时,写屏障会在删除指向白色对象C的指针的时候就将C对象置灰,就可以保护下来了,且它下游的所有对象都处于被保护状态。 如果对象B在栈上,引用堆上的白色对象C,将其引用关系删除,且新增一个黑色对象到对象C的引用,那么就需要通过shade(ptr)来保护了,在指针插入黑色对象时会触发对对象C的置灰操作。如果栈已经被扫描过了,那么栈上引用的对象都是灰色或受灰色保护的白色对象了,所以就没有必要再进行这步操作。

Golang中的混合写屏障满足的是变形的弱三色不变式,同样允许黑色对象引用白色对象,白色对象处于灰色保护状态,但是只由堆上的灰色对象保护。由于结合了Yuasa的删除写屏障和Dijkstra的插入写屏障的优点,只需要在开始时并发扫描各个goroutine的栈,使其变黑并一直保持,这个过程不需要STW,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行re-scan操作了,减少了STW的时间。

总结具体操作

1、GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW),
2、GC期间,任何在栈上创建的新对象,均为黑色。
3、被删除的对象标记为灰色。
4、被添加的对象标记为灰色。

满足: 变形的弱三色不变式.

总结混合写屏障特点

  1. 混合写屏障继承了插入写屏障的优点,起始无需 STW 打快照,直接并发扫描垃圾即可;
  2. 混合写屏障继承了删除写屏障的优点,赋值器是黑色赋值器,扫描过一次就不需要扫描了,这样就消除了插入写屏障时期最后 STW 的重新扫描栈;
  3. 混合写屏障扫描精度继承了删除写屏障,比插入写屏障更低,随着带来的是 GC 过程全程无 STW;
  4. 混合写屏障扫描栈虽然没有 STW,但是扫描某一个具体的栈的时候,还是要停止这个 goroutine 赋值器的工作的(针对一个 goroutine 栈来说,是暂停扫的,要么全灰,要么全黑,原子状态切换)

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

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

证明:对于内存对象的操作主要包括四种基本情况,所以我们只要能在上述四种情况下,保障弱三原色约束,就能证明该写屏障是可靠的。

  1. 栈指针重指向到了另一个堆指针对象;
  2. 栈指针重指向到了另一个栈指针对象;
  3. 堆指针重指向到了另一个堆指针对象;
  4. 堆指针重指向到了另一个栈指针对象;

增量和并发

传统的垃圾收集算法会在垃圾收集的执行期间暂停应用程序,一旦触发垃圾收集,垃圾收集器会抢占 CPU 的使用权占据大量的计算资源以完成标记和清除工作,然而很多追求实时的应用程序无法接受长时间的 STW。

为了减少应用程序暂停的最长时间和垃圾收集的总暂停时间,我们会使用下面的策略优化现代的垃圾收集器:

  • 增量垃圾收集:增量地标记和清除垃圾,降低应用程序暂停的最长时间;
  • 并发垃圾收集:利用多核的计算资源,在用户程序执行时并发标记和清除垃圾;

因为增量和并发两种方式都可以与用户程序交替运行,所以我们需要使用屏障技术保证垃圾收集的正确性;与此同时,应用程序也不能等到内存溢出时触发垃圾收集,因为当内存不足时,应用程序已经无法分配内存,这与直接暂停程序没有什么区别,增量和并发的垃圾收集需要提前触发并在内存不足前完成整个循环,避免程序的长时间暂停。

增量收集

增量式(Incremental)的垃圾收集是减少程序最长暂停时间的一种方案,它可以将原本时间较长的暂停时间切分成多个更小的 GC 时间片,虽然从垃圾收集开始到结束的时间更长了,但是这也减少了应用程序暂停的最大时间:

需要注意的是,增量式的垃圾收集需要与三色标记法一起使用,为了保证垃圾收集的正确性,我们需要在垃圾收集开始前打开写屏障,这样用户程序修改内存都会先经过写屏障的处理,保证了堆内存中对象关系的强三色不变性或者弱三色不变性。虽然增量式的垃圾收集能够减少最大的程序暂停时间,但是增量式收集也会增加一次 GC 循环的总时间,在垃圾收集期间,因为写屏障的影响用户程序也需要承担额外的计算开销,所以增量式的垃圾收集也不是只带来好处的,但是总体来说还是利大于弊。

并发收集

并发(Concurrent)的垃圾收集不仅能够减少程序的最长暂停时间,还能减少整个垃圾收集阶段的时间,通过开启读写屏障、利用多核优势与用户程序并行执行,并发垃圾收集器确实能够减少垃圾收集对应用程序的影响:

虽然并发收集器能够与用户程序一起运行,但是并不是所有阶段都可以与用户程序一起运行,部分阶段还是需要暂停用户程序的,不过与传统的算法相比,并发的垃圾收集可以将能够并发执行的工作尽量并发执行;当然,因为读写屏障的引入,并发的垃圾收集器也一定会带来额外开销,不仅会增加垃圾收集的总时间,还会影响用户程序,这是我们在设计垃圾收集策略时必须要注意的。

GC的时机

运行时会通过如下所示的 runtime.gcTrigger.test 方法决定是否需要触发垃圾收集,当满足触发垃圾收集的基本条件时 — 允许垃圾收集、程序没有崩溃并且没有处于垃圾收集循环,该方法会根据三种不同方式触发进行不同的检查:

func (t gcTrigger) test() bool {
    if !memstats.enablegc || panicking != 0 || gcphase != _GCoff {
        return false
    }
    switch t.kind {
    case gcTriggerHeap:
        return memstats.heap_live >= memstats.gc_trigger
    case gcTriggerTime:
        if gcpercent < 0 {
            return false
        }
        lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))
        return lastgc != 0 && t.now-lastgc > forcegcperiod
    case gcTriggerCycle:
        return int32(t.n-work.cycles) > 0
    }
    return true
}
  1. gcTriggerHeap :堆内存的分配达到达控制器计算的触发堆大小;
  2. gcTriggerTime :如果一定时间内没有触发,就会触发新的循环,该出发条件由 runtime.forcegcperiod 变量控制,默认为 2 分钟;
  3. gcTriggerCycle:如果当前没有开启垃圾收集,则触发新的循环;
  4. runtime.gcpercent 是触发垃圾收集的内存增长百分比,默认情况下为 100,即堆内存相比上次垃圾收集增长 100% 时应该触发 GC,并行的垃圾收集器会在到达该目标前完成垃圾收集。

用于开启垃圾收集的方法 runtime.gcStart 会接收一个 runtime.gcTrigger 类型的结构,所有出现 runtime.gcTrigger 结构体的位置都是触发垃圾收集的代码:

更多参考:

垃圾收集器

GO GC 垃圾回收机制

搞懂Go垃圾回收

Go 语言 GC 机制 · Analyze

这个GC什么时候会被触发呢?

考点:GC细节

超超:触发GC有俩个条件,一是堆内存的分配达到控制器计算的触发堆大小,初始大小环境变量GOGC,之后堆内存达到上一次垃圾收集的 2 倍时才会触发GC。二是如果一定时间内没有触发,就会触发新的循环,该触发条件由runtime.forcegcperiod变量控制,默认为 2 分钟。

在完成三色标记后,可能用户程序在标记执行的过程中修改对象的指针,因为三色标记清除算法不支持并发和增量执行,所有需要STW。如果用户程序建立从A对象到D对象的引用,但程序中已经不存在灰色对象了,所有D对象会被垃圾收集器错误地回收。本来不应该被回收的对象被回收了,在内存管理中是非常严重的错误,这种错误称为悬挂指针,即指针没有指向特定类型的合法对象,影响内存安全性,想要并发或者增量地标记对象需要使用屏障技术。

然后介绍屏障技术

那么三色算法是如何解决STW问题的???

三色算法

同时引入了上文介绍的三色标记法,这种方法的mark操作是可以渐进执行的而不需每次都扫描整个内存空间,可以减少stop the world的时间

渐进式:
内存管理与用户程序的执行交织在一起,讲GC的代码分散到整个承诺需,不像传统的标记清除算法那样需要STW,

不过

相信很多人对垃圾收集器的印象都是暂停程序(Stop the world,STW),随着用户程序申请越来越多的内存,系统中的垃圾也逐渐增多;当程序的内存占用达到一定阈值时,整个应用程序就会全部暂停,垃圾收集器会扫描已经分配的所有对象并回收不再使用的内存空间,当这个过程结束后,用户程序才可以继续执行,Go 语言在早期也使用这种策略实现垃圾收集,但是今天的实现已经复杂了很多。

4. GC调优常见方法

  • 尽量使用小数据类型,比如使用int8代替int
  • 少使用+连接string:go语言中string是一个只读类型,针对string的每一个操作都会创建一个新的string。大量小文本拼接时优先使用strings.Join,大量大文本拼接时使用bytes.Buffer
上一篇
下一篇
Copyright © 2022 Egon的技术星球 egonlin.com 版权所有 帮助IT小伙伴学到真正的技术