聊一聊 GC 机制

2023年 10月 8日 45.9k 0

一 摘要

本文主要介绍下 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。
  • 当引用变量被销毁或者改变引用对象时,原先引用对象的计数减 1。
  • 当对象的引用计数变为 0 时,对象被回收。
  • 引用计数式 GC 的优点是实现简单,垃圾回收实时性强,不会造成程序暂停。但缺点也明显,它需要维护引用计数,存在一定的时间和空间开销,而且无法处理循环引用的问题,即两个或多个对象互相引用,但实际上已经不可能再被访问到。

    3.2 追踪式

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

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

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

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

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

    4.1 三色标记算法

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

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

    标记过程如下:

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

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

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

    4.2 屏障机制

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

    聊一聊 GC 机制-2

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

  • 赋值器「修改引用关系的用户代码」修改对象图,导致某一黑色对象引用白色对象
  • 从灰色对象出发,到达白色对象的、未经过访问过的路径被赋值器破环
  • 上面的 case 则同时满足了这两个条件「a: 将 A 指向 C,b: 从 B 到 C 的访问路径被破坏」,导致垃圾回收器错误的回收对象 C.

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

    插入屏障

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

    聊一聊 GC 机制-3

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

    聊一聊 GC 机制-4

    删除屏障

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

    聊一聊 GC 机制-5

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

    聊一聊 GC 机制-6

    混合屏障

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

  • GC 开始将栈上的对象全部标记为黑色
  • GC 期间,任何栈上新建的对象均为黑色
  • 被删除的对象标记为灰色
  • 被添加的对象标记为灰色
  • 4.3 整体流程

    whiteboard_exported_image.png

  • 清理终止阶段

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

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

  • 标记阶段

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

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

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

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

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

  • 标记终止阶段

    A. STW.

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

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

  • 清理阶段

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

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

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

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

  • 4.4 GC 触发时机

  • GOGC

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

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

  • 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 对程序的影响。

  • 手动触发

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

    五 GC 调优

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

    5.1 识别 GC 的开销

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

  • 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 过高。
  • 执行跟踪

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

  • 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 的次数。

      堆内存火焰图

    使用堆内存分析,可以详细看到哪里进行了内存分配,从而进行相关的优化。每个 profile 文件可以按照一下四种方式分析:

      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。

    具体使用可以参考: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…

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论