likes
comments
collection
share

聊一聊 GC 机制

作者站长头像
站长
· 阅读数 9

一 摘要

本文主要介绍下 GC 的基本概念、常见的 GC 算法、go 的 GC 过程,最后介绍一些 GC 优化的方法来提升服务性能。通过本文,读者可以对 go 的 GC 机制有一个大致的了解,针对 GC 问题有一定的解决思路。在业务开发中,能够及时发现由于 GC 导致的性能问题,并对其进行调优。

二 什么是 GC

GC:Garbage Collector「垃圾回收」,是一种自动的内存管理机制。

根据内存管理方式的不同,可以将编程语言分为手动内存管理「C、C++」和自动内存管理「Java、Python、Go」。在具有自动内存管理的语言中,开发者无需关注程序运行过程中的内存申请与回收,只需关注业务逻辑的实现即可。为了让程序实现自动的内存分配和回收,不同的语言都有一套属于自己的 GC 机制。

三 GC 算法

垃圾回收的主要目标是自动回收程序不再使用的内存空间,主要分为一下两种形式:

3.1 引用计数

跟踪每个对象被引用的次数,当一个对象的引用计数变为 0 时,表示这个对象不再被任何其他对象所引用,也就意味着程序将来不会再使用这个对象,因此这个对象就可以被当做垃圾回收了。

具体操作如下:

  1. 对象创建时,初始化引用计数为 1。
  2. 当对象被一个引用变量引用时,其引用计数加 1。
  3. 当引用变量被销毁或者改变引用对象时,原先引用对象的计数减 1。
  4. 当对象的引用计数变为 0 时,对象被回收。

引用计数式 GC 的优点是实现简单,垃圾回收实时性强,不会造成程序暂停。但缺点也明显,它需要维护引用计数,存在一定的时间和空间开销,而且无法处理循环引用的问题,即两个或多个对象互相引用,但实际上已经不可能再被访问到。

3.2 追踪式

追踪式垃圾回收(Tracing Garbage Collection)是一类基于"可达性"分析来进行内存回收的算法。这类算法的基本思想是:从一组根对象(通常是全局变量和当前执行的函数的局部变量)开始,通过跟踪对象引用关系,逐步找出所有能够被访问到的对象,这些对象被认为是"存活"的,而其他未被访问到的对象则被认为是垃圾,可以被回收。

追踪式垃圾回收的主要代表算法有:

  1. 标记-清除(Mark-Sweep) :分为标记和清除两个阶段。标记阶段从根对象开始,标记所有可达的对象;清除阶段遍历整个堆内存,回收未被标记的对象。这种方法的主要缺点是会产生大量内存碎片。主要适用于对象生命周期长,不需要被频繁回收的场景。
  2. 副本(Copying) :将内存分为两块,新分配的对象放在其中一块内存中,当这块内存满了,就从根对象开始,复制所有可达的对象到另一块内存中,然后回收原来的内存区域。这种方法的优点是避免了内存碎片,但是需要两倍的内存空间。主要适用于频繁分配和回收对象的场景,可以减少碎片的产生。
  3. 标记-压缩(Mark-Compact) :和标记-清除类似,但是在清除阶段,不直接回收未被标记的对象,而是将存活的对象压缩到内存的一端,然后再回收剩余的内存。这种方法避免了内存碎片,但是移动对象的成本较高。主要适用于内存空间有限且频繁分配和回收对象的场景。
  4. 分代(Generational) :根据对象的生命周期不同,将内存划分为新生代和老年代,新创建的对象放在新生代,经过一定次数垃圾回收后仍然存活的对象移动到老年代。新生代使用 Copying 算法,老年代使用 Mark-Sweep 或Mark-Compact 算法。这种方法的优点是可以针对不同代的特点采用最适合的算法,提高垃圾回收的效率。缺点就是管理复杂,需要合理划分不同代的大小以及不同代的晋升策略。
  5. 增量(Incremental)并发(Concurrent) :这是对上述算法的优化,目的是减少垃圾回收导致的程序暂停时间。增量垃圾回收是将垃圾回收的工作分解成多个小步骤,交错在程序运行中执行;并发垃圾回收则是让垃圾回收和程序运行在不同的线程中并发执行。

