Go 语言(Golang)作为一款内置运行时的现代编程语言,其垃圾回收(Garbage Collection, GC)机制是开发者理解其性能和行为的关键一环。要深入理解 Go 的 GC,我们首先需要明确垃圾回收的核心任务是什么,以及它在设计上需要面对哪些权衡与博弈。
在主流的编程语言内存模型中,程序运行时使用到的内存通常可以划分为几个区域,其中最主要的是静态数据区、栈(stack)和堆(heap)。 栈内存 的管理相对简单:当一个函数被调用时,系统会为其分配一个栈帧(stack frame),用于存储局部变量、参数以及函数返回地址等信息;当函数执行完毕并返回时,其对应的栈帧会被自动销毁,所占用的内存也随之释放。这种后进先出(LIFO)的管理方式使得栈上数据的生命周期与函数调用周期紧密绑定,无需开发者手动干预。
然而,并非所有数据都适合存放在栈上。对于那些需要在函数调用结束后依然存在,或者大小在编译期难以确定的数据,通常会在 堆内存 中分配。对于像 C/C++ 这类没有内置垃圾回收机制的语言,开发者需要显式地使用如 malloc 这样的函数向操作系统申请堆内存,并且在不再需要时通过 free 函数手动释放。如果忘记释放,就会导致 内存泄漏 (memory leak),即程序持续占用不再使用的内存,最终可能耗尽系统资源。反之,如果在内存释放后继续尝试访问它(悬垂指针),则可能导致程序崩溃或未定义行为,即 释放后使用 (use-after-free)错误。
在 Go 语言中,开发者通常不需要像 C/C++ 那样显式地“申请”和“释放”内存来管理对象的生命周期,尽管 Go 提供了如 new 关键字(用于分配零值的内存并返回指针)和 make 函数(用于初始化切片、映射和通道等内建类型)来进行内存分配。当这些分配发生在堆上,或者当变量因为 逃逸分析 (escape analysis)——一种编译器优化技术,用于决定变量是分配在栈上还是堆上——而被分配到堆上时,这些对象的生命周期管理便不再由栈的自动机制控制。
这些脱离了栈作用域控制的堆上对象,其内存何时以及如何被回收,就成了 GC 的核心职责。一个优秀的 GC 机制需要在多个维度上进行优化:
- 高效检测 :如何快速准确地识别哪些内存是不再被使用的“垃圾”?
- 回收时机与频率 :过于频繁的垃圾回收会中断业务逻辑的执行,影响程序性能(吞吐量和延迟);而不频繁的回收则可能导致不再使用的内存长时间累积,造成内存膨胀,甚至引发内存溢出(Out Of Memory, OOM)。
- 内存碎片 (memory fragmentation):反复分配和释放不同大小的内存块可能导致堆中出现许多不连续的小块空闲内存,这些碎片虽然总量可能不少,但难以满足较大的内存分配请求。如何减少内存碎片,提高内存利用率,也是 GC 需要考量的。
本文将围绕 Go 语言的 GC 设计展开讨论,力求在过程中逐步厘清上述问题,并理解 Go 如何在这些挑战中取得平衡。
Go GC 总体设计
通用的垃圾回收原理主要可以归纳为两大类:
第一类是基于 引用计数 (reference counting)的机制。这种方法为每个对象维护一个计数器,记录有多少个引用指向该对象。当一个新的引用指向对象时,计数器加一;当一个引用被移除时,计数器减一。一旦对象的引用计数变为零,就表明该对象不再被任何部分使用,可以立即被回收。Python 语言的 GC 机制中就包含了引用计数,C++ 的智能指针 std::shared_ptr 也是基于这一原理。引用计数的主要优点是对象可以在不再被引用的那一刻立即被回收,内存管理及时。然而,它也存在一些显著的缺点:一是频繁更新引用计数会带来额外的运行时开销;二是它难以处理 循环引用 (circular references)的问题,即一组对象互相引用,即使它们整体已经与程序的其他部分隔离(即成为垃圾),但它们的引用计数都大于零,导致无法被回收。为了解决循环引用,通常需要引入额外的检测机制,如 C++ 中的 std::weak_ptr(弱指针)。
第二类是基于 可达性分析 (reachability analysis)的追踪式垃圾回收(tracing garbage collection)。这类 GC 的核心思想是“可达性即存活性”:程序从一组固定的 根对象 (roots)——通常包括全局变量、当前活跃的函数调用栈上的局部变量和参数,以及 CPU 寄存器中的指针等——开始,沿着指针引用关系遍历内存中的对象图。所有从根对象出发能够访问到的对象都被认为是“活”对象,其余未被访问到的对象则被视为“垃圾”,可以被回收。Go 语言的 GC 正是采用了基于可达性分析的并发标记清扫(concurrent mark-and-sweep)算法。
那么,Go 语言是如何基于可达性分析的假设,高效且正确地标记并清扫不再使用的内存呢?这需要我们先了解一些 Go 内存管理和 GC 相关的基础概念。
在进一步探讨 Go GC 的具体流程和技术细节之前(假设读者对 Go 的 GPM 调度模型已有基本了解),我们先介绍一些核心的术语和组件:
- 页(page) :在 Go 的内存管理中,操作系统级别的内存页(通常为 4KB 或 8KB)是内存分配的最小单位之一。Go 的运行时会向操作系统申请大块内存,然后将这些大块内存划分为更小的、固定大小的页进行内部管理。Go 自身的内存管理系统通常使用 8KB 大小的页。
- span (mspan) :mspan 是 Go 内存管理的核心数据结构,代表了一段连续的页。一个 mspan 可以用来存储特定 大小类 (size class)的多个小对象,或者一个单独的大对象。mspan 内部维护了关于其所含对象的重要元数据,例如对象的分配状态、存活状态(用于 GC 标记)等。
- 大小类(size class) :为了高效管理不同大小的对象分配并减少内部碎片,Go 将小对象(通常指小于 32KB 的对象)归入不同的固定大小等级,即大小类。每个 mspan 通常服务于一种特定的大小类,这意味着它内部所有可分配的槽位(slot)都是同样大小的。
- mcache :mcache 是一个与每个 P(Processor,对应于 GPM 模型中的 P,代表一个逻辑处理器,用于执行 goroutine)相关联的本地内存缓存。它为当前 P 上运行的 goroutine 提供快速的小对象分配服务。mcache 中包含各种大小类的 mspan 列表,由于是 P 本地缓存,从 mcache 分配内存通常不需要加锁,极大地提高了并发分配的效率。
- mcentral :mcentral 是一个全局的、按大小类组织的数据结构。每个大小类都有一个对应的 mcentral 实例。当某个 P 的 mcache 中缺少特定大小类的 mspan 时,它会向相应的 mcentral 请求。反之,如果 mcache 中有多余的空闲 mspan,也会归还给 mcentral。mcentral 起到了在不同 P 之间平衡 mspan 资源的作用,它也需要处理锁来保证并发安全。
- mheap :mheap 是 Go 程序的全局堆内存管理器。它持有所有从操作系统申请到的内存(这些大块内存被称为 arenas ,例如在 64 位系统上一个 arena 可能是 64MB),并将这些内存切分成 mspan 分配给各个 mcentral。mheap 负责管理堆的整体大小,决定何时向操作系统申请更多内存或何时将未使用的内存归还给操作系统。所有的大对象(大于 32KB)直接由 mheap 分配。
- 根对象(root object) :如前所述,根对象是 GC 开始追踪对象可达性的起点。在 Go 中,这主要包括全局变量区域中的指针、每个 goroutine 栈(goroutine stack)上指向堆对象的指针,以及一些运行时内部结构的指针。
- 存活堆(live heap) :指在一轮 GC 标记阶段结束后,被确认为仍然存活(即从根对象可达)的所有堆对象的总大小。
- 堆目标(heapGoal) :heapGoal 是一个动态计算的目标堆大小。当实际的堆内存占用达到或超过 heapGoal 时,就会触发新一轮的 GC。它的计算通常基于上一轮 GC 结束后的存活堆大小以及一个由环境变量 GOGC(默认为 100)控制的百分比,公式为 heapGoal = liveHeap * (1 + GOGC/100)。
基于上述概念,Go 的一轮并发标记清扫 GC 大致可以分为以下几个主要阶段:
首先是 标记准备(Mark Setup) 阶段。这是一个短暂的 STW (Stop The World)过程,意味着所有用户 goroutine 都会被暂停。在此期间,GC 会启用 写屏障 (write barrier)——一种在并发标记期间确保数据一致性的关键机制,我们稍后会详细讨论。同时,还会进行一些必要的初始化工作,为接下来的并发标记做准备。
接下来是 并发标记(Concurrent Marking) 阶段。在这个阶段,用户 goroutine 恢复运行,与 GC 的标记工作并行执行。GC 会从根对象开始,递归地遍历所有可达的对象图。如果一个对象是可达的,它会被标记。为了提高效率,Go 语言的 goroutine 在进行内存分配时,也可能会被要求辅助 GC 执行一部分标记工作(这被称为 标记辅助 ,mark assist)。写屏障在这一阶段持续工作,以正确处理用户 goroutine 在标记过程中对指针的修改。在三色标记法中,对象会从初始的“白色”(未访问)变为“灰色”(已发现但其引用的对象尚未完全扫描),最终变为“黑色”(已扫描且其所有引用的对象也已扫描或加入扫描队列)。
标记工作基本完成后,进入 标记终止(Mark Termination) 阶段。这同样是一个 STW 阶段,所有用户 goroutine 再次暂停。GC 会完成所有剩余的标记工作,例如处理一些在并发标记期间被写屏障记录下来的指针修改,确保所有存活对象都被正确标记。在此之后,所有未被标记(即仍为“白色”)的对象都被认为是垃圾。
最后是 并发清扫(Concurrent Sweeping) 阶段。用户 goroutine 恢复运行。GC 会在后台或者在用户 goroutine 尝试分配新内存时,逐步回收那些在标记阶段被识别为垃圾(白色)的对象所占用的内存空间,将其管理的 mspan 中的对应槽位重新标记为空闲,以便后续分配使用。如果一个 mspan 中的所有对象都被回收,那么整个 mspan 就可以被归还给 mheap,用于其他目的,甚至可能被归还给操作系统。
值得注意的是,Go 的 GC 设计目标之一是尽可能缩短 STW 的时间,将大部分工作并发化,以减少对应用程序延迟的影响。关于 STW 的具体细节和其必要性,我们将在后续章节中进一步讨论。
三色标记法
为了在并发环境中准确地识别存活对象,Go 的 GC 采用了 三色标记法(Tri-color Marking Algorithm)。这个算法将堆中的对象逻辑上分为三种颜色:
- 白色(White) :对象初始状态,表示尚未被 GC 访问到。在一轮 GC 结束时,所有仍然是白色的对象都被认为是垃圾,将被回收。
- 灰色(Gray) :对象已被 GC 发现(即从根可达),但其内部的指针(即它所引用的其他对象)尚未被完全扫描。灰色对象被视为一个临界状态,它们存在于一个待处理的工作队列中。
- 黑色(Black) :对象已被 GC 发现,并且其内部所有指针都已经被扫描完毕(即它引用的对象要么也变成了灰色或黑色,要么是 nil)。黑色对象表示 GC 已经处理完毕,并且在当前 GC 周期内是存活的。
垃圾回收进行“染色”(标记)时,其标记信息(例如三色标记法中的颜色)是针对 mspan 中具体的对象或对象槽位(slot)的。 对于包含小对象的 mspan,它内部有一个位图(bitmap)或者类似的数据结构,用于记录每个小对象的标记状态。因此,虽然 mspan 是页的集合,但 GC 标记的精度是对象级别的,这些标记信息存储在 mspan 的元数据中。
GC 的标记过程可以想象成一个从根对象开始向外“染色”的过程:
- 初始时,所有对象(除了少量特殊对象)都被认为是白色的。
- GC 从所有根对象开始,将它们标记为灰色,并放入一个待扫描的灰色对象集合(工作队列)中。
- GC 从灰色对象集合中取出一个灰色对象,扫描它引用的所有其他对象:
- 对于每一个它引用的白色对象,将其标记为灰色,并放入灰色对象集合中。
- 当这个灰色对象的所有引用都被扫描完毕后,该对象自身被标记为黑色。
- 重复步骤 3,直到灰色对象集合为空。
- 此时,所有仍然是白色的对象就是不可达的垃圾,可以被回收。
那么,为什么需要三种颜色而不是简单的两种(例如,白色和黑色)呢?在非并发的 GC 中,两种颜色确实足够。但在并发 GC 中,应用程序的 赋值器 (mutator,即用户 goroutine)会与 GC 的 收集器 (collector)同时运行。我们需要灰色这个中间状态配合后问提到的写屏障来保证正确性。
为了防止这种“丢失对象”的情况,三色标记法引入了灰色状态,并配合 写屏障 (write barrier)来共同维护一个关键的不变性。这个不变性通常是: 不允许黑色对象直接指向白色对象,除非该白色对象能够通过其他灰色对象间接可达,或者该白色对象本身即将被标记为灰色。 更严格地说,Go 的写屏障努力确保在并发标记期间,不会出现一个黑色对象直接指向一个白色对象,而这个白色对象又没有其他路径可以被灰色对象发现。
如果赋值器试图创建一个从黑色对象指向白色对象的指针,写屏障就会介入。例如,当执行 objBlack.field = objWhite 这样的操作时,写屏障会确保 objWhite(或与其相关的对象)被标记为灰色,从而保证它不会被遗漏。例如,在一个扫描顺序可能导致问题的场景中:GC 线程扫描了对象 X 并将其标记为黑色。随后,用户 goroutine 修改了 X 的一个字段,使其指向了一个白色对象 Y。如果没有写屏障,Y 可能永远不会被扫描。有了写屏障,这次写入操作会被拦截,写屏障会将 Y 标记为灰色,放入待处理队列,确保其后续会被扫描。
Go 的写屏障主要有两种形式(或其变种/组合): 插入写屏障 (insertion write barrier)和 删除写屏障 (deletion write barrier),它们共同服务于维护上述三色不变性。
删除写屏障、插入写屏障以及混合屏障
写屏障是 Go 并发 GC 的核心机制之一,它通过在指针写入操作前后插入少量额外代码来实现。当用户 goroutine(赋值器)修改堆上对象的指针字段时,这些由编译器自动插入的屏障代码会被执行,以通知 GC 发生了可能影响对象可达性的变化。
插入写屏障(Dijkstra-style Insertion Write Barrier)
插入写屏障的核心思想是:当一个指针从对象 A 指向对象 B 时(A.ptr = B),如果对象 B 是白色的,那么写屏障会强制将对象 B 标记为灰色。这样做的目的是防止一个已经被扫描过的黑色对象(比如 A,如果它已经是黑色的)指向一个尚未被发现的白色对象 B,从而避免 B 被遗漏。
具体来说,当赋值器执行 *slot = ptr (其中 slot 是堆上一个指针的地址,ptr 是新的指针值)时:
- 如果 ptr 指向的对象是白色的,则将其标记为灰色并加入 GC 的工作队列。
- 然后才实际执行 *slot = ptr 的赋值。
这种屏障确保了所有新建立的引用,如果指向的是白色对象,那么该白色对象都会被“保护”起来(变为灰色),等待 GC 的后续处理。Go 在早期版本中主要依赖这种类型的屏障。它的优点是实现相对简单,能够保证正确性(不丢失存活对象)。但它可能导致一些已经死亡的对象(在被标记为灰色后,又因为其他引用的断开而变得不可达)仍然被当作存活对象处理,直到下一轮 GC,这被称为“浮动垃圾”(floating garbage)。
删除写屏障(Yuasa-style Deletion Write Barrier)
删除写屏障关注的是被覆盖的旧指针。当一个指针字段 A.ptr 原本指向对象 B,现在要改为指向对象 C(或者 nil)时(oldVal = A.ptr; A.ptr = C),删除写屏障会在赋值发生 之前 对 oldVal(即 B)进行处理。如果 oldVal 指向的对象是白色的,并且它可能因为这次引用断开而失去唯一的灰色或黑色先行者,那么删除写屏障会将 oldVal 指向的对象标记为灰色。
这种屏障主要用于增量式或并发 GC 中,以确保在并发删除引用时,不会意外地使一个本应存活的对象(因为其他路径仍然存在,只是 GC 尚未扫描到)变成不可达。它在处理并发场景下对象图的动态变化时,能够提供更强的保障,有助于提高回收的精度。
混合写屏障(Hybrid Write Barrier)
Go 从 1.8 版本开始引入了一种 混合写屏障 (hybrid write barrier)。这种屏障结合了插入写屏障和删除写屏障的特性,其主要目的是 消除在标记终止(Mark Termination)STW 阶段对所有 goroutine 栈进行重新扫描的需求,从而显著缩短 STW 的时间。
混合写屏障的工作方式大致如下:
- 堆指针写入 :当向堆对象的指针字段写入新值时(*slot = ptr),屏障会先将 *slot 原本指向的对象(如果它是白色的)标记为灰色。这类似于删除写屏障的思路,即保护即将被覆盖的指针所指向的对象。然后才执行指针的写入。
- 栈指针写入:栈上的指针写入不使用这种复杂的屏障,因为栈会在标记阶段被特殊处理(例如,初始扫描和可能的STW期间的最终处理)。
混合写屏障的核心保证是:任何在并发标记期间被黑色对象引用的白色对象,都会通过某种方式被 GC 发现。具体来说:
- 如果一个黑色对象在堆上创建了一个指向白色对象的指针,那么被该指针槽位 覆盖 的旧对象(如果是白色)会被着色为灰色。新指向的白色对象 ptr 如果没有其他路径,其可达性依赖于其宿主对象(即包含 slot 的对象)的状态。如果宿主是黑色,且 ptr 是白色,这个场景正是写屏障要处理的。混合写屏障通过“保护旧值”的方式,间接保证了当宿主对象被扫描时,即使新值 ptr 是白色,也不会立即丢失。
- 栈上的指针变化由栈扫描覆盖(主要是在 GC 开始时的 标记准备 STW 阶段 ,会对所有 goroutine 的栈进行扫描,这是识别从栈出发的根引用的必要步骤);在 标记终止 STW 阶段 ,不再需要对所有 goroutine 的栈进行第二次完整的、地毯式的扫描。
这其中,最核心的原因是 混合写屏障加强了对从栈流向堆的指针的追踪能力 。过去,如果一个指针只存在于栈上,并且在并发标记期间栈发生了复杂的变化,GC 可能会“跟丢”这个指针。引入混合写屏障后,如果这个指针要“逃逸”到堆上并可能导致问题(比如被一个黑色堆对象引用),混合写屏障会介入。
因此,不再需要通过一个全局的、重量级的“所有栈都停下来重新彻底扫描一遍”的操作来“查漏补缺”。系统相信,通过初始的栈扫描,结合并发标记期间混合写屏障在堆上的工作,以及运行时对 goroutine 栈的常规管理,已经足以保证所有存活对象都能被找到。
通过这种设计,Go 的混合写屏障确保了在并发标记期间,即使赋值器在堆上创建了从黑色对象到白色对象的引用,或者删除了这样的引用,三色不变性也能被维护,而无需在标记结束时进行昂贵的完整栈重扫。这使得 Go 的 GC 停顿时间,特别是标记终止阶段的 STW,能够控制在非常低的水平。
STW, Stop the World
现在我们对 Go GC 的流程和写屏障有了更深入的理解,可以再次审视 STW(Stop The World) 在 GC 周期中的角色、发生的时机以及其必要性。
Go 的 GC 周期中包含两次主要的 STW 停顿:
标记准备阶段(Mark Setup)的 STW
- 何时发生 :在 GC 周期开始时。
- 为何需要 :此阶段非常短暂。它的主要任务是启用写屏障,并准备 GC 所需的各种数据结构。暂停所有 P(逻辑处理器)和用户 goroutine 是为了确保在一个一致的程序状态下安全地开启写屏障。如果在此期间用户 goroutine 仍在运行并修改指针,那么写屏障的启用过程可能会出现竞态条件,或者遗漏在屏障完全生效前发生的指针修改。此外,此阶段还会进行一些根对象的初始扫描准备工作,比如扫描全局变量和准备扫描 goroutine 栈(现代 Go GC 对栈的初始处理更轻量)。
- 如果不 STW 会怎样 :无法保证写屏障的一致启用,可能导致在并发标记初期就丢失对象。根集合的快照也可能不准确。
标记终止阶段(Mark Termination)的 STW
- 何时发生 :在并发标记工作基本完成之后,清扫开始之前。
- 为何需要 :此阶段用于完成所有并发标记的收尾工作。例如,处理在并发标记期间由写屏障记录下来的、需要进一步检查的指针。在引入混合写屏障之前,这个阶段一个非常重要的任务是重新扫描所有 goroutine 的栈,因为在并发标记期间,栈上的指针可能发生了变化,而这些变化可能没有被写屏障完全覆盖(早期写屏障主要针对堆指针)。引入混合写屏障后,栈重扫的负担大大减轻,甚至在很多情况下被消除了,使得这个 STW 时间显著缩短。此 STW 确保了在进入清扫阶段之前,所有存活对象都已被正确标记为黑色,所有垃圾对象都保持白色。这也是一个同步点,确保所有 GC 工作线程和辅助标记的 goroutine 都已完成其标记任务。
- 如果不 STW 会怎样 :如果并发标记结束后不进行最终的同步和检查,可能会有少量本应存活的对象因为并发修改而未被标记,导致被错误回收。或者,某些由写屏障延迟处理的任务没有完成,标记结果不完整。
为什么需要两次 STW,而不是一次长时间的 STW?
如果 GC 从头到尾都是 STW,那么应用程序的响应会受到极大影响,长时间的停顿对于许多在线服务是不可接受的。Go GC 的设计哲学是将尽可能多的工作并发化,只在绝对必要的同步点进行短暂的 STW。这两次 STW 将整个 GC 周期划分为几个阶段,使得主要的标记工作(最耗时)可以与用户 goroutine 并行执行。
GC Pacer 与 heapGoal
Go 的 GC 触发和步调是由一个称为 Pacer 的机制来控制的。Pacer 的目标是根据 GOGC 环境变量(默认为 100)设定的比率来决定何时启动下一轮 GC。GOGC=100 意味着当堆大小增长到上一次 GC 结束后存活堆大小的两倍时,就应该触发新的 GC。计算公式为:
heapGoal = heap_live * (1 + GOGC/100)
其中 heap_live 是上一轮 GC 结束时测得的存活对象总大小。Pacer 会监控当前的堆分配情况,并在接近 heapGoal 时启动 GC,力求平滑地完成回收任务,避免突兀的性能抖动。
GC 工作的公平性与标记辅助
为了确保 GC 工作能够及时完成,尤其是在分配速率非常高的 goroutine 存在的情况下,Go 引入了 标记辅助(Mark Assist) 机制。当一个用户 goroutine 尝试在堆上分配新内存时,如果此时 GC 的标记阶段正在进行中,并且 GC 的进度落后于预期的步调(即分配速度超过了 GC 标记的速度),那么这个正在分配内存的 goroutine 会被要求“帮助”GC 完成一部分标记工作,然后才能继续其自身的内存分配。这种机制确保了所有 goroutine 都为 GC 贡献力量,防止某些高分配率的 goroutine “饿死”GC 或导致堆内存失控增长。这不是严格意义上 P 之间的公平性,而是确保分配者也承担 GC 责任,从而间接促进整体进度。GC 的后台标记任务则由专门的 GC worker goroutine(通常占 GOMAXPROCS 的 25%)执行,它们之间也存在工作窃取机制以平衡负载。
更加细致的 GC 流程
为了更清晰地理解 Go 的垃圾回收过程,我们将其细化为一系列明确的阶段和状态。Go 的运行时内部使用如 _GCoff、_GCmark、_GCmarktermination 等状态来管理 GC 周期。以下是一轮典型 GC 周期的详细流程:- **当前状态: _GCoff (GC 关闭)**
- - 描述: GC 当前未激活。应用程序 (mutators) 正常运行。
- - 内存分配: 新分配的对象被标记为白色。
- - 后台清扫: 上一个 GC 周期的清扫工作可能仍在并发进行中 (由 gcBgMarkWorker 或按需触发)。
- 一个 mspan 必须先被清扫干净 (即回收其中上一周期标记为白色的对象),其空闲槽位才能用于新的分配。
- 一旦一个 mspan 被清扫过,在本轮 GC 的后续扫描标记完成前,它不会被再次清扫。
- - 触发条件: 当堆分配的总大小达到根据 GOGC 计算出的 heapGoal 时,准备启动新一轮 GC。
- ▼ ▼ ▼ GC 触发 ▼ ▼ ▼
- **阶段 1: 标记准备 (Mark Setup) - STW (Stop The World)**
- - 运行时状态转换: 从 _GCoff 进入 _GCmark 阶段。
- - 动作:
- 1. STW 开始: 暂停所有用户 goroutine 和 P。
- 2. 设置 gcphase = _GCmark。
- 3. 启用写屏障 (write barrier): 确保并发标记期间对象指针修改的正确性。
- 4. 启用标记辅助 (mark assists): 分配内存的用户 goroutine 可能需要协助 GC 进行标记。
- 5. 扫描根对象:
- - 扫描所有全局变量中的指针。
- - 扫描所有当前活跃 goroutine 的栈上的指针 (现代 Go 通过混合屏障等优化,此步骤可能更轻量或分阶段)。
- - 将从根对象直接可达的对象标记为灰色,并放入待处理工作队列。
- 6. STW 结束: 恢复所有用户 goroutine 和 P。
- - 后续: GC 工作 goroutine (通常为 GOMAXPROCS 的 25%) 开始并发标记。用户 goroutine 继续执行,并在分配时可能参与标记辅助。
- **阶段 2: 并发标记 (Concurrent Marking)**
- - 运行时状态: gcphase = _GCmark。
- - 描述: 这是 GC 工作的主要阶段,与用户 goroutine 并发执行。
- - 动作:
- 1. GC 工作 goroutine 和标记辅助的 goroutine 从工作队列中取出灰色对象。
- 2. 扫描灰色对象的指针字段:
- - 对于其引用的每个白色对象,将其标记为灰色并加入工作队列。
- 3. 当一个灰色对象的所有指针字段都被扫描后,将其标记为黑色。
- 4. 写屏障持续工作: 拦截用户 goroutine 对指针的修改,以维护三色不变性 (例如,防止黑色对象指向白色对象而未将白色对象置灰)。
- 5. 持续处理工作队列,直到队列为空或满足特定终止条件。
- **阶段 3: 标记终止 (Mark Termination) - STW (Stop The World)**
- - 运行时状态转换: 从 _GCmark 进入 _GCmarktermination 阶段。
- - 动作:
- 1. STW 开始: 再次暂停所有用户 goroutine 和 P。
- 2. 设置 gcphase = _GCmarktermination。
- 3. 完成剩余标记工作:
- - 处理所有写屏障记录的待处理指针。
- - 重新检查某些根集合或特定条件下的对象,确保没有遗漏 (混合屏障显著减少了栈重扫的需求)。
- - 确保所有可达对象均已标记为黑色。此时,所有仍为白色的对象被确认为垃圾。
- 4. 禁用写屏障。
- 5. 禁用标记辅助。
- 6. 准备清扫阶段: 初始化清扫所需的状态。
- 7. STW 结束: 恢复所有用户 goroutine 和 P。
- **阶段 4: 并发清扫 (Concurrent Sweeping)**
- - 运行时状态转换: 从 _GCmarktermination 回到 _GCoff (清扫在 _GCoff 状态下进行)。
- - 描述: 回收在标记阶段被识别为垃圾 (白色) 的对象所占用的内存。此阶段与用户 goroutine 并发执行。
- - 动作:
- 1. 设置 gcphase = _GCoff。
- 2. 清扫器 (sweeper) 开始工作:
- - 后台 GC 工作 goroutine (gcBgMarkWorker) 会主动遍历所有 mspan,回收其中标记为白色的对象,并将其占用的槽位标记为空闲。
- - 按需清扫: 当用户 goroutine 尝试分配内存,且所需的 mspan 尚未被清扫时,该 goroutine 可能会先触发对该 mspan 的清扫。
- 3. 对于一个 mspan:
- - 一旦被清扫,其内部的白色对象所占用的空间被回收。
- - 该 mspan 的空闲槽位可立即用于新的对象分配 (新分配的对象为白色)。
- - 此 mspan 在下一次 GC 的标记阶段完成之前,不会被再次清扫 (即,当前分配到其中的新白色对象不会被本轮 GC 的清扫器误回收)。
- 4. 更新堆的统计数据 (如 live heap 大小)。
- 5. 根据新的 live heap 大小和 GOGC 设置,计算下一次 GC 的 heapGoal。
- ▼ ▼ ▼ GC 周期结束,系统返回 _GCoff 状态,等待下一次触发 ▼ ▼ ▼
复制代码 关于 _GCoff 阶段新分配对象的处理
在 _GCoff 阶段,GC 的标记工作并未激活。当用户 goroutine 在此阶段申请内存并分配新对象时,这些新对象默认被标记为 白色 。
你可能会问,如果这些新分配的白色对象在 _GCoff 期间(此时上一周期的清扫可能还在进行),它们会不会被错误地清扫掉?答案是 不会 。原因在于:
- 清扫针对的是上一周期的垃圾 :GC 的清扫阶段是针对 上一轮 标记结束后被识别为白色(即垃圾)的对象。例如,第 N 轮 GC 的清扫器只会回收在第 N 轮标记中最终仍为白色的对象。
- mspan 先清扫后分配 :一个 mspan(内存管理的基本单元)在能够用于分配新的对象之前,必须确保它已经被上一轮 GC 的清扫器处理完毕。也就是说,如果一个 mspan 中含有第 N 轮 GC 判定的垃圾,这些垃圾必须被清理掉,相应的槽位变为空闲,然后这个 mspan 才能用来分配第 N+1 轮(或 _GCoff 期间)的新对象。
- 新对象等待下一轮 GC :在 _GCoff 期间分配到已清扫 mspan 上的新白色对象,它们是“干净”的,它们是否存活将由 下一轮(即第 N+1 轮)GC 的标记阶段来判断。如果它们在第 N+1 轮标记开始时仍然存活(即从根可达),它们会被标记为灰色,然后黑色;如果不可达,则在第 N+1 轮标记结束后它们依然是白色,并将在第 N+1 轮的清扫阶段被回收。
总结来说, “对于一个 mspan,需要先清扫完(上一周期的垃圾),再用于(为新对象)分配。下次 GC 扫描标记完成前,(这个 mspan 中新分配的对象)不会被再次清扫” 。这个机制确保了新分配的对象不会被当前(或刚结束的)GC 周期的清扫过程错误回收。
内存碎片与对象复用
长时间运行的程序,尤其是那些频繁进行内存分配和释放的程序,常常会面临 内存碎片 (memory fragmentation)的问题。内存碎片分为内部碎片(因分配单元大于实际需求导致的空间浪费)和外部碎片(空闲内存被分割成许多不连续的小块,导致虽然总空闲内存足够,但无法满足较大的单次分配请求)。
一些其他语言的 GC,例如 Java 中的 HotSpot 虚拟机,采用了 分代收集 (generational collection)的策略。这基于一个常见的“弱分代假说”:大多数对象在年轻时(刚分配后不久)就会死亡,而存活时间较长的对象则倾向于继续存活更久。因此,Java 将堆分为新生代和老年代。新生代中的对象回收频繁且通常采用 复制算法 (copying algorithm),这种算法在回收的同时会将存活对象复制到另一块内存区域,从而自然地整理了内存,消除了碎片。但复制算法的代价是需要额外的空间(通常是可用空间的一半),并且移动对象也会带来开销。老年代则采用标记-清除或标记-整理算法。
Go 语言的 GC 并没有采用分代收集,也没有使用会移动对象的整理(compacting)算法。这意味着 Go 的 GC 本身不直接通过移动对象来消除外部碎片。然而,Go 的内存分配器通过一系列精巧的设计来缓解内存碎片问题,并促进内存的高效复用:
多级缓存与大小类(Size Classes & Caching Hierarchy)
- Go 的内存分配器为小对象(通常 < 32KB)预定义了大约 67 个 大小类 (size classes)。当程序请求分配一个小对象时,分配器会将其向上取整到最接近的大小类进行分配。这有助于标准化内存块的大小,减少内部碎片。
- 内存分配通过一个分层缓存系统进行:mcache(P 本地缓存) -> mcentral(全局大小类缓存) -> mheap(全局堆)。
- mcache 为每个 P 提供了各种大小类的 mspan 列表。当 goroutine 在其 P 上分配小对象时,它可以直接从 mcache 中获取对应大小类的空闲槽位,这个过程通常是无锁的,非常迅速。当对象被释放时,其占用的槽位也会返回到 mcache 中对应的 mspan,以便快速复用。这种设计极大地提高了小对象的分配和回收效率,并鼓励了内存的本地化复用。
mspan 的管理与复用
- 一个 mspan 管理着一连串相同大小类的对象槽位。当一个 mspan 中的所有对象都被 GC 回收后,这个 mspan 就变为空闲状态。
- 空闲的 mspan 会被 mcentral 回收,并可以被重新用于服务于相同大小类的分配请求,或者如果 mheap 需要,在某些情况下,一个完全空闲的 mspan(由多个页组成)的内存页甚至可以被拆分或重新组合用于其他大小类或大对象的分配,虽然这不如直接复用高效。
大对象直接分配
对于大对象(> 32KB),它们直接从 mheap 中分配,通常会独占一个或多个 mspan。当这些大对象被回收后,它们所占用的 mspan 也会被释放回 mheap。
向操作系统归还内存(madvise)
- 当 mheap 中积累了大量连续的空闲页(通常是由于大量 mspan 被完全释放)时,Go 的运行时会周期性地扫描并尝试将这些未使用的物理内存归还给操作系统。这是通过调用操作系统提供的机制(如 Linux 上的 madvise 系统调用,使用 MADV_DONTNEED 或 MADV_FREE 等参数)来实现的。这并不会改变进程的虚拟地址空间大小,但会减少其实际占用的物理内存,从而降低整体系统内存压力。虽然这不是内存整理,但它能有效减少程序在空闲时的内存足迹。
通过上述机制,Go 语言在不移动对象(避免了移动带来的复杂性和开销)的前提下,力求通过精细化的内存管理、高效的本地缓存和及时的内存归还,来最大限度地减少内存碎片的影响并提高内存的复用率。特别是对于小对象的分配和回收,Go 的性能表现非常出色。
内存感知型垃圾回收的探索:Green Tea GC
随着 CPU 核心数量的增加和内存架构(如 NUMA,Non-Uniform Memory Access)的日益复杂,内存访问的延迟和带宽正成为高性能系统的主要瓶颈。传统的垃圾回收算法,包括 Go 此前版本的并行标记算法,在进行对象图遍历时,其内存访问模式往往缺乏良好的 空间局部性 (spatial locality,即连续访问物理上相邻的内存)和 时间局部性 (temporal locality,即短时间内重复访问同一内存区域),也未充分考虑内存拓扑结构。这会导致大量的 CPU 周期浪费在等待内存访问上(即所谓的 memory stalls)。据统计,在 Go 的传统 GC 扫描循环中,超过 35% 的 CPU 周期可能仅仅是由于内存停顿造成的。
为了应对这一挑战,Go 团队一直在探索更具内存感知能力的垃圾回收算法。在 Go 语言的 Issue #73581 中,一个名为 <strong>Green Tea
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |