目录

Go 原理之 gc 垃圾回收机制:三色标记 + 混合写屏障(需要 STW)

用过 Go 开发的都知道,它的垃圾回收(GC)机制是保障程序稳定运行的关键——尤其是在高并发场景下,GC 的延迟直接影响服务的响应性能。

Go 1.5 版本引入了三色标记 + 插入写屏障,Go 1.8 版本进一步演进为"三色标记 + 混合写屏障"的组合方案,将 STW(Stop The World,世界暂停)时间压缩到毫秒级甚至微秒级。

今天,我们从原理到源码,彻底讲透这套 GC 方案的设计逻辑与实现细节。

一、常见垃圾回收算法

在学习 Go Gc 垃圾回收算法之前,我们先对现有的比较常见的垃圾回收算法做个大概了解,如下表:

垃圾回收算法 描述 代表语言 优缺点
引用计数 为每个对象维护一个引用计数,记录对象被引用的次数
每当一个对象被引用时,引用计数就会增加。
当对象不再被引用时,引用计数就会减少。
如果对象的引用计数变为 0,
则对象可以被垃圾回收器回收
PythonPHP 优点
实现简单,处理快
缺点
无法处理循环引用,两个对象相互引用,计数永远不为0
分代收集 按照对象生命周期长短划分不同的代空间,
生命周期长的放入老年代,短的放入新生代,
不同代有不同的回收算法和回收频率
Java 优点:性能好
缺点:需要 STW,算法复杂
三色标记法 从根变量开始遍历所有引用的对象,标记引用的对象为不同颜色,
被标记为白色的对象进行回收
Golang 优点
解决了引用计数的缺点
缺点
需要 STW,暂时停掉程序运行

⚠️:以上都需要 STW

二、Go GC 为什么要"三色标记 + 混合写屏障"?

在聊具体方案前,得先搞清楚一个核心问题:Go 为什么要花大力气迭代 GC 机制?这要从早期 GC 方案的痛点说起。

Go 1.0 采用"标记-清除"算法,全程 STW——执行 GC 时,所有 Goroutine 都会暂停,直到 GC 完成。

这种方式在程序内存较小时还好,一旦内存达到 GB 级,STW 时间会长达数百毫秒,严重影响服务可用性。

为了解决 STW 过长的问题,Go 团队引入了"并发标记"的思路:让 GC 标记过程与 Goroutine 并发执行,只在关键阶段短暂 STW。

但并发标记会带来新问题——对象引用关系动态变化(比如 Goroutine 在标记过程中修改了对象的引用),可能导致"漏标记"或"误标记"。

为解决并发标记的一致性问题,业界有两种经典方案:

  1. 写屏障:监控对象引用的修改,当修改发生时标记相关对象,保证标记一致性;
  2. 快照:在标记开始时生成对象引用的快照,基于快照进行标记,但会占用额外内存。

Go 选择了写屏障方案,并结合"三色标记"算法提升标记效率,最终在 Go 1.8 版本确定了"三色标记 + 混合写屏障"的成熟方案——既实现了大部分阶段的并发执行,又通过混合写屏障解决了传统写屏障的不足,将 STW 时间控制在极低水平。

三、三色标记与混合写屏障是什么?

要理解 Go GC 的核心逻辑,必须先吃透"三色标记"和"混合写屏障"这两个核心概念。

它们一个负责"高效标记垃圾",一个负责"保证并发标记的一致性",相辅相成。

(一)三色标记

三色标记:高效区分"存活对象"与"垃圾"

三色标记算法是一种基于"对象可达性"的标记方法,通过给对象标记三种颜色,区分不同状态的对象,实现高效的并发标记。

三种颜色对应对象的三种状态,核心逻辑围绕"可达性分析"展开——从根对象(如 Goroutine 栈上的变量、全局变量)出发,遍历所有可达对象,未被遍历到的就是垃圾。

三种颜色的定义及流转逻辑:

颜色 状态定义 处理逻辑
白色 未被标记的对象 初始状态,所有对象都是白色;标记结束后仍为白色的是垃圾,将被清理
灰色 已被标记,但引用的子对象未完全标记 处于"待处理"状态,会被放入标记队列,后续需遍历其引用的子对象
黑色 已被标记,且引用的子对象已全部标记 处于"已完成"状态,不会再被遍历;黑色对象是存活对象,标记阶段不会被清理

三色标记白色(清除对象) + 灰色(过渡对象,受保护, 最终变黑色) + 黑色(受保护)

可达对象引用关系举例;可达的意思就是可以关联到的,有对象引用它了

对象1 = 对象2 // 对象2可达,对象1引用了对象2,对象2 被 对象1 引用
对象1 = 对象3
对象2 = 对象3
对象2 = 对象5

核心流转流程:

  1. 初始时,所有对象为白色,根对象(如 Goroutine 栈变量、全局变量)被标记为灰色,放入标记队列;

  2. 从标记队列取出灰色对象,遍历其引用的子对象:

    a. 将子对象(白色对象)标记为灰色并加入队列,

    b. 然后将自身(当前灰色对象)标记为黑色;

  3. 持续重复步骤 2,直到标记队列为空(灰色对象列表为空);

  4. 标记结束,白色对象即为垃圾。没有灰色对象,只有黑色对象(保留)

如下图:

/img/go-gc/1.png
image-20230413112723437

大白话讲解三色标记:

  1. GC 只会清理堆上的所有对象,GC 开始时,所有对象的颜色都是白色的

  2. 从根节点 root 对象开始遍历,遍历到的子对象标记为灰色对象,放在灰色对象队列里,然后当前对象标记为黑色

  3. 然后持续遍历灰色对象队列,持续重复步骤2,最终灰色对象列表为空,所有的灰色对象都会转为黑色对象

  4. 剩下哪些没有被遍历到的对象都是白色对象,说明没有被任何对象引用,最终被清理

  5. 也就是最终只剩下黑色对象,因为灰色对象会转为黑色对象,白色对象会被清理

源码中对象标记的核心结构体(简化版,来自 src/runtime/mheap.go):

// mspan 是 Go 内存管理的基本单元,包含多个相同大小的对象
type mspan struct {
    next *mspan         // 链表下一个节点
    prev *mspan         // 链表前一个节点
    startAddr uintptr   // 该 span 起始地址
    npages    uintptr   // 占用的页数
    sizeclass uint8     // 对象大小等级(对应固定的对象大小)
    allocBits  *gcBits  // 分配位图:标记哪些对象已被分配
    gcmarkBits *gcBits  // 标记位图:标记对象的颜色状态(三色标记的核心)
}

// gcBits 用于存储对象的标记状态(简化版)
type gcBits struct {
    bits []uint8        // 位图数组,每一位代表一个对象的标记状态
    nbits int           // 总位数(对应对象数量)
}

关键逻辑:mspan 是 Go 内存分配的基本单元,每个 mspan 管理一批相同大小的对象。gcmarkBits 位图用于记录每个对象的标记状态——比如某一位为 0 代表白色,1 代表灰色或黑色,通过位图操作实现高效的批量标记。

v1.3之前,go 使用的是 标记-清除法,需要 stw ,效率极低;

v1.5 引入三色标记 + 插入写屏障;v1.8之后,go 采用 三色标记 + 混合写屏障 极大的降低stw的时间,提高gc性能

(二)混合写屏障

混合写屏障:解决并发标记的一致性问题。

三色标记在并发执行时,最大的问题是"对象引用被动态修改"。

比如:灰色对象 C 原本引用白色对象 B,在标记过程中,C 断开对 B 的引用,同时黑色对象 A 新增引用 B——此时 B 不会被灰色对象遍历到(C 已断开),也不会被黑色对象遍历到(A 已完成标记),最终 B 被漏标记,误判为垃圾。

为解决这个问题,需要通过"写屏障"监控对象引用的修改。

Go 之前使用过"插入写屏障"和"删除写屏障",但各有不足:

  • 插入写屏障:当有新引用插入时(如 C 引用 B),将 B 标记为灰色,但会导致栈上对象需要最终 STW 重新扫描;
  • 删除写屏障:当有引用删除时(如 A 取消引用 B),将 B 标记为灰色,但会导致大量不必要的标记。

