01. Go 内存管理全景架构

摘要:本文系统解析 Go 内存管理的核心架构与工作机制。首先阐述内存管理的必要性,明确 Go 内存管理的三大职责:分配、回收(GC)和归还 OS。核心架构基于 TCMalloc 分级缓存思想,构建了 mcache→mcentral→mheap→OS 四级缓存层次,实现高效无锁分配。对象按大小分为 Tiny(<16B)、Small(16B~32KB)和 Large(>32KB)三类,走不同分配路径。垃圾回收采用并发标记-清扫算法,基于三色标记法与混合写屏障保证并发正确性,分为 Sweep Termination、Concurrent Mark、Mark Termination、Concurrent Sweep 四个阶段,仅两处极短 STW。最后通过完整生命周期图串联分配与回收全流程,并附源码文件索引供深入研读。

Go 内存管理 Wiki / 第一部分:全景概览

new(T) 到垃圾回收,一张图看懂 Go 内存管理的全链路


1. 为什么需要内存管理?

当你在 Go 中写下 s := make([]int, 100)p := &Person{name: "Alice"} 时,程序需要从内存中找一块空闲空间来存放这些数据。这个过程就是内存分配

但内存不是无限的。当这些数据不再被使用时,它们占用的内存必须被回收,否则程序会越用越多,最终耗尽内存崩溃。这就是**垃圾回收(Garbage Collection,简称 GC)**要做的事。

在 C 语言中,程序员需要手动调用 malloc 分配内存、free 释放内存,稍有不慎就会导致内存泄漏或野指针。Go 选择了自动内存管理 ------ runtime 帮你搞定一切,你只管 new,不用关心什么时候释放。

一句话理解 :Go 内存管理 = 帮你高效地找空间 (分配)+ 帮你安全地腾空间(回收)。整个系列 Wiki 就是在拆解这两件事是怎么做的。


2. Go 内存管理的三大职责

Go 的内存管理不仅仅是"分配 + 回收",实际上它做了三件事:

图 1-1:Go 内存管理的三大职责

职责 做什么 对应源码 触发时机
分配 为对象找到合适的内存空间 malloc.go 每次 new / make / 逃逸到堆的变量
回收 (GC) 找出垃圾对象,回收它们占用的 span mgc.go mgcmark.go mgcsweep.go 堆增长到阈值 / 2 分钟定时 / 手动调用
归还 OS 把长期不用的物理内存归还给操作系统 mgcscavenge.go 后台 scavenger 持续运行(CPU 占比 ~1%)

回收和归还 OS 是两件事 :GC 回收后,内存只是从"垃圾对象"变成了"空闲 span",仍然被 Go 持有。只有 scavenge 阶段才会调用 madvise / VirtualFree 把物理内存真正还给操作系统。后面的 Wiki 会详细拆解这两个阶段。


3. 核心架构:TCMalloc 分级缓存

Go 的内存分配器基于 Google 的 TCMalloc(Thread-Caching Malloc)设计,但经过大量演进。源码开头的注释写道:

go 复制代码
// Memory allocator.
// This was originally based on tcmalloc, but has diverged quite a bit.
// http://goog-perftools.sourceforge.net/doc/tcmalloc.html

--- malloc.go:5-8

TCMalloc 的核心思想很简单:用多级缓存减少锁竞争

想象一个超市的货架管理:

  • 你手边有个购物袋(mcache),放最常用的东西,拿取最快,不用排队
  • 购物袋空了,去货架(mcentral)补货,货架按商品分类,每类排一次队
  • 货架也没了,去仓库(mheap)取,仓库是全局唯一的,要锁
  • 仓库也空了,找供应商(OS)要货,最慢但批量最大

大部分时候你只需要在购物袋里拿东西------不需要任何锁,速度极快。只有购物袋空了才需要往上一层。这就是 Go 分配器高效的根本原因。

为什么需要缓存? 因为每次分配内存都去全局锁里排队,性能会很差。缓存让最常见的情况(小对象分配)完全无锁,只有少数情况才需要加锁或系统调用。