四 GC 流程

在 go 中使用的是无分代「不会进行分代管理」、不整理「不会对产生的碎片空间进行移动和整理」、并发「gc 程序与用户代码并发执行」的三色标记清楚算法。go 采取这种算法主要是出于以下考虑:

  • 在 go 中,基于 tcmalloc(Thread-Caching Malloc)进行内存管理,讲内存分为不同固定大小的块,分配内存时会优先寻找符合大小的块分配,从而减少了内存碎片的产生。
  • 在 go 中,使用的是值传递。这就意味着程序中频繁创建的非指针对象直接就会分配到栈中,函数执行完直接就回收栈空间,从而避免在堆上产生大量生命周期较短的小对象。此外,go 通过逃逸分析判断是否将对象分配到栈上,从而保证只有生命周期相对较长的对象才会分配到堆中。因此,无需对对象进行分代处理,只会增加相应的复杂度。
  • go 垃圾回收器的目标则是优化时延,尽可能保证与用户代码并发执行,从而减低 GC 带来的时延。

4.1 三色标记算法

在 go 中,垃圾回收采用的并发的标记清楚算法,又叫三色标记法。在三色标记法中将对象分为三类,并用不同的颜色相称:

  • 白色对象「可能死亡」:未被垃圾回收器访问到的对象。在回收未开始时,所有的对象均为白色。回收结束后,白色对象为不可达对象。
  • 灰色对象:已经被回收器访问到的对象,但回收器需要对其中的指针进行扫描,因为可能指向白色对象。
  • 黑色对象: 已经被回收器访问到的对象,且其中的所有字段都已经被扫描,黑色对象中的任何一个指针都不可能指向白色对象。

标记过程如下:

  1. 所有对象置为白色
  2. 从根对象出发扫描所有可达对象,标记为灰色,放入待处理队列
  3. 从待处理队列中取出灰色对象,将其引用对象标记为灰色并放入待处理队列中,自身标记为黑色
  4. 重复步骤 c, 指导待处理队列为空,此时白色对象为不可达对象,即需要被回收的对象。

聊一聊 GC 机制

根对象又叫根集合,是垃圾回收中最先检查的对象,主要包括:

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

4.2 屏障机制

在垃圾回收机制中,为了保证其准确性,通常需要一段时间暂停全部的用户代码,等待垃圾回收相关操作完成,将其称为 STW「Stop-The-Word」。在 go 中,为了将少 STW 的时间,采用了并发标记的方式,即标记与用户代码并发执行。这也引入一个新的问题,即并发标记过程中,用户代码更改对象引用的问题。如下图所示,当垃圾回收器对 B 进行扫描时,用户代码更新了对 C 的引用。最终标记完成后,C 为白色对象,需要被错误的清理。

聊一聊 GC 机制

可以证明,当以下两个条件同时被满足时会破坏垃圾回收器的正确性, 即用户代码导致白色对象无法被垃圾回收器扫描:

  1. 赋值器「修改引用关系的用户代码」修改对象图,导致某一黑色对象引用白色对象
  2. 从灰色对象出发,到达白色对象的、未经过访问过的路径被赋值器破环

上面的 case 则同时满足了这两个条件「a: 将 A 指向 C,b: 从 B 到 C 的访问路径被破坏」,导致垃圾回收器错误的回收对象 C.

对于上面的两个条件,只要避免其中的一条,就可以保证算法的正确性。为保证标记结果的正确性,需要通过赋值器屏障技术来保证指针读写的一致性。本质上就是在赋值器更新引用时,能够“通知”回收器,让回收器根据引用关系的变更来更新对象颜色。

插入屏障