Go 1.8 引入的"混合写屏障"结合了两者的优势,核心规则(四句话总结):

  1. 写屏障开启时,所有新分配的对象直接标记为黑色;
  2. 当灰色对象或黑色对象修改引用,指向白色对象时,将白色对象标记为灰色;
  3. 当栈上对象修改引用时,不触发写屏障(栈上对象最终会短暂 STW 扫描);
  4. 堆上对象修改引用时,触发写屏障。

混合写屏障的优势:既避免了插入写屏障对栈的频繁扫描,又减少了删除写屏障的冗余标记,同时保证了并发标记的一致性,大幅缩短 STW 时间。

大白话讲解:

三色标记存在并发问题:

在三色标记期间,如果没有STW,并发创建对象,可能存在垃圾对象或误删对象的情况:

    1. 黑色对象的引用对象被删除,则不可达,正常黑色对象应该被回收,但是gc期间只会循环遍历灰色列表,不会回收黑色对象,因此该对象为垃圾对象 (多余垃圾对象)

      eg:对象1已经被标记为黑色,表示该对象有引用方,受保护,如果没有stw,该对象的引用可能被删除,正常应该转为白色对象被清除,然gc并不会清除黑色对象。

    1. 黑色对象引用了白色对象,白色对象有了引用对象应该被保护,但仍然被无情的回收 (清掉不该清的对象)

    ​ 白色对象只有被灰色对象引用情况,才会判断是否需要清理,白色对象如果在gc期间被黑色对象引用,那只会被误删除。

所以 go 引入了 混合写屏障 机制,满足:

  • 强三色不变式:黑色对象不允许引用了白色对象;因为一旦引用,该黑色对象将不会继续参与 gc,白色对象会被无理清除。
  • 弱三色不变式:黑色对象可以引用白色对象,但该白色对象必须被其它灰色对象或其上游有灰色对象引用,否则该白色对象将被无理清除。

这里需要注意一点,插入屏障仅会在堆内存中生效,不对栈内存空间生效,这是因为 go 在并发运行时,大部分的操作都发生在栈上,函数调用会非常频繁。

数十万 goroutine 的栈都进行屏障保护自然会有性能问题

所以 gc 期间,任何在堆上新创建的对象,均为黑色。

混合写屏障 开启期间 描述
插入写屏障 新分配的堆对象直接标记为黑色 满足:强三色不变式。
不会存在黑色对象引用白色对象
删除写屏障 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色 满足:弱三色不变式
(保护灰色对象到白色对象的路径不会断)

(三)STW

STW:不可避免的"短暂暂停"。

虽然"并发标记"是核心,但 Go GC 仍需要短暂的 STW 阶段——主要用于"标记准备"和"标记终止",原因是这两个阶段需要操作全局状态,无法并发执行:

  1. 标记准备:暂停所有 Goroutine,初始化标记状态(如重置位图、构建根对象列表),时间通常在微秒级;
  2. 标记终止:暂停所有 Goroutine,处理剩余的灰色对象,清理标记状态,时间通常在微秒级。

混合写屏障的引入,让栈无需在标记终止阶段重新扫描(每个 Goroutine 的栈在并发标记阶段逐个扫描一次即可),这是 Go GC 能将 STW 时间压缩到极低水平的关键。

(四)优缺点

优点:

    减少stw时间,三色标记需要stw整个程序,混合写屏障(分段stw)可以有效降低stw的时间

缺点:

    回收精度低,有些垃圾需要在下一轮 gc 清理

四、Go GC 完整流程

三色标记 + 混合写屏障如何协同工作?

理解了核心概念后,我们结合 Go 1.21 版本的 GC 流程,拆解"三色标记 + 混合写屏障"的完整协同逻辑。

整个流程分为四个阶段,其中只有两个阶段需要短暂 STW。

阶段 1:标记准备(STW)

此阶段会暂停所有 Goroutine(STW),执行初始化操作,耗时极短:

  1. 暂停所有 Goroutine,禁止其修改对象引用;
  2. 初始化 GC 相关全局状态,如重置 gcmarkBits 位图;
  3. 构建根对象列表(根对象包括:全局变量、Goroutine 栈上的变量、寄存器中的变量);
  4. 将根对象标记为灰色,加入标记队列;
  5. 开启混合写屏障;
  6. 恢复所有 Goroutine,结束 STW。