4. 分配的四级缓存层次

Go 的分配器有四级层次,从快到慢、从小到大:

图 1-2:Go 内存分配的四级缓存层次(从上到下越来越慢)

源码中的注释精确描述了这个层次:

go 复制代码
// Allocating a small object proceeds up a hierarchy of caches:
//
// 1. Round the size up to one of the small size classes
//    and look in the corresponding mspan in this P's mcache.
//    Scan the mspan's free bitmap to find a free slot.
//    If there is a free slot, allocate it.
//    This can all be done without acquiring a lock.
//
// 2. If the mspan has no free slots, obtain a new mspan
//    from the mcentral's list of mspans of the required size
//    class that have free space.
//
// 3. If the mcentral's mspan list is empty, obtain a run
//    of pages from the mheap to use for the mspan.
//
// 4. If the mheap is empty or has no page runs large enough,
//    allocate a new group of pages (at least 1MB) from the
//    operating system.

--- malloc.go:27-47
注意 :注释中的 "at least 1MB" 是过时描述。实际 mheap.grow()mheap.go:1549-1554)按 pallocChunkPages(512 页)对齐:ask := alignUp(npage, pallocChunkPages) * pageSize,最小为 512 × 8192 = 4MB。即堆向 OS 申请的最小粒度是 4MB(一个 palloc chunk)。

每个层次做什么?

层次 是什么 粒度 源码
mcache 每个 P(处理器)独有的缓存 单个对象(8B ~ 32KB) 无锁 mcache.go
mcentral 每种 size class 的中心缓存 整个 span(若干页) class 级锁 mcentral.go
mheap 全局唯一的堆管理器 连续页(8KB 的倍数) 全局锁 mheap.go
OS 操作系统 至少 4MB 的内存块 系统调用 mem_linux.go

什么是 P? Go 的调度器有 G(goroutine)、M(线程)、P(处理器)三个概念。P 是逻辑处理器,数量等于 GOMAXPROCS。每个 P 拥有自己的 mcache,所以同一时刻最多有 GOMAXPROCS 个 goroutine 在无锁地分配内存。这是 Go 高性能并发分配的关键。


5. 三种分配路径:Tiny / Small / Large

Go 把对象按大小分成三类,走不同的分配路径:

图 1-3:三种分配路径的分派逻辑

类型 大小范围 分配方式 经过 mcache? 典型场景
Tiny < 16 字节 合并到 16B 的 block 中 小字符串、bool 值、小数字
Small 16B ~ 32KB 查 size class 表,从 mcache 取 大部分 struct、slice、map 元素
Large > 32KB 直接从 mheap 分配整页 否(跳过缓存) 大数组、大 buffer、大文件读取

mallocgc 函数的入口分派逻辑如下(简化版):

go 复制代码
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    if size == 0 {
        return unsafe.Pointer(&zerobase)  // 0 字节分配直接返回
    }

    if size <= maxSmallSize - gc.MallocHeaderSize {
        if typ == nil || !typ.Pointers() {
            // 不含指针  noscan,GC 不需要扫描
            if size < maxTinySize {
                x = mallocgcTiny(size, typ)      // Tiny 路径
            } else {
                x = mallocgcSmallNoscan(size)    // Small noscan
            }
        } else {
            // 含指针  scan,GC 需要扫描
            x = mallocgcSmallScan(size, typ)     // Small scan
        }
    } else {
        x = mallocgcLarge(size, typ)             // Large 路径
    }
}

--- malloc.go:1067-1202(简化)
scan vs noscan 是什么? 如果一个对象内部包含指针(比如 struct 里有 slice、map、pointer 字段),GC 需要扫描它来发现更多对象,这种叫 scan 对象。如果对象只有数字、字符串等不含指针的数据,GC 可以直接跳过,这种叫 noscan 对象。Go 用 spanClass 的最低位标记这个信息,让 GC 少做很多无用功。


6. 垃圾回收:并发标记-清扫