核心思想是破坏条件 a,即避免黑色对象直接引用白色对象。当插入的对象时白色时,就将其标记为灰色。它保证了所有可疑对象都是灰色的,且避免黑色对象直接引用白色对象。如下图所示,当更新 A -> C 的引用时,则将 C 标记为灰色,最终标记为黑色。

聊一聊 GC 机制

插入屏障的优点是可以保证标记算法和用户代码的并发执行。缺点是需要回收的对象可能会被标记会黑色「如下图流程中,当 A->C 的引用被用户代码删除,这是 C 已经被标记为黑色,即不可回收」,需要下一次 GC 才能进行回收。另一方面,每次的插入操作都会引入写屏障,增加了性能开销。为了减少性能开销,Go 在具体实现时,并没有对针对栈上的指针写操作开启写屏障。因此,在并发标记结束时,需要执行 STW,重新对栈空间进行三色标记。

聊一聊 GC 机制

删除屏障

核心思想是破环条件 b,即保证灰色对象对白色对象路径的完整性。当发生引用删除时,如果该对象是白色,则把它标记为灰色。如下图所示,当 B->C 的引用被删除时,将 C 标记为灰色。

聊一聊 GC 机制

删除屏障的优势在于无需对栈对象重新进行扫描,结束之后可以直接对白色对象进行回收;缺点在于会产生冗余的扫描。如下图,当删除 C 时,仍会对 C 进行扫描,且下次 GC 才能将 C 清除。

聊一聊 GC 机制

混合屏障

插入屏障和删除屏障进行混合,尽可能减少 STW 的时间。具体操作如下,详细流程参考:

  1. GC 开始将栈上的对象全部标记为黑色
  2. GC 期间,任何栈上新建的对象均为黑色
  3. 被删除的对象标记为灰色
  4. 被添加的对象标记为灰色

4.3 整体流程

聊一聊 GC 机制

  1. 清理终止阶段

    A. STW。所有的 goroutine 进入安全点「计算机程序中的一个特定位置,通常是一些指令之后,这个位置被认为是"安全的",在此点上,垃圾回收器可以安全地停止该线程,并进行垃圾回收或其他的系统级操作。」

    B. 如果是强制进入的 GC「显性掉用 GC 方法」,则需要先清理未被处理的内存管理单元。

  2. 标记阶段

    A. 将状态切换至 _GCmark,开启写屏障、用户程序协助、根对象如队列。

    B. 恢复程序执行。标记程序和用于协助的用户程序会开始并发标记内存中的对象,写屏障会将被覆盖的指针和新指针都标记成灰色,而所有新创建的对象都会被直接标记成黑色。

    C. 开始根节点标记工作。扫描所有的栈空间,全局变量,堆外运行「不属于 go 垃圾收集器管理的内存」时数据结构中的任何堆指针。每扫描一个 goroutine 栈时,都会先停止此 goroutine,扫描完成后重新启动 goroutine。

    D. 依次处理灰色队列中的对象,将对象标记成黑色,并将它们指向的对象标记成灰色,放回待处理队列

    E. 使用分布式终止算法检查所有的标记工作是否已经完成,进入标记终止阶段。

  3. 标记终止阶段

    A. STW.

    B. 将状态切换至 _GCmarktermination 并关闭辅助标记的用户程序

    C. 清理处理器上的线程缓存

  4. 清理阶段

    A. 将状态设置为 _GCoff,初始化清理状态并关闭写屏障

    B. 恢复用户程序,所有新创建的对象标记为白色

    C. 后台使用较少的 CPU 并发清理内存单元,当 Goroutine 申请新的内存时也会触发清理

  5. 当分配了大量的内存,开始进入步骤 1

4.4 GC 触发时机

  1. GOGC

GOGC 是一个环境变量,用于控制 GC 频率。GOGC 的值越大,GC 频率越小,反之亦然。当然,也可以通过代码 debug.SetGCPercent(100),显性的控制 GOGC 的值。根据 GOGC 的值来计算下次触发 GC 的时机,其计算公式如下。即当程序的内存达到 Target heap memory 时会触发下次 GC。