阶段 2:并发标记(与 Goroutine 并发)

此阶段是 GC 的核心阶段,与 Goroutine 并发执行,无需 STW:

  1. 从标记队列中取出灰色对象,遍历其引用的所有子对象;
  2. 对每个子对象,通过 gcmarkBits 检查状态:若为白色,标记为灰色并加入标记队列;
  3. 遍历完成后,将当前灰色对象标记为黑色;
  4. 同时,混合写屏障监控堆上对象的引用修改:
    • 新分配的堆对象直接标记为黑色;
    • 堆上对象修改引用时,若指向白色对象,将其标记为灰色;
  5. 当标记队列中的灰色对象为空时,并发标记阶段结束。

阶段 3:标记终止(STW)

此阶段再次短暂 STW,处理并发标记的收尾工作:

  1. 暂停所有 Goroutine;
  2. 关闭混合写屏障;
  3. 处理剩余的灰色对象(遍历其引用并标记);
  4. 计算垃圾对象的数量和大小,为清理阶段做准备;
  5. 恢复所有 Goroutine,结束 STW。

注意:混合写屏障的核心优势是不需要在标记终止阶段重新扫描所有 Goroutine 的栈。每个 Goroutine 的栈在并发标记阶段会被逐个暂停扫描(每个栈只需扫描一次),混合写屏障保证了栈扫描后堆上的引用变更不会导致漏标,因此标记终止阶段的 STW 时间极短。

阶段 4:清理阶段(并发)

此阶段与 Goroutine 并发执行,清理被标记为白色的垃圾对象:

  1. 遍历所有 mspan,通过 gcmarkBits 识别白色对象;
  2. 回收白色对象的内存,将其标记为"空闲"状态,存入对应大小等级的空闲链表;
  3. 对内存碎片进行整理(如合并相邻的空闲 mspan);
  4. 清理完成后,重置 gcmarkBits,为下一次 GC 做准备。

大白话讲解

三色标记 + 混合写屏障

  • 标记准备(Mark Setup):开启混合写屏障(Write Barrier),需 STW(stop the world)
  • 标记开始(Marking):使用三色标记法并发标记 ,与用户程序并发执行
  • 标记终止(Mark Termination):对触发写屏障的对象进行重新扫描标记,关闭写屏障(Write Barrier),需 STW(stop the world)
  • 清理(Sweeping):将需要回收的内存归还到堆中,将过多的内存归还给操作系统,与用户程序并发执行

五、源码解析

见:

src/runtime/mgc.go

src/runtime/mbarrier.go

以下是简化后的核心源码及注释,聚焦混合写屏障的关键逻辑:

// 混合写屏障核心函数:当对象 ptr 新增引用到对象 val 时触发
// ptr:指向源对象的指针(如黑色对象 A 或灰色对象 C)
// val:被引用的目标对象(如白色对象 B)
func writeBarrier(ptr *uintptr, val uintptr) {
    // 1. 若目标对象 val 为空,或不在堆上,直接返回(不处理栈上对象)
    if val == 0 || !inHeap(val) {
        return
    }

    // 2. 若源对象 ptr 所在的 span 未被标记,直接返回
    ptrSpan := spanOf(ptr)
    if ptrSpan == nil || !ptrSpan.inMarking() {
        return
    }

    // 3. 获取目标对象 val 所在的 span 和标记状态
    valSpan := spanOf(val)
    valObjIdx := valSpan.objIndex(val) // 计算 val 在 span 中的索引
    valMarked := valSpan.gcmarkBits.get(valObjIdx) // 获取 val 的标记状态

    // 4. 混合写屏障核心逻辑:若目标对象是白色,标记为灰色
    if !valMarked {
        // 将 val 标记为灰色
        valSpan.gcmarkBits.set(valObjIdx)
        // 将 val 加入标记队列,后续遍历其引用的子对象
        queueForMarking(val)
    }

    // 5. 若源对象是栈上对象,记录需要最终 STW 扫描(优化点)
    if inStack(ptr) {
        stackScanNeeded = true
    }
}

