摘要:本文系统解析 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:三色标记法的状态转换
- 开始时,所有对象都是白色(可能被回收)
- GC 从根对象(全局变量、栈上的变量)出发,找到直接引用的对象,标为灰色
- 取出一个灰色对象,扫描它引用的所有对象,把那些白色对象标为灰色,自己变成黑色
- 重复步骤 3,直到没有灰色对象为止
- 此时,所有存活对象都是黑色,剩下的白色对象就是垃圾,可以被回收
什么是"根"(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 内存管理的全貌:
- 用户代码分配对象
mallocgc入口 - 小对象走 mcache mcentral mheap OS 四级缓存
- 大对象直接走 mheap OS
- 对象使用中,Go 的逃逸分析确保该在栈上的在栈上,该在堆上的在堆上
- 堆增长到阈值 GC 触发
- GC 并发标记存活对象(三色标记 + 写屏障)
- GC 并发清扫垃圾 span
- Scavenger 把空闲内存归还给操作系统
- 循环往复
9. 源码文件索引
本系列 Wiki 涉及的所有 Go runtime 源码文件,按功能分组:
内存分配
| 文件 | 职责 |
|---|---|
malloc.go |
分配总入口 mallocgc,Tiny/Small/Large 分派 |
mcache.go |
Per-P 缓存 mcache,refill/nextFree |
mcentral.go |
中心缓存 mcentral,cacheSpan/uncacheSpan |
mheap.go |
全局堆 mheap,mspan,heapArena,spanClass |
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 |
标记阶段 gcDrain,greyobject,根扫描 |
mgcsweep.go |
清扫阶段 sweepone,bgsweep,mspan.sweep |
mgcscavenge.go |
内存归还 OS bgscavenge,PI 控制器 |
mgcpacer.go |
GC 步调控制 gcControllerState,触发计算 |
mgcwork.go |
GC 工作缓冲区 gcWork,workbuf |
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.go的mallocgc函数开始,跟着调用链一路深入。每篇 Wiki 都会标注具体的源码文件和行号,方便对照阅读。