聊一聊 GC 机制

举一个具体的例子,当前 GOGC 的值为 100「默认值」,全局变量有 1MB,程序内存达到 4MB「默认值」 时开始第一次 GC;GC 结束后存活的内存还有 3MB「Live heap」,goroutine 栈有 1MB;当程序内存到达 8 MB 「3MB + (3MB + 1MB + 1MB) * 100 / 100」时,触发第二次 GC。

  1. Memory Limit

在上面的例子中,假设操作系统只给当前程序分配了 7MB 内存「触发 GC 需要达到 8MB」,那将会导致内存空间不足的问题。因此,在 go 1.19 中,支持在程序运行时设置内存限制,也通过 GOMEMLIMIT 环境变量进行控制,或者使用代码 debug.SetMemoryLimit(1024 * 1024 * 7),它规定了程序可以使用的总内存。当程序使用的内存达到设置的内存限制则会开始触发 GC。

可以通过调整 GOGC 和 Mem Limit 的值,来保证一个合理的 GC 频率。当内存使用没有超出系统分配的阈值时,通过 GOGC 参数来控制 GC 的步长;当申请阈值到达 Mem Limit 时,直接触发 GC,避免无法从操作系统中申请到内存。

为了避免开发者将 Mem Limit 设置的过小、或者程序有一些峰值导致的频繁到达 Mem Limit 阈值而导致的频繁 GC。垃圾回收器并不会严格的按照 Mem Limit 触发 GC,在具体实现时,程序本身会考虑垃圾回收器使用的 CPU 时间,如果垃圾回收器占用的 CPU 时间超过 50%,程序仍然会继续分配内存,降低 GC 对程序的影响。

  1. 手动触发

go 也给开发者提供了显性触发 GC 的接口,当掉用 GC 方法runtime.GC()时,会阻塞当前任务,直到 GC 工作完成。其实,大多数场景下开发者均不需要显性的触发 GC,直接交给垃圾回收器即可。然后,对于一些内存密集型操作,由于内存资源有限,需要及时释放内存来完成后续的工作。这时可以手动触发 GC。

聊一聊 GC 机制

五 GC 调优

在程序世界中,没有银弹。开发者体验垃圾回收机制便利的同时,也需要忍受垃圾回收带来的副作用。那就是内存不能及时回收导致内存占用过高,以及垃圾回收时带来的性能影响。因此,需要通过一些手段让程序能够在有限的资源内发挥最大的性能。常见的手段包括增加硬件设置「cpu、mem」、合理设置 GOGC 和 Mem Limit、减少程序的内存分配等。

5.1 识别 GC 的开销

在开始 GC 调优之前,需要先确定 GC 是否已经给业务程序带来了显著的影响。否则,就会陷入过度优化的泥沼。可以通过一下几种方式来判断 GC 是否已经占用了过多资源,影响到了业务程序。

  1. CPU 火焰图

通过 CPU 火焰图可以直观的直到 CPU 时间都花费在什么地方。查看火焰图时,如果有下面的函数占用 CPU 时间过高,则意味着 GC 耗费了太多的 CPU。

  • runtime.gcBgMarkWorker: 标记工作的入口点。花费的时间取决于 GC 的频率和对象图的规模。它代表了程序在标记扫描阶段花费时间的基准。
  • runtime.mallocgc: 堆内存分配的入口点。如果函数占用过多的 cpu 时间「>15%」,则意味着程序大量的分配内存,需要看下是否能够进行优化。
  • runtime.gcAssitAlloc: 当部分 goroutine 协助 GC 进行扫描和标记时会执行此函数。大量的 cpu 时间「>5%」花费在这个函数则意味着 GC 回收的速度已经跟不上内存分配的速度,因此需要其他 goroutine 协助进行标记扫描。此函数被 runtime.mallocgc 的调用树所包含,从而导致 runtime.mallogc 占用 cpu 过高。
  1. 执行跟踪

