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 的?

老陈:不要! 除非你能回答这三个问题:

  1. 你真的测过分配是瓶颈吗?(pprof 里 mallocgc 占比多少?)
  2. 你能接受失去 GC 的保护吗?(悬垂指针、内存泄漏、double free)
  3. 你有把握维护这套代码吗?(半年后新同事接手,他看得懂吗?)

绝大多数情况下,答案是”不值得”。但理解它怎么工作,能让你在真正需要的时候(写数据库、写游戏引擎、写 TSDB)有能力自己造一个。这就是”知其然”和”知其所以然”的差别。


工业级分配器对比

我们写的这个跟工业级方案比,还差什么?

特性我们的实现tcmalloc (Google)Go runtimejemalloc (FreeBSD)
Size class41 档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 上会怎样?

答案:

  1. M 的数量是动态的。系统调用阻塞时会创建新的 M。一个 8 核机器可能有几百个 M,每个 M 一个 mcache 会浪费大量内存。
  2. P 的数量是固定的(= GOMAXPROCS)。所以最多 GOMAXPROCS 个 mcache,内存占用可控。
  3. 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(处理图片、视频帧)。

老陈的提示:想想这几个方向——

  1. 能不能复用? 用 sync.Pool 缓存 1MB 的 buffer。注意:sync.Pool 在 GC 时会被清空,跨 P 访问有开销。
  2. 能不能分档? 仿照 size class,给大对象也做几档(1MB / 2MB / 4MB),每档一个 free list。
  3. 能不能绕过 Go 的堆? 直接 mmap 一块 100MB 自己切(就是我们这一节做的)。
  4. 能不能减少分配? 用 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”带来的隐性优势:

  1. 没有 header 的内存开销(小对象特别划算)
  2. 没有 buffer underflow 破坏 header 的风险
  3. 不需要处理”空闲/已分配”状态转换

Rust 也不需要:Box::new(x) 的大小编译期已知,drop 时也是编译期知道。


三者对比:

C mallocGoRust
大小信息来源运行时 header类型信息编译期 layout
内存开销16B/对象00
释放时机手动GC编译期 drop
悬垂指针可能不可能不可能(借用检查)
溢出风险header 可被破坏无 header无 header

核心洞察:运行时需要的信息越少,开销越小,安全性越高。 C 需要在运行时记录大小,因为它的类型系统编译后就”消失”了。Go/Rust 保留类型信息到编译产物里,换来了运行时开销的降低和安全性的提升。

这是一条普适规律:把信息从运行时提前到编译期,几乎总是划算的。

  • 编译期类型检查 vs 运行时类型检查
  • 静态链接 vs 动态链接
  • AOT vs JIT
  • Go 的 wire(编译期 DI)vs dig(运行时 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 会怎样?

  1. 标记阶段要遍历所有存活对象 → 115GB 的扫描量
  2. 虽然并发,但 gcAssist(让业务 goroutine 分担标记工作)会严重拖慢写入
  3. 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 · 对象地址会变,与指针语义冲突

复制后对象地址变了,所有引用都要更新。这带来三个问题:

  1. 必须能找到所有引用。这需要精确的类型信息(知道哪些字段是指针)。C/C++ 做不到(无法区分一个 8 字节是指针还是整数),所以 C++ 的 GC 只能是保守式(conservative),不能移动对象。
  2. 与 C 互操作困难。Go 的 cgo 要把 Go 指针传给 C 代码,如果 Go 对象会移动,C 那边持有的指针就失效了。
  3. 需要读写屏障。如果 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 栈增长用的就是复制式!

栈不够时:

  1. 分配一块 2 倍大的新栈
  2. 把旧栈内容整体复制过去
  3. 调整栈上所有指针(Go 有精确的栈帧信息,知道每个位置是不是指针)

为什么栈可以用复制式? 因为栈的访问是受限的——只有当前 goroutine 能访问自己的栈,不需要考虑其他线程的并发访问。约束更强,所以能用更激进的方案。

这个对比很有启发:

  • 栈:单线程访问、可以 STW 这个 goroutine → 可以用复制式
  • 堆:多线程并发访问、不能长时间 STW → 不能用复制式

又一次看到:约束决定了方案的选择空间。


一句话总结

复制式 GC 是”空间换时间 + 简洁”的极致——它用 50% 的内存,换来了 O(存活对象) 的 GC 时间、零碎片、O(1) 的分配速度。

它适合存活率低的场景(新生代、短生命周期对象密集的程序)。

Go 没选它,是因为 Go 优先保证低延迟和cgo 兼容性,代价是接受碎片(通过 size class 缓解,而非消除)。

没有”最好”的 GC,只有”最适合约束”的 GC。 理解这一点,比记住任何一种 GC 的细节都重要。


小结:这一节你应该带走的东西

  1. 四个版本的演进本身就是一部分配器设计史:bump(不能释放)→ free list(能释放但碎片)→ size class(用内部碎片换外部碎片)→ thread cache(用分片换并发)。

  2. 内部碎片与外部碎片的交易:size class 的本质。Go 用 67 档把最坏浪费控制在 12.5% 以内。

  3. 批量操作是降低慢路径频率的通用手段:从 bump 切 16 块、从 central 取 32 块、一次 atomic 预留 100 个数据点——同一个模式。

  4. 层次化缓存第三次出现:mcache(无锁)→ mcentral(per-class 锁)→ mheap(全局锁)→ OS。跟 CPU 的 L1/L2/L3/内存完全同构。

  5. 通用方案一定比专用方案慢。我们的玩具比 Go 原生快 3 倍,因为它做得更少。理解这一点,你就知道为什么”自研”要非常谨慎。

  6. 最高级的优化是发现约束,从而用更简单的方案。TSDB 的例子说明了这一点:不是让 GC 更快,而是发现”数据点生命周期一致”,从而根本不需要 GC。


下一节

05 · 编译原理:从字符到语法树

内存搞明白了,接下来是代码本身。

老陈的预告:你知道为什么 Go 的编译错误信息总是说”expected ’;’, found ‘x‘“吗?为什么 Rust 的错误提示却那么友好?这背后是两种完全不同的语法分析策略。 下一节我们从零写一个词法分析器和语法分析器,你会亲手体会到——编译器的设计,本质上是一场”如何在有限信息下做出判断”的博弈。