Go 使用**并发标记-清扫(Concurrent Mark-Sweep)**垃圾回收器。源码开头的注释描述了它的特征:

go 复制代码
// The GC runs concurrently with mutator threads, is type accurate (aka precise),
// allows multiple GC threads to run in parallel. It is a concurrent mark and sweep
// that uses a write barrier. It is non-generational and non-compacting.

--- mgc.go:5-10

这段话信息量很大,逐句解释:

特征 含义 对开发者的意义
concurrent(并发) GC 大部分时间和用户代码同时运行 GC 暂停时间极短(通常 < 1ms)
type accurate(精确) GC 知道每个指针的确切类型和位置 不会把数字误当成指针,不会泄漏内存
parallel(并行) 多个 GC 线程可以同时工作 多核机器上 GC 更快
write barrier(写屏障) GC 期间修改指针会被拦截 保证并发标记的正确性
non-generational(非分代) 不区分新对象和老对象 实现简单,但不如分代 GC 对短命对象友好
non-compacting(非压缩) GC 后不整理内存碎片 分配器自己管理碎片,内存地址不变

三色标记法

Go 的 GC 基于三色标记法。想象内存中的对象被涂成三种颜色:

图 1-4:三色标记法的状态转换

  1. 开始时,所有对象都是白色(可能被回收)
  2. GC 从根对象(全局变量、栈上的变量)出发,找到直接引用的对象,标为灰色
  3. 取出一个灰色对象,扫描它引用的所有对象,把那些白色对象标为灰色,自己变成黑色
  4. 重复步骤 3,直到没有灰色对象为止
  5. 此时,所有存活对象都是黑色,剩下的白色对象就是垃圾,可以被回收

什么是"根"(root)? 根是 GC 扫描的起点,包括:① 全局变量(data/BSS 段);② 所有 goroutine 的栈;③ 注册了 finalizer 的对象。从根出发能到达的对象就是存活的,到达不了的就是垃圾。

写屏障:为什么并发标记不会出错?

GC 和用户代码同时运行,有个问题:如果 GC 已经扫描过对象 A(标记为黑色),之后用户代码在 A 中新写了一个指向白色对象 B 的指针,那 B 就会被遗漏------明明还活着却被当垃圾回收了。

Go 用混合写屏障解决这个问题:在 GC 期间,每次修改指针时,runtime 会自动把旧值和新值都标灰。这样即使并发修改,也不会漏掉任何对象。

go 复制代码
// 混合写屏障伪代码
writePointer(slot, ptr):
    shade(*slot)              // 标记旧值(Yuasa 删除屏障)
    if current stack is grey:
        shade(ptr)            // 标记新值(Dijkstra 插入屏障)
    *slot = ptr

--- mbarrier.go:30-35


7. GC 四阶段时序

Go 的 GC 分为四个阶段,只有其中两个需要短暂停止所有 goroutine(STW):

图 1-5:GC 四阶段时序(红色 = STW,蓝色 = 并发标记,绿色 = 并发清扫)

阶段 名称 是否 STW 做什么 源码入口
1 Sweep Termination 是(极短) 完成上轮清扫、初始化 GC 控制器、启用写屏障 mgc.go:gcStart()
2 Concurrent Mark 并发标记存活对象、写屏障开启、新对象标黑 mgcmark.go:gcDrain()
3 Mark Termination 是(极短) 验证标记完成、关闭写屏障、准备清扫 mgc.go:gcMarkTermination()
4 Concurrent Sweep 并发清扫垃圾 span、归还内存、scavenge 归还 OS mgcsweep.go:bgsweep()

STW 有多短? Go 1.8 以后,两处 STW 通常都在微秒级 (几十到几百微秒)。大部分 GC 工作在阶段 2 和阶段 4 并发完成,用户代码几乎不受影响。你可以用 GODEBUG=gctrace=1 运行程序来观察实际的 STW 时间。

GC 三个阶段的常量定义