CPU 火焰图只是抽样聚合了不同函数占用的 CPU 时间,无法进行深入观察性能消耗。例如,堆内存增长情况、GC 的次数、持续时长等信息、goroutine 的创建、阻塞等信息。执行跟踪的具体用法参考:execution trace

  1. GC 跟踪

如果 CPU 火焰图和执行跟踪均无法发现问题,go 提供一些特定的跟踪用于深入观察 GC 的行为。这些追踪可以将 GC 的详细信息直接输入到 STDERR 中。它们主要用于调试 GC 机制本身,因此需要对 GC 算法的实现细节有一定了解。淡然,也可以用于了解程序本身的 GC 行为。

通过设置环境变量 GODEBUG=gctrace=1。可以输出 GC 对应的详细信息,具体文档参考:environment_variables。更深入的信息可以通过设置 GODEBUG=gcpacertrace=1 查看,这里就需要对 GC 实现细节有深入的了解:gc pacer

5.2 减少堆内存分配

GC 的本质是内存资源有限,需要回收内存保证程序的执行。因此可以尽量避免堆内存的分配,让程序使用较少的内存,从而降低 GC 的次数。

  堆内存火焰图

  inuse_objects: 活动对象数量

  inuse_space: 活动对象使用的内存「byte」

  alloc_objecs: 程序开始执行以来分配的对象总数

  alloc_space: 程序开始执行以来分配的内存总量

为了降低 GC 开销,则需要重点关注 alloc_space,它表示了内存分配率。可以通过优化此指标来降低 GC 执行的频率。

  逃逸分析

通过堆内存的 profile 可以看到内存上分配的对象,那如何避免分配到堆上呢?可以通过 Go 编译器进行逃逸分析,使用更有效的方式分配内存,例如分配到 goroutine 栈上。通过逃逸分析,可以知道为什么对应的对象会分配到堆上。有了这些信息之后,就可以重新组织源代码避免分配到堆上「超出本文范围」。

通过下面的命令,可以直接将逃逸分析的结果进行输出:$ go build -gcflags=-m=3 [package]

5.3 动态 GC

在上面我们了解到,通过设置 GOGC 和 Memory Limit 来调整 GC 触发的时机。如果相关参数设置的不合理,会导致频繁触发 GC,造成不必要的性能开销。在生产环境,一般会使用 docker, 为其分配 4GB 内存,在默认情况下触发 GC 的频率可能是 4MB => 8MB => 16MB,但完全可以到 3GB 时触发 GC。因此,可以使用动态设置 GOGC 的方式,调整 GC 触发时机。核心原则:使用内存未达到 threshold 时,尽可能大的设置 GCPercent;超过时,尽可能小的设置 GCPercent。

聊一聊 GC 机制

具体使用可以参考:gctuner

5.4 基于 GC 实现的优化

除了优化内存分配,降低 GC 执行次数之外。还可以基于 GC 的实现,通过优化结构体,降低 GC 执行的时间。在 go 的 GC 实现中,可以通过一下方式进行优化「一下优化可能会降低代码的可读性,并且随着 go 版本的更新可能会失效。因此,只有在对 GC 开销要求极高的地方使用」:

  • 无指针值和其他值分开

在程序中,对于一些结构体,如果非必要可以尽可能的使用值而不是指针,可以减少 GC 扫描的压力。当然,这种优化只有在对象图复杂、垃圾回收器在标记和扫描上花费大量的情况下收益比较明显。

  • GC 将在值的最后一个指针处停止扫描

可以将结构体中的指针子弹分组在值的开头,这样就无需扫描结构体的全部字段。 「理论上,编译器可以自动执行此操作,但尚未实现,并且结构字段的排列方式与源代码中所写的相同。」

此外,GC 必须与它所看到的几乎每个指针交互。因此,使用切片中的索引而不是指针,也可以帮助降低GC成本。

六 参考资料

tip.golang.org/doc/gc-guid…

golang.design/go-question…

转载自:https://juejin.cn/post/7287246372055629880
评论
请登录