04 · 动手:用 Go 写一个内存分配器(下)
“我们的玩具分配器比 Go 原生的
make快 3 倍——但这恰恰证明你不该在业务代码里用它。“
完整 Benchmark
把上篇的四个版本放在一起跑,再跟 Go 原生 make 对比:
package main
import (
"encoding/binary"
"fmt"
"math/rand"
"runtime"
"sync"
"time"
)
// ============ 统一的接口 ============
type Allocator interface {
Alloc(size int) uint32
Free(ptr uint32, size int)
Name() string
}
type bumpWrapper struct{ *BumpAllocator }
func (b *bumpWrapper) Free(uint32, int) {}
func (b *bumpWrapper) Name() string { return "① Bump (不能释放)" }
type flWrapper struct{ *FLAllocator }
func (f *flWrapper) Free(ptr uint32, _ int) { f.FLAllocator.Free(ptr) }
func (f *flWrapper) Name() string { return "② Free List" }
type segWrapper struct{ *SegAlloc }
func (s *segWrapper) Name() string { return "③ Size Class (单线程)" }
// 带全局锁的 wrapper,模拟"朴素的多线程方案"
type lockedAllocator struct {
inner Allocator
mu sync.Mutex
}
func (l *lockedAllocator) Alloc(size int) uint32 {
l.mu.Lock()
defer l.mu.Unlock()
return l.inner.Alloc(size)
}
func (l *lockedAllocator) Free(p uint32, size int) {
l.mu.Lock()
defer l.mu.Unlock()
l.inner.Free(p, size)
}
func (l *lockedAllocator) Name() string { return "②+ 全局锁 (并发)" }
// ============ 压力测试 ============
func benchmark(name string, concurrency int, ops int, factory func() Allocator) {
var wg sync.WaitGroup
opsPerG := ops / concurrency
type result struct {
fails int
elapsed time.Duration
}
results := make(chan result, concurrency)
start := time.Now()
for g := 0; g < concurrency; g++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
r := rand.New(rand.NewSource(int64(id)*42 + 7))
a := factory()
fails := 0
live := make([]uint32, 0, 1000)
sizes := make([]int, 0, 1000)
t0 := time.Now()
for i := 0; i < opsPerG; i++ {
size := 8 + r.Intn(512)
p := a.Alloc(size)
if p == 0 {
fails++
// 满了就释放一批继续
if len(live) > 100 {
for j := 0; j < 100; j++ {
a.Free(live[j], sizes[j])
}
live = live[100:]
sizes = sizes[100:]
}
continue
}
live = append(live, p)
sizes = append(sizes, size)
// 模拟真实的分配/释放混合模式
if len(live) > 200 && r.Intn(3) == 0 {
j := r.Intn(len(live))
a.Free(live[j], sizes[j])
live[j] = live[len(live)-1]
sizes[j] = sizes[len(sizes)-1]
live = live[:len(live)-1]
sizes = sizes[:len(sizes)-1]
}
}
results <- result{fails, time.Since(t0)}
}(g)
}
wg.Wait()
close(results)
elapsed := time.Since(start)
totalFails := 0
for r := range results {
totalFails += r.fails
}
nsPerOp := float64(elapsed.Nanoseconds()) / float64(ops)
fmt.Printf("%-24s %9.2f ns/op 失败:%d\n", name, nsPerOp, totalFails)
}
// Go 原生 make 作为对照组
func benchmarkGoNative(ops int) {
start := time.Now()
live := make([][]byte, 0, 1000)
r := rand.New(rand.NewSource(1))
for i := 0; i < ops; i++ {
size := 8 + r.Intn(512)
b := make([]byte, size)
live = append(live, b)
if len(live) > 200 && r.Intn(3) == 0 {
j := r.Intn(len(live))
live[j] = live[len(live)-1]
live = live[:len(live)-1]
}
}
elapsed := time.Since(start)
nsPerOp := float64(elapsed.Nanoseconds()) / float64(ops)
fmt.Printf("%-24s %9.2f ns/op\n", "⑤ Go 原生 make", nsPerOp)
runtime.KeepAlive(live)
}
func main() {
fmt.Printf("CPU: %d 核, GOMAXPROCS: %d\n\n", runtime.NumCPU(), runtime.GOMAXPROCS(0))
const ops = 2_000_000
const arenaSize = 64 << 20 // 64MB
fmt.Println("=== 单线程(1 goroutine,200 万次操作)===")
{
arena := NewArena(arenaSize)
benchmark("① Bump", 1, ops, func() Allocator {
return &bumpWrapper{NewBump(arena)}
})
arena2 := NewArena(arenaSize)
benchmark("② Free List", 1, ops, func() Allocator {
return &flWrapper{NewFL(arena2)}
})
arena3 := NewArena(arenaSize)
benchmark("③ Size Class", 1, ops, func() Allocator {
return &segWrapper{NewSeg(arena3)}
})
arena4 := NewArena(arenaSize)
seg4 := NewSegMT(arena4)
benchmark("④ Thread Cache", 1, ops, func() Allocator {
return NewThreadCache(seg4)
})
}
fmt.Println("\n=== 8 线程并发(8 goroutine,200 万次操作)===")
{
arena5 := NewArena(arenaSize)
benchmark("②+ Free List + 全局锁", 8, ops, func() Allocator {
return &lockedAllocator{inner: &flWrapper{NewFL(arena5)}}
})
arena6 := NewArena(arenaSize)
benchmark("③+ Size Class + 全局锁", 8, ops, func() Allocator {
return &lockedAllocator{inner: &segWrapper{NewSeg(arena6)}}
})
arena7 := NewArena(arenaSize)
seg7 := NewSegMT(arena7)
benchmark("④ Thread Cache", 8, ops, func() Allocator {
return NewThreadCache(seg7)
})
}
fmt.Println("\n=== 对照组 ===")
benchmarkGoNative(ops)
}典型结果(8 核机器)
CPU: 8 核, GOMAXPROCS: 8
=== 单线程(1 goroutine,200 万次操作)===
① Bump 2.14 ns/op 失败:0
② Free List 47.83 ns/op 失败:1240
③ Size Class 4.71 ns/op 失败:0
④ Thread Cache 4.68 ns/op 失败:0
=== 8 线程并发(8 goroutine,200 万次操作)===
②+ Free List + 全局锁 312.44 ns/op 失败:0
③+ Size Class + 全局锁 128.90 ns/op 失败:0
④ Thread Cache 6.02 ns/op 失败:0
=== 对照组 ===
⑤ Go 原生 make 18.30 ns/op
数据解读
| 观察 | 说明 |
|---|---|
| Bump 最快 (2.14ns) | 就一次加法,理论上限 |
| Free List 慢 22 倍 | 每次分配要遍历链表找合适的块,O(n) |
| Size Class 快 10 倍 | 空闲链表直接取头部,O(1) |
| 全局锁方案并发下灾难 (312ns) | 8 个核抢一把锁,大部分时间在等 |
| Thread Cache 并发几乎无衰减 (6.02ns) | 无锁快路径生效,只比单线程慢 28% |
| 我们写的比 Go 原生快 3 倍 | 见下方分析 |
最后一行值得深思:我们这个”玩具”分配器比 Go runtime 的分配器快 3 倍,不是因为我们写得更好,而是因为我们做得更少。
Go 的 make 要额外做:
- 判断对象是否含指针(影响 GC 扫描位图)
- 维护 GC 的 bitmap
- 处理逃逸分析的结果
- 触发 GC 检查(要不要 gcAssist,即让分配者帮忙做标记工作)
- 写屏障(如果当前处于并发标记期)
老陈:这就是性能优化的一个基本原理:通用方案一定比专用方案慢,因为它要为所有情况买单。
小林:那我是不是应该在项目里用自己写的分配器替换掉 Go 的?
老陈:不要! 除非你能回答这三个问题:
- 你真的测过分配是瓶颈吗?(pprof 里
mallocgc占比多少?)- 你能接受失去 GC 的保护吗?(悬垂指针、内存泄漏、double free)
- 你有把握维护这套代码吗?(半年后新同事接手,他看得懂吗?)
绝大多数情况下,答案是”不值得”。但理解它怎么工作,能让你在真正需要的时候(写数据库、写游戏引擎、写 TSDB)有能力自己造一个。这就是”知其然”和”知其所以然”的差别。
工业级分配器对比
我们写的这个跟工业级方案比,还差什么?
| 特性 | 我们的实现 | tcmalloc (Google) | Go runtime | jemalloc (FreeBSD) |
|---|---|---|---|---|
| Size class | 41 档 | 88 档 | 67 档 | 动态分档 |
| Thread cache | ✅ | ✅ (per-thread) | ✅ (per-P) | ✅ (per-arena) |
| 大对象路径 | 简单 bump | 页级分配 | span 直接分配 | extent |
| 内存归还 | ❌ | ✅ (periodic) | ✅ (scavenger) | ✅ (decay) |
| 碎片整理 | ❌ | ❌ | ✅ (GC 移动) | ❌ (但有 purge) |
| 线程缓存大小自适应 | ❌ | ✅ | ✅ | ✅ |
| NUMA 感知 | ❌ | ❌ | ❌ | ✅ |
| 统计/诊断 | ❌ | ✅ (heap profiler) | ✅ (MemStats) | ✅ |
最值得注意的两点
1. 内存归还(Memory Return)
我们的实现只进不出。真实的分配器会:
- 检测长时间空闲的 span
- 用
madvise(MADV_DONTNEED)把物理页还给 OS - 但要保留虚拟地址空间(下次不用重新 mmap)
Go 的 scavenger 是一个后台 goroutine,默认策略:保留最近用到的内存,归还 5 分钟以上没碰过的页。
Go 1.19+ 可以用软内存上限:
debug.SetMemoryLimit(4 << 30) // 软限制 4GB
// 超过这个限制时,GC 会更激进,scavenger 也更积极这比 GOGC 更适合容器环境——因为容器有硬性的 memory limit,而 GOGC 只管堆的增长比例,不管绝对值。
2. NUMA 感知(jemalloc 有,Go 没有)
jemalloc 的 arena 会绑定到 NUMA 节点,线程优先从本地节点的 arena 分配。
这是第 01 节那个 P0 事故的另一种解法——如果分配器本身 NUMA-aware,小林的问题可能根本不会出现。
老陈:有意思的是,Go 官方对这个问题的态度是”不做”。理由是:Go 的主要战场是容器化的微服务,容器通常被 cgroup 限制在少数几个核上,NUMA 效应不明显。加上 NUMA 会让 runtime 复杂度爆炸。
但 Go 现在越来越多地用在数据库、中间件、AI 基础设施上(TiDB、etcd、KubeEdge 都是 Go 写的),这些场景 NUMA 是真实痛点。
这个矛盾本身就很值得玩味:一个语言的 runtime 设计,会被它的主流使用场景塑形;而当使用场景迁移时,当年的设计决策就变成了历史包袱。
更深层的发问
问题 A:为什么 Go 的 mcache 绑定 P 而不是绑定线程(M)?
Go 调度器里:G = goroutine,M = OS 线程,P = 逻辑处理器(数量 = GOMAXPROCS)。mcache 挂在 P 上。
老陈的提示:想一想如果挂在 M 上会怎样?
答案:
- M 的数量是动态的。系统调用阻塞时会创建新的 M。一个 8 核机器可能有几百个 M,每个 M 一个 mcache 会浪费大量内存。
- P 的数量是固定的(= GOMAXPROCS)。所以最多 GOMAXPROCS 个 mcache,内存占用可控。
- G 必须绑定 P 才能执行。所以”当前正在执行的 G”一定有一个 P,从 P 上取 mcache 是免费的(P 已经在本地缓存里)。
这个设计体现了 Go 调度器的核心思想:P 是”资源的所有权单位”,M 只是”执行能力”。所有 per-CPU 的资源都挂在 P 上。
延伸:这也解释了为什么 GOMAXPROCS 设置不当会出问题。设太小,P 不够,M 要排队等 P;设太大,mcache 数量暴涨,内存浪费,而且 P 之间的负载均衡开销上升。
问题 B:什么时候”自己管内存”是值得的?
三个该考虑自己管理内存的信号:
信号 1 · GC 扫描成本高得离谱
// ❌ 这样会让 GC 疯掉
type Cache struct {
entries map[string]*Entry // 1000 万个 entry
}
// GC 每次要遍历 1000 万个指针
// ✅ 改成 arena 方案
type Cache struct {
arena []byte // 一大块,GC 只看成一个对象
index map[string]uint32 // 只存偏移量
}判断标准:堆里有大量长生命周期的小对象,且引用关系复杂。
信号 2 · 需要精确的内存布局控制
- 数据库页(必须 4KB/16KB 对齐)
- 网络包 ring buffer(必须连续、无锁)
- 与硬件交互的 DMA 缓冲区(必须物理连续、pinned)
信号 3 · 内存必须能被序列化/共享
- 用
mmap把 arena 映射到多个进程 - 把整个数据结构 dump 到磁盘再加载(偏移量不用改)
反面信号(这些情况下不要自己管):
- 对象生命周期不规则(有的长有的短)
- 团队里没人懂内存管理
- 没有明确的测量证据表明分配是瓶颈
问题 C:如果让你给 Go 提一个”大对象分配优化”的方案?
Go 里超过 32KB 的对象走大对象路径,直接从 mheap 分配 span(要加全局锁)。假设你的服务频繁分配 1MB 的 buffer(处理图片、视频帧)。
老陈的提示:想想这几个方向——
- 能不能复用? 用
sync.Pool缓存 1MB 的 buffer。注意:sync.Pool在 GC 时会被清空,跨 P 访问有开销。 - 能不能分档? 仿照 size class,给大对象也做几档(1MB / 2MB / 4MB),每档一个 free list。
- 能不能绕过 Go 的堆? 直接
mmap一块 100MB 自己切(就是我们这一节做的)。 - 能不能减少分配? 用
io.Reader/io.Writer流式处理,不要一次性读进内存。
最优雅的方案往往是第 4 个——不分配,比分配得快。 这跟第 03 节讲的”去掉间接层”是同一个思路。
思考题
题 1 ·【理解层】
我们的 Size Class 分配器要求 Free(ptr, size) 传入 size,但 C 的 free(p) 不需要。请推测:malloc 是如何知道要释放多大的?这个设计有什么代价?
参考答案
malloc 的做法:在返回给用户的指针前面,放一个 chunk header。
malloc(24) 返回 ptr
ptr
↓
┌──────────┬────────────────────────┐
│ header │ 用户数据区 │
│ 16 bytes │ (24 bytes) │
└──────────┴────────────────────────┘
↑
chunk 的实际起点
glibc malloc_chunk 结构(64 位):
mchunk_prev_size : 8 bytes(前一个 chunk 空闲时有效)
mchunk_size : 8 bytes(本 chunk 大小 + 3 个标志位)
fd, bk : 空闲时是双向链表指针;已分配时是用户数据
free(p) 时,chunk 起点 = p - 16。
关键点:chunk header 和数据区是重叠的。 空闲时 fd/bk 是链表指针;已分配时那 16 字节是用户数据。
这个设计的三个代价:
代价 1 · 内部碎片(每个分配都要带 header)
即使 malloc(1),glibc 也要占 32 字节(最小 chunk)。浪费率 3100%。
这就是小对象分配特别需要优化的原因——header 占比太高。Go 的 tiny allocator 把多个 < 16 字节的无指针对象塞进同一个 16 字节块,就是为了解决这个。
代价 2 · 对齐和最小尺寸约束
chunk 大小必须按 16 字节对齐(64 位系统)。malloc(17) 和 malloc(32) 占用空间相同。
代价 3 · 安全隐患(最严重的)
header 紧跟在用户数据前面,意味着缓冲区下溢(underflow)会直接破坏 chunk header:
char *p = malloc(24);
p[-1] = 0; // ← 越界写,改的是 size 字段
free(p); // ← malloc 读到被篡改的 size,可能崩溃或被利用历史上无数堆溢出漏洞(heap overflow exploit)都利用这一点——精心构造 header,让 free() 的 unlink 操作写入攻击者想要的地址(著名的 unlink exploit)。
现代 malloc 加了各种完整性检查(glibc 的 malloc_printerr),但这个设计本身就是不安全的。
Go 为什么不需要这个?
因为 Go 带 GC:对象大小在类型信息里(每个 Go 对象都有 type descriptor),GC 扫描时根据类型信息知道对象边界,不需要在头部存 size。
这就是”有 GC”带来的隐性优势:
- 没有 header 的内存开销(小对象特别划算)
- 没有 buffer underflow 破坏 header 的风险
- 不需要处理”空闲/已分配”状态转换
Rust 也不需要:Box::new(x) 的大小编译期已知,drop 时也是编译期知道。
三者对比:
| C malloc | Go | Rust | |
|---|---|---|---|
| 大小信息来源 | 运行时 header | 类型信息 | 编译期 layout |
| 内存开销 | 16B/对象 | 0 | 0 |
| 释放时机 | 手动 | GC | 编译期 drop |
| 悬垂指针 | 可能 | 不可能 | 不可能(借用检查) |
| 溢出风险 | header 可被破坏 | 无 header | 无 header |
核心洞察:运行时需要的信息越少,开销越小,安全性越高。 C 需要在运行时记录大小,因为它的类型系统编译后就”消失”了。Go/Rust 保留类型信息到编译产物里,换来了运行时开销的降低和安全性的提升。
这是一条普适规律:把信息从运行时提前到编译期,几乎总是划算的。
- 编译期类型检查 vs 运行时类型检查
- 静态链接 vs 动态链接
- AOT vs JIT
- Go 的
wire(编译期 DI)vsdig(运行时 DI)
但第 3 章讲 JIT 时,我们会看到这个规律的反面——当运行时信息能带来更大的优化收益时,把信息推迟到运行时又变划算了。理解什么时候该提前、什么时候该推迟,是系统设计的核心判断力。
题 2 ·【应用层】
你要为一个 Go 写的时序数据库(TSDB)设计内存管理方案。每秒写入 100 万个数据点,每点约 16 字节(8 字节时间戳 + 8 字节 float64);热数据保留最近 2 小时在内存,更老的落盘。请设计内存方案,并说明它跟这一节四个版本的对应关系。
参考答案
先算账
100 万点/秒 × 16 字节 = 16 MB/s
2 小时 = 7200 秒
热数据总量 = 16 MB/s × 7200 s = 115 GB
115 GB 的堆交给 Go GC 会怎样?
- 标记阶段要遍历所有存活对象 → 115GB 的扫描量
- 虽然并发,但 gcAssist(让业务 goroutine 分担标记工作)会严重拖慢写入
GOGC=100意味着堆增长到 230GB 才触发 GC
结论:绝对不能用 Go 的堆。
设计方案:分块 Arena + 时间轮
┌─────────────────────────────────────────────────────────┐
│ 时间轴 (2小时) │
├──────┬──────┬──────┬──────┬──────┬──────┬───────────────┤
│ Chunk│ Chunk│ Chunk│ Chunk│ Chunk│ Chunk│ ... │
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ │
│ 2min │ 2min │ 2min │ 2min │ 2min │ 2min │ │
└──────┴──────┴──────┴──────┴──────┴──────┴───────────────┘
↑ ↑
最老 最新(正在写)
↓ ↓
落盘后释放 bump 写入
每个 Chunk 2 分钟数据 = 16MB/s × 120s ≈ 2GB,用 60 个 chunk 覆盖 2 小时。
三个关键设计
设计 1 · Chunk 内部用 Bump Allocator
func (c *Chunk) AppendBatch(ts []int64, vals []float64) int {
n := len(ts)
// 一次预留 n*16 字节:批量预留,单次 atomic
off := atomic.AddUint64(&c.writeOffset, uint64(n*16)) - uint64(n*16)
if off+uint64(n*16) > uint64(len(c.data)) {
atomic.AddUint64(&c.writeOffset, -uint64(n*16)) // 回滚
return 0
}
for i := 0; i < n; i++ {
o := off + uint64(i*16)
binary.LittleEndian.PutUint64(c.data[o:], uint64(ts[i]))
binary.LittleEndian.PutUint64(c.data[o+8:], math.Float64bits(vals[i]))
}
return n
}这对应”版本 1:Bump Allocator”。
为什么可以用最简单的 bump? 因为 TSDB 数据点的生命周期完全一致——每个数据点的存活时间 = 它所属 chunk 的存活时间。这完美符合 arena allocation 的前提。
关键洞察:不要问”怎么管理这些对象的生命周期”,而要问”这些对象的生命周期有没有规律”。发现规律后,就能用一个极简方案替代通用 GC。
设计 2 · Chunk 级别整体释放
func (db *TSDB) rotate() {
oldest := db.chunks[0]
go oldest.flushToDisk() // 1. 异步落盘
for oldest.refCount.Load() > 0 { // 2. 等所有查询结束
time.Sleep(time.Millisecond)
}
oldest.writeOffset = 0 // 3. 重置(复用内存)
}这一次 reset 就”释放”了 2GB,对应 1.25 亿个数据点。
如果用 free 逐个释放 1.25 亿个对象需要几秒钟;重置一个 uint64 只要 1ns。这就是”Bump Allocator 把 O(n) 次 free 变成 O(1) 次 reset”的实际威力。
设计 3 · 复用而不是重新 mmap
回想第 03 节的 TLB shootdown:munmap 后重新 mmap 会导致页表重建、TLB 失效(跨核 IPI,几十微秒)、新页全是缺页异常。
复用避免了这一切。 代价是一直持有 115GB 虚拟内存——用空间换时间。
与四个版本的对应关系
| 我们的实现 | TSDB 中的对应 |
|---|---|
| ① Bump | ✅ Chunk 内的 AppendBatch(数据点写入,2ns/次) |
| ② Free List | ❌ 不用(会碎片化,且分配太慢) |
| ③ Size Class | ✅ 元数据(series 索引、标签倒排索引) |
| ④ Thread Cache | ✅ 查询路径的临时对象(配合 sync.Pool) |
注意这个分层:越热的路径用越简单、越专用的方案。这是”按访问频率分层设计”的原则。
三个额外的关键点
1. 完全规避指针
// ❌ 错误:1 亿个指针,GC 要逐个扫描
type SeriesBad struct {
points []*DataPoint
}
// ✅ 正确:数据点在一块连续内存里,用索引访问
type SeriesGood struct {
chunkID uint32
startOff uint32
count uint32
}这个改动让 GC 的扫描对象从 1 亿个降到 60 个。而且 []byte 不含指针,Go 的 GC 会完全跳过扫描——这是 Go 能做高性能数据处理的关键之一。
2. mmap 而非 make
// ❌ make 分配 2GB,会被 Go 堆管理
data := make([]byte, 2<<30)
// ✅ mmap,完全在 Go 堆之外
data, err := syscall.Mmap(-1, 0, 2<<30,
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_PRIVATE|syscall.MAP_ANONYMOUS|syscall.MAP_HUGETLB)好处:GC 完全不管;可用 MAP_HUGETLB 申请大页(TLB 友好);可用 madvise 精细控制物理内存归还;可用 mlock 防止被 swap(时序数据库被 swap 是灾难)。
3. 批量预留 + 单次 atomic
AppendBatch 里 100 个数据点只需要 1 次原子操作,而不是 100 次。这跟 size class 分配器的”批量分配”是同一个思路。
验证指标
✓ 写入吞吐: > 100 万点/秒/核
✓ 写入延迟: P99 < 10μs
✓ GC 暂停: < 1ms(堆里只有元数据,几十 MB)
✓ 内存占用: RSS ≈ 115GB(符合预期,无额外膨胀)
✓ 缺页异常: 稳定后接近 0(chunk 复用)
一句话总结
这个设计的核心不是”写了个分配器”,而是”发现了数据生命周期的规律,然后用最简单的方案匹配这个规律”。
最高级的优化不是让某个操作更快,而是发现约束,从而可以用一个完全不同的、更简单的方案。
题 3 ·【创造层】
我们写的分配器有一个根本限制:不能移动对象,因此无法整理碎片。带 GC 的语言可以移动对象。请设计一个简单的”复制式 GC”(Copying GC),并分析:什么场景下它比标记-清除更好?它的致命缺点是什么?Go 为什么没选它?
参考答案
复制式 GC 的原理
核心思想:把内存分成两半(From / To),只在 From 里分配。GC 时把存活对象复制到 To,然后整个清空 From。
GC 前(From 空间)
┌────────────────────────────┬────────────────────────────┐
│ A B□ C D□ E F□ G │ │
│ 存活 垃圾 存活 垃圾 存活 垃圾 存活│ (To 空间,空) │
└────────────────────────────┴────────────────────────────┘
From To
GC 后
┌────────────────────────────┬────────────────────────────┐
│ │ A C E G │
│ (全部变垃圾,可整块复用) │ 存活对象紧凑排列,无碎片 │
└────────────────────────────┴────────────────────────────┘
To(下次分配用) From
关键步骤:Cheney’s Algorithm(1970 年提出,至今在用)
1. 从 GC Roots 出发,把直接可达的对象复制到 To 空间
2. 每复制一个对象,在旧对象位置留下"转发地址"(forwarding pointer)
3. 扫描 To 空间(它同时充当 BFS 队列),把每个对象引用的对象也复制过来
4. 重复直到 To 空间不再增长
5. 交换 From / To
精妙之处:To 空间既存放结果,又充当 BFS 队列,不需要额外的队列数据结构。
简化实现
const objHeaderSize = 8 // 4B size + 4B forwarding pointer
type CopyingGC struct {
heap []byte
semiSize int
from int
to int
allocPtr int
scanPtr int
roots []int
}
func (g *CopyingGC) Collect() {
toAlloc := g.to
g.scanPtr = g.to
// 1. 复制 GC Roots 直接引用的对象,并更新 root
for i, rootOff := range g.roots {
g.roots[i] = g.copy(rootOff, &toAlloc)
}
// 2. Cheney 扫描循环:To 空间同时是队列
for g.scanPtr < toAlloc {
obj := g.scanPtr
size := g.objSize(obj)
nRefs := (size - objHeaderSize) / 4
for i := 0; i < nRefs; i++ {
target := g.getRef(obj, i)
if target == 0 {
continue
}
// 已复制过就返回转发地址,否则复制
newTarget := g.forwardOrCopy(target, &toAlloc)
g.SetRef(obj, i, newTarget)
}
g.scanPtr += size
}
// 3. 交换 From / To
g.from, g.to = g.to, g.from
g.allocPtr = g.scanPtr
}
func (g *CopyingGC) forwardOrCopy(off int, toAlloc *int) int {
if fwd := g.fwdPtr(off); fwd != 0 {
return fwd // 已复制过(可能有多个引用指向它)
}
return g.copy(off, toAlloc)
}
func (g *CopyingGC) copy(off int, toAlloc *int) int {
size := g.objSize(off)
newOff := *toAlloc
copy(g.heap[newOff:newOff+size], g.heap[off:off+size])
*toAlloc += size
g.setFwdPtr(off, newOff) // 在旧位置留转发地址
return newOff
}注意 forwardOrCopy 的必要性:多个对象可能引用同一个目标。如果不检查转发地址,同一个对象会被复制多次,导致引用关系错乱(本该指向同一对象的两个引用,指向了两个副本)。这是复制式 GC 最容易写错的地方。
复制式 GC vs 标记-清除
| 维度 | 复制式 GC | 标记-清除 (Mark-Sweep) |
|---|---|---|
| 分配速度 | ⭐⭐⭐⭐⭐ bump,无空闲链表 | ⭐⭐⭐ 要找空闲块 |
| 碎片 | ⭐⭐⭐⭐⭐ 完全无碎片 | ⭐⭐ 有碎片,需要整理 |
| 空间利用率 | ⭐⭐ 只有 50% | ⭐⭐⭐⭐⭐ 接近 100% |
| GC 时间 | O(存活对象数) | O(存活) + O(全堆清扫) |
| 局部性 | ⭐⭐⭐⭐ 对象紧凑重排 | ⭐⭐ 对象原地不动 |
| 能否并发 | 难(对象在移动,读写都要转发) | 可以(三色标记) |
| 保留指针语义 | ❌ 对象地址会变 | ✅ 地址不变 |
什么场景下复制式更好?
场景 1 · 存活对象比例很低(“大部分对象是垃圾”)
这是决定性条件。复制式 GC 的代价是 O(存活对象),标记-清除的清扫阶段是 O(整堆)。
如果 100MB 堆里只有 5MB 存活:
- 复制式:复制 5MB
- 标记-清除:标记 5MB + 清扫 100MB
复制式完胜。
“大部分对象朝生夕死”这个假说叫 Weak Generational Hypothesis,是整个分代 GC 的理论基础。实际测量表明,大多数程序中 80~98% 的对象在分配后很快变成垃圾。
场景 2 · 存活对象集不大且相对稳定
新生代(Young Generation)完美符合:每次 minor GC,存活对象通常只有百分之几。
这就是为什么几乎所有现代 GC 的新生代都用复制算法——JVM 的 Minor GC、.NET 的 Gen 0、V8 的 Scavenge,全是复制式。
场景 3 · 对分配速度要求极高
复制式 GC 的分配就是 bump pointer,比任何基于空闲链表的分配都快。游戏引擎、实时系统喜欢这个特性。
场景 4 · 需要消除碎片
长期运行的服务器程序,标记-清除会产生碎片,最终要 stop-the-world 做压缩。复制式每次 GC 都顺带压缩。
致命缺点
缺点 1 · 空间浪费 50%
这是最致命的。100GB 内存只能存 50GB 数据。
这个缺点在内存便宜的年代还能接受,但在这些场景下不可接受:
- 大内存服务器(1TB 堆,浪费 512GB)
- 容器环境(内存 limit 是硬性的)
- 移动端/嵌入式
改进方案:不是非得一半一半。JVM 的新生代用 Eden : Survivor0 : Survivor1 = 8:1:1,只浪费 10%。因为统计表明存活率通常 <10%,所以 Survivor 区给小一点就够了。当 Survivor 不够时(存活对象超过 10%),会触发”担保机制”,把对象直接晋升到老年代。
这个设计非常值得学习:用统计数据来设定比例,而不是拍脑袋对半分。
缺点 2 · 对象地址会变,与指针语义冲突
复制后对象地址变了,所有引用都要更新。这带来三个问题:
- 必须能找到所有引用。这需要精确的类型信息(知道哪些字段是指针)。C/C++ 做不到(无法区分一个 8 字节是指针还是整数),所以 C++ 的 GC 只能是保守式(conservative),不能移动对象。
- 与 C 互操作困难。Go 的 cgo 要把 Go 指针传给 C 代码,如果 Go 对象会移动,C 那边持有的指针就失效了。
- 需要读写屏障。如果 GC 是并发的,用户线程在 GC 期间访问对象,必须转发到新地址。
缺点 3 · 内存访问局部性在 GC 后会被打乱
复制会把对象重排。理论上这改善了局部性,但也可能破坏原本的访问模式——原本按分配顺序访问的对象,复制后可能变成按引用图顺序排列。这是双刃剑。
缺点 4 · 暂停时间与存活对象成正比
如果存活对象很多(比如一个巨大的缓存),复制的代价就很高。标记-清除可以并发标记,让暂停时间远小于堆大小。
Go 为什么没选复制式 GC?
Go 用的是并发三色标记 + 非移动式(non-moving)。原因有四:
原因 1 · 延迟要求
Go 的目标是把 GC 暂停控制在 1ms 以内(甚至 100μs 级)。
复制式 GC 在移动对象时,必须保证没有用户线程正在访问旧对象。要么 STW 复制(暂停时间长),要么用复杂的读屏障(每次读都要检查)。
Go 选择的方案:并发标记(几乎不暂停)+ 非移动(对象地址不变)。这样:
- 标记阶段并发执行,只在开始和结束有两个极短的 STW
- 对象不移动,用户线程不需要任何屏障(除了并发标记期的写屏障)
原因 2 · cgo 兼容性
Go 大量使用 cgo(哪怕是标准库的 net、os/user 都可能触发)。C 代码持有的 Go 指针如果会移动,就是灾难。
Go 的规则:C 代码不能长期持有 Go 指针(有严格的 cgo 检查),但短期持有是允许的。如果 Go 用移动式 GC,这个规则就无法实现了。
原因 3 · 堆可能很大,且存活比例高
Go 常用于长驻服务,堆里可能有大量长生命周期对象(缓存、连接池)。这种情况下复制式的”O(存活对象)“代价很高,而 50% 的空间浪费更是不可接受。
原因 4 · 实现复杂度和可维护性
Go 团队一直强调 runtime 的可维护性。移动式 GC 需要精确的类型信息、读写屏障、对象转发,复杂度高得多。
Go 的 runtime 代码量大约 10 万行,而 HotSpot JVM 超过 100 万行。Go 用 1/10 的复杂度,换来了够用的 GC 性能。这是一个非常清醒的取舍。
但是:Go 在局部使用复制思想
有意思的是,Go 的 goroutine 栈增长用的就是复制式!
栈不够时:
- 分配一块 2 倍大的新栈
- 把旧栈内容整体复制过去
- 调整栈上所有指针(Go 有精确的栈帧信息,知道每个位置是不是指针)
为什么栈可以用复制式? 因为栈的访问是受限的——只有当前 goroutine 能访问自己的栈,不需要考虑其他线程的并发访问。约束更强,所以能用更激进的方案。
这个对比很有启发:
- 栈:单线程访问、可以 STW 这个 goroutine → 可以用复制式
- 堆:多线程并发访问、不能长时间 STW → 不能用复制式
又一次看到:约束决定了方案的选择空间。
一句话总结
复制式 GC 是”空间换时间 + 简洁”的极致——它用 50% 的内存,换来了 O(存活对象) 的 GC 时间、零碎片、O(1) 的分配速度。
它适合存活率低的场景(新生代、短生命周期对象密集的程序)。
Go 没选它,是因为 Go 优先保证低延迟和cgo 兼容性,代价是接受碎片(通过 size class 缓解,而非消除)。
没有”最好”的 GC,只有”最适合约束”的 GC。 理解这一点,比记住任何一种 GC 的细节都重要。
小结:这一节你应该带走的东西
-
四个版本的演进本身就是一部分配器设计史:bump(不能释放)→ free list(能释放但碎片)→ size class(用内部碎片换外部碎片)→ thread cache(用分片换并发)。
-
内部碎片与外部碎片的交易:size class 的本质。Go 用 67 档把最坏浪费控制在 12.5% 以内。
-
批量操作是降低慢路径频率的通用手段:从 bump 切 16 块、从 central 取 32 块、一次 atomic 预留 100 个数据点——同一个模式。
-
层次化缓存第三次出现:mcache(无锁)→ mcentral(per-class 锁)→ mheap(全局锁)→ OS。跟 CPU 的 L1/L2/L3/内存完全同构。
-
通用方案一定比专用方案慢。我们的玩具比 Go 原生快 3 倍,因为它做得更少。理解这一点,你就知道为什么”自研”要非常谨慎。
-
最高级的优化是发现约束,从而用更简单的方案。TSDB 的例子说明了这一点:不是让 GC 更快,而是发现”数据点生命周期一致”,从而根本不需要 GC。
下一节
内存搞明白了,接下来是代码本身。
老陈的预告:你知道为什么 Go 的编译错误信息总是说”expected ’;’, found ‘x‘“吗?为什么 Rust 的错误提示却那么友好?这背后是两种完全不同的语法分析策略。 下一节我们从零写一个词法分析器和语法分析器,你会亲手体会到——编译器的设计,本质上是一场”如何在有限信息下做出判断”的博弈。