go 复制代码
const (
    _GCoff             = iota // GC 未运行;后台清扫,写屏障关闭
    _GCmark                   // GC 标记阶段:分配黑色,写屏障开启
    _GCmarktermination        // GC 标记终止:分配黑色,P 帮助 GC,写屏障开启
)

--- mgc.go:250-254


8. 完整生命周期:一张图串联

把分配和回收串在一起,就是一个完整的内存生命周期:

图 1-6:从 new(T) 到 GC 回收的完整生命周期

这就是 Go 内存管理的全貌:

  1. 用户代码分配对象 mallocgc 入口
  2. 小对象走 mcache mcentral mheap OS 四级缓存
  3. 大对象直接走 mheap OS
  4. 对象使用中,Go 的逃逸分析确保该在栈上的在栈上,该在堆上的在堆上
  5. 堆增长到阈值 GC 触发
  6. GC 并发标记存活对象(三色标记 + 写屏障)
  7. GC 并发清扫垃圾 span
  8. Scavenger 把空闲内存归还给操作系统
  9. 循环往复

9. 源码文件索引

本系列 Wiki 涉及的所有 Go runtime 源码文件,按功能分组:

内存分配

文件 职责
malloc.go 分配总入口 mallocgc,Tiny/Small/Large 分派
mcache.go Per-P 缓存 mcacherefill/nextFree
mcentral.go 中心缓存 mcentralcacheSpan/uncacheSpan
mheap.go 全局堆 mheapmspanheapArenaspanClass
mfixalloc.go 定长分配器 fixalloc(分配器自身的元数据)
msize.go 大小对齐 roundupsize
sizeclasses.go 67 个 size class 表(工具生成)

底层存储

文件 职责
mpagealloc.go 页分配器 pageAlloc,基数树搜索
mpallocbits.go 页位图 pallocBits,位操作查找空闲页
mspanset.go 并发安全 span 集合 spanSet

垃圾回收

文件 职责
mgc.go GC 主控 gcStart,状态机,四阶段调度
mgcmark.go 标记阶段 gcDraingreyobject,根扫描
mgcsweep.go 清扫阶段 sweeponebgsweepmspan.sweep
mgcscavenge.go 内存归还 OS bgscavenge,PI 控制器
mgcpacer.go GC 步调控制 gcControllerState,触发计算
mgcwork.go GC 工作缓冲区 gcWorkworkbuf
mgcstack.go 栈扫描 stackScanState
mbarrier.go 混合写屏障声明
mwbbuf.go 写屏障缓冲区 wbBuf

OS 交互

文件 职责
mem.go 内存 API 封装 sysMap/sysUsed/sysFree
mem_linux.go Linux mmap/madvise 实现
mem_windows.go Windows VirtualAlloc/VirtualFree 实现

如何阅读源码? 建议按照本 Wiki 系列的顺序,先从 malloc.gomallocgc 函数开始,跟着调用链一路深入。每篇 Wiki 都会标注具体的源码文件和行号,方便对照阅读。

相关推荐
萧瑟余晖37 分钟前
Java深入解析篇三十四之分布式事务
java·开发语言·分布式
CAE虚拟与现实38 分钟前
源码注解 /class 注解 / 运行时注解三者生命周期
java·开发语言
有脚就行40 分钟前
第24篇-Go-gRPC推理服务-高性能跨语言通信
开发语言·人工智能·后端·golang
互联网中的一颗神经元44 分钟前
02. 核心概念与术语表
java·jvm·spring
January12071 小时前
Cassandra CQL 基本命令详解
java
Escalating_xu1 小时前
【Linux】线程概念与控制:从地址空间、LWP 到 pthread_create、join、detach 与 NPTL
java·linux·redis
Lam Tang1 小时前
APS 系列文章 09
java·代理模式
evans在进步1 小时前
LeetCode 198:打家劫舍——Java 动态规划详解
java·leetcode·动态规划
2601_963870222 小时前
【计算机毕业设计】基于Spring Boot的考研信息交流平台的设计与实现
java·spring boot·后端