// 辅助函数:判断地址是否在堆上
func inHeap(addr uintptr) bool {
    return addr >= heapStart && addr < heapEnd
}

// 辅助函数:判断地址是否在栈上
func inStack(addr uintptr) bool {
    // 遍历所有 Goroutine 的栈范围,判断 addr 是否在其中
    for _, g := range allGoroutines() {
        if addr >= g.stack.start && addr < g.stack.end {
            return true
        }
    }
    return false
}

核心逻辑拆解:

  1. 过滤非堆对象:栈上对象修改引用不触发写屏障(后续 STW 扫描),仅处理堆上对象;
  2. 状态检查:仅在标记阶段处理,且确保源对象和目标对象所在的 span 处于标记状态;
  3. 颜色修正:若目标对象是白色(未标记),将其标记为灰色并加入标记队列,避免漏标记;
  4. 栈扫描标记:若源对象是栈上对象,标记需要在标记终止阶段 STW 扫描栈,确保栈上对象引用的一致性。

六、观察 Go GC 行为

光看原理不够直观,我们通过一段实战代码,结合 Go 自带的工具观察 GC 行为,同时给出实际开发中的调优技巧。

(一)监控 GC 关键指标

package main

import (
    "fmt"
    "runtime"
    "runtime/debug"
    "time"
)

// 模拟内存分配:创建大量对象,触发 GC
func allocateMemory() {
    var slice []*int
    // 循环分配 100 万个 int 对象(每个 int 8 字节,共约 8 MB)
    for i := 0; i < 1000000; i++ {
        num := new(int)
        *num = i
        slice = append(slice, num)
    }
    // 保持引用,避免被提前回收
    _ = slice
}

func main() {
    // 禁用 GC 自动触发,手动控制(方便观察)
    runtime.GC() // 先执行一次 GC,清理初始垃圾
    debug.SetGCPercent(-1) // 设置为 -1 禁用自动 GC

    fmt.Println("开始分配内存...")
    allocateMemory()
    fmt.Println("内存分配完成,准备触发 GC")

    // 记录 GC 开始时间
    start := time.Now()
    // 手动触发 GC
    debug.SetGCPercent(100) // 恢复默认 GC 触发比例
    runtime.GC()
    // 计算 GC 耗时
    gcDuration := time.Since(start)

    // 打印 GC 相关指标
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("GC 耗时:%v\n", gcDuration)
    fmt.Printf("已分配堆内存:%v MB\n", m.Alloc/1024/1024)
    fmt.Printf("GC 总次数:%d\n", m.NumGC)
    fmt.Printf("STW 总时间:%v\n", m.PauseTotal)
}

// 输出示例(不同环境可能有差异):
// 开始分配内存...
// 内存分配完成,准备触发 GC
// GC 耗时:1.234ms
// 已分配堆内存:8 MB
// GC 总次数:1
// STW 总时间:123.456µs

关键指标解读:

  • gcDuration:GC 总耗时(包含并发标记和 STW 时间);
  • m.Alloc:当前已分配的堆内存大小;
  • m.NumGC:程序运行以来的 GC 总次数;
  • m.PauseTotal:所有 STW 阶段的总耗时(可以看到混合写屏障下 STW 时间极短)。

(二)代码调优

通过代码调优,减少 GC 压力。

GC 调优的核心思路是"减少垃圾产生"和"优化内存分配",结合 GMP 模型和 GC 机制,给出三个高频调优技巧:

避免大量小对象的频繁分配

小对象(如小于 16 字节)会被分配到"微对象分配器",但频繁分配会导致大量 mspan 被占用,增加 GC 标记和清理的开销。

// 错误示例:频繁分配小对象
func badAlloc() {
    for i := 0; i < 1000000; i++ {
        // 每次循环分配一个 int 小对象
        s := fmt.Sprintf("num: %d", i)
        _ = s
    }
}

// 正确示例:复用对象或使用对象池
var strPool = sync.Pool{
    New: func() interface{} {
        b := make([]byte, 32)
        return &b
    },
}

func goodAlloc() {
    for i := 0; i < 1000000; i++ {
        // 从对象池获取复用对象
        bPtr := strPool.Get().(*[]byte)
        b := *bPtr
        // 重置并复用缓冲区
        b = b[:0]
        b = append(b, "num: "...)
        b = strconv.AppendInt(b, int64(i), 10)
        _ = string(b)
        // 归还对象到池
        strPool.Put(bPtr)
    }
}

原理:sync.Pool 可以复用临时对象,减少小对象的频繁分配和回收,降低 GC 标记和清理的压力。

合理设置 GOGC 环境变量

GOGC 是控制 GC 触发时机的环境变量,默认值为 100,代表当堆内存增长到上次 GC 后内存的 2 倍时触发 GC。

根据业务场景调整 GOGC 可优化 GC 频率:

  • CPU 密集型场景:可将 GOGC 调大(如 200),减少 GC 触发频率,降低 GC 对 CPU 的占用;

  • 内存敏感型场景:可将 GOGC 调小(如 50),提前触发 GC,避免内存占用过高。

使用方式:运行程序时指定 GOGC=200 ./your-program

避免内存逃逸到堆上

栈上的对象会随着 Goroutine 退出而自动释放,无需 GC 处理。若变量因"内存逃逸"被分配到堆上,会增加 GC 开销。

可通过 go build -gcflags="-m" 查看逃逸分析结果,优化代码避免不必要的逃逸。

// 错误示例:变量逃逸到堆上
func escapeDemo() *int {
    num := 10
    return &num // 返回栈上变量地址,导致逃逸到堆
}

// 正确示例:避免逃逸(根据业务场景调整返回方式)
func noEscapeDemo() int {
    num := 10
    return num // 返回值,不逃逸
}

常见问题

Q1. 混合写屏障为什么不需要扫描堆上对象,只需要扫描栈上对象?

因为堆上对象的引用修改会被混合写屏障监控:当堆上黑色/灰色对象修改引用指向白色对象时,白色对象会被标记为灰色,不会漏标。

而栈上对象的引用修改不触发写屏障(为了减少性能开销),所以每个 Goroutine 的栈需要在并发标记阶段被暂停并扫描一次。混合写屏障保证了在栈被扫描之后,即使堆上发生引用变更也不会导致漏标,因此不需要在标记终止阶段重新扫描所有栈,这是混合写屏障相比纯插入写屏障的核心优势。

Q2. gc 多久执行一次,什么时候触发

  • 定时触发:默认情况下,如果超过 2 分钟没有触发过 GC,Go 运行时会强制触发一次 GC(由 forcegcperiod 控制)
  • 内存分配触发:当堆内存增长到上次 GC 后存活内存的 1 + GOGC/100 倍时触发 GC(默认 GOGC=100,即增长到 2 倍时触发)
  • 主动触发:调用 runtime.GC
  • 空间不足时触发: 当前线程的内存管理单元中不存在空闲空间时,创建32KB以下的对象可能触发垃圾收集,创建32KB以上的对象时,一定会尝试触发

Q3. 为什么混合写屏障不保护栈的引用

因为go在并发运行时,大部分的操作都发生在栈上,函数调用会非常频繁。数十万goroutine的栈都进行屏障保护自然会有性能问题

虽然混合写屏障不保护栈上的引用,但 Go 语言的垃圾回收器在并发标记阶段会逐个暂停每个 Goroutine 并扫描其栈(每个栈只需扫描一次)。混合写屏障保证了栈扫描后,堆上的引用变更不会导致已扫描栈引用的对象被漏标。因此,不需要在标记终止阶段重新扫描所有栈,这既保证了垃圾回收的正确性,又大幅降低了 STW 时间。

综上所述,混合写屏障不保护栈的引用是为了在保证垃圾回收正确性的前提下,尽可能提高程序的性能和降低实现复杂度。通过并发标记阶段逐个栈扫描 + 混合写屏障的协作,既确保了栈上的可达对象不会被错误回收,又避免了标记终止阶段对所有栈的重新扫描。

Q4. gc 过程中那一部分使用了 STW

  • 标记准备阶段(Mark Setup)

    在这个阶段,垃圾回收器需要初始化标记状态,开启写屏障等操作。为了确保标记的正确性,需要暂停所有的用户程序,进行 STW

    在标记阶段开始前会进行一次STW,暂停所有goroutine的执行,然后再进行标记操作

  • 标记终止阶段(Mark Termination)

    在并发标记阶段结束后,可能还有一些标记工作没有完成,如一些新创建的对象或者修改的引用关系没有被标记。因此,需要暂停所有的用户程序,完成剩余的标记工作,关闭写屏障,统计所有需要回收的对象。

Q5. Go GC 的清理阶段是并发的,如何避免清理时 Goroutine 访问垃圾对象?

通过内存分配器的协作实现:清理阶段写屏障已关闭(在标记终止阶段已关闭),并发清理的安全性是通过 mspan 级别的懒清理(lazy sweeping)机制保证的。当 Goroutine 需要分配内存时,分配器会先检查对应的 mspan 是否已被清理,如果尚未清理则先完成清理再分配。存活对象(黑色)在标记阶段已被正确标记,清理阶段只会回收白色对象的内存,不会影响存活对象。因此 Goroutine 在清理阶段访问的存活对象不会被误回收。

Q6. 如何查看 Go 程序的 GC 详细日志?

通过设置环境变量 GODEBUG=gctrace=1 运行程序,会输出详细的 GC 日志,包含各阶段耗时、内存变化等信息。例如: GODEBUG=gctrace=1 ./your-program 日志中关键字段:pause 代表 STW 时间,alloc 代表堆内存分配大小,mark 代表并发标记耗时。

Q7. 高并发场景下,如何平衡 GC 性能和业务性能?

核心原则是"减少 GC 压力":

  1. 复用对象:通过 sync.Pool 复用临时对象,减少垃圾产生;
  2. 控制内存增长:避免一次性分配大量内存,采用分批分配的方式;
  3. 调整 GOGC:根据业务是 CPU 密集还是内存密集,调整 GOGC 优化 GC 频率;
  4. 避免逃逸:通过逃逸分析优化代码,减少堆上内存分配。

Q8. 为什么 GC 只清理堆上对象而不清理栈上对象?

这是由栈和堆两种内存分配方式的本质区别决定的。

栈内存的生命周期是确定的;栈内存遵循 LIFO(后进先出) 的管理方式,其生命周期与函数调用完全绑定:

  • 函数被调用时,栈帧(stack frame)自动分配,局部变量存在于栈帧中
  • 函数返回时,栈帧自动销毁,所有局部变量立即释放

堆上的对象则不同,它的生命周期无法在编译期确定:堆上对象可能被多个 Goroutine 引用、被全局变量持有、被闭包捕获……编译器无法预判它何时变成"垃圾",所以必须依赖 GC 在运行时通过可达性分析来判断并清理。

一句话来说:栈对象自己会销毁,堆对象太复杂自己不能销毁需要 GC

总结

Go 的"三色标记 + 混合写屏障"GC 方案,核心是通过"高效标记"和"并发安全"的平衡,实现低延迟的垃圾回收。

其设计精髓可概括为三点:

  1. 三色标记提效:通过颜色区分对象状态,结合位图操作实现批量高效标记,降低标记阶段的时间开销;
  2. 混合写屏障保准:结合插入写屏障和删除写屏障的优势,监控堆上对象引用修改,仅需短暂 STW 扫描栈,解决并发标记的一致性问题;
  3. 并发执行降延迟:除标记准备和终止阶段的短暂 STW 外,标记和清理阶段均与 Goroutine 并发执行,将 GC 对业务的影响降到最低。

实际开发中,GC 调优的核心不是"调优 GC 本身",而是"优化内存分配"——减少垃圾产生、避免不必要的堆分配,才能从根本上降低 GC 压力。

理解 GC 机制的底层逻辑,才能写出更高效、更稳定的 Go 并发程序。

如果大家关于 Go Gc 垃圾回收算法的解读还有哪些不清楚的地方,欢迎大家在评论区交流~~~

版权声明

未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!

本文原文链接: https://fiveyoboy.com/articles/go-gc/

备用原文链接: https://blog.fiveyoboy.com/articles/go-gc/