内存分配器深挖
# 18.内存分配器深挖
卷三第十八篇——Go 的堆分配器不是"在堆上找一块空闲内存"那么简单。它是 tcmalloc 思想的工程化落地:67 个 size class 把对象分配变成查表,
mcache让每个 P 有自己的一级缓存(无锁),mcentral做二级缓冲(有锁但不竞争),mheap管 OS 页面的最终入口。tiny allocator 把多个小于 16 字节的小对象挤进同一个 slot——本质上是个"微观的内存池"。读完本篇,你能回答:为什么new(int)走 mcache → 极快,但make([]byte, 65536)直接走 mmap?67 个 size class 的知识浪费率分布是怎样的?mallocgc为什么不是一个人就能写的"简单函数"——它的 switch-case 背后是四级缓存的协作契约。关键词:tcmalloc、mspan 状态机、size class、mcache 无锁、mcentral 锁策略、tiny allocator、mallocgc 主流程。
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. mspan 页管理单元
- 4. mcache 每 P 缓存
- 5. mcentral 全局供给
- 6. mheap OS 接口层
- 7. 三条分配路径
- 8. mallocgc 主流程源码
- 9. 与 jemalloc/tcmalloc 对比
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段崩在哪
看一个 JSON API 网关——它接收上游请求、反序列化、做字段转换、序列化响应返回。生产环境跑了半年,某天响应对象平均大小从 1200 字节涨到 1550 字节(业务逻辑增加了几个新字段),RSS 突然从 2GB 飙到 3.5GB,CPU 中 runtime.mallocgc 占比从 3% 升到 12%:
// api_gateway.go —— JSON 网关
package main
import (
"encoding/json"
"net/http"
)
type APIResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
TraceID string `json:"trace_id"`
// 新增字段——导致对象变大
Extra map[string]string `json:"extra,omitempty"`
Meta *ResponseMeta `json:"meta,omitempty"`
}
type ResponseMeta struct {
ServerTime int64 `json:"server_time"`
Region string `json:"region"`
NodeID string `json:"node_id"`
}
func handleAPI(w http.ResponseWriter, r *http.Request) {
// 每个请求分配一个新的 Response——大小约 1550 字节
resp := &APIResponse{
Code: 0,
Message: "success",
TraceID: generateTraceID(),
}
// 填充业务数据...
json.NewEncoder(w).Encode(resp)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
现象:
- 旧版 Response 约 1200 字节——分配器走到 size class 38(1216 字节槽)——浪费率 1.3%
- 新版 Response 约 1550 字节——跨过了 class 边界——走到 size class 40(1792 字节槽)——浪费率 13.5%
- 每个 Response 浪费 1792 - 1550 = 242 字节。QPS 5000 → 每秒 ~1.2MB 浪费 → RSS 虚高
- 更严重的是:class 从 1216→1792 导致每个 span(1 页 = 8KB)只能放 4 个对象(之前 6 个)→ 需要更多 span → mcentral 锁竞争加剧
关联合并:CPU profile 显示热点在 runtime.mcentral.cacheSpan(mcentral 锁竞争)和 runtime.memclrNoHeapPointers(零值初始化)。RSS 虚高 + GC 扫描更多的 span → GC CPU 从 5% 升到 18%。
这个事故不是"内存泄漏"——是分配器的 size class 边界效应:对象大小跨过 class 边界 → 内部浪费率跃升 → 连锁影响 span 密度、mcentral 竞争、GC 扫描量。
# 1.2 顺藤摸到根因
追查过程:
第一步:确认对象大小——pprof heap profile 看 APIResponse 的 inuse 分布:
$ go tool pprof -inuse_space http://localhost:6060/debug/pprof/heap
(pprof) top
# APIResponse 约 3.1GB inuse —— 但实际有效数据只有 ~2.7GB
# 差了 ~400MB → 来自 size class 的对齐浪费
2
3
4
第二步:追踪 size class——用 runtime.ReadMemStats 或 GODEBUG 看分配统计:
var m runtime.MemStats
runtime.ReadMemStats(&m)
// 计算:HeapInuse - HeapAlloc ≈ 内部碎片
fmt.Printf("堆碎片: %.1fMB\n", float64(m.HeapInuse-m.HeapAlloc)/1024/1024)
// → 堆碎片: 420MB —— 主要是 size class 的 padding 浪费
2
3
4
5
第三步:验证 class 边界效应——分析每一类对象的大小分布:
对象类型 大小 class 槽大小 浪费
Response(旧) 1200B 38 1216B 16B (1.3%)
Response(新) 1550B 40 1792B 242B (13.5%) ← 关键!
Extra map 不定 动态 — —
Meta 48B 4 48B 0B
2
3
4
5
这个事故藏着 7 个原理点:
① 67 个 size class 是怎么设计的——每个 class 的知识浪费率控制在多少? → 第 3 章
② mcache 为什么能无锁分配——per-P 设计的并发保证? → 第 4 章
③ mcentral 的锁什么时候会竞争——高分配率下的锁行为? → 第 5.3
④ tiny allocator 怎么把多个小对象挤进一个 slot? → 第 7.1
⑤ 大对象(>32KB)为什么跳过 mcache 和 mcentral? → 第 7.3
⑥ mallocgc 的完整路径——从 entry 到 return 的每一步? → 第 8 章
⑦ Go 分配器和 jemalloc/tcmalloc 的设计差异——goroutine 安全 vs 线程安全? → 第 9 章
2
3
4
5
6
7
# 1.3 我们要回答什么
这个 API 网关案例贯穿全篇。我们从 mspan 的位图分配机制出发,深入到 mcache/mcentral/mheap 四级缓存的协作流程——再分析 size class 设计表的每一级浪费率——最后用 mallocgc 的完整源码路径串起所有知识点。
本篇路线:
tcmalloc 思想 (第 2 章) ── 为什么 Go 自己造了一个分配器
↓
mspan 页管理 (第 3 章) ── 位图分配 + 状态机
↓
mcache 无锁缓存 (第 4 章) ── per-P 设计 + alloc 数组
↓
mcentral 全局供给 (第 5 章) ── partial/full 链表 + cacheSpan
↓
mheap OS 接口 (第 6 章) ── 页分配 + arena + scavenger
↓
三条分配路径 (第 7 章) ── tiny / small / large
↓
mallocgc 主流程 (第 8 章) ── 入口代码 + GC 协作
↓
jemalloc 对比 (第 9 章) ── 设计哲学 + 性能对比
↓
综合案例 (第 10 章) ── 修复网关 + 设计哲学
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
📌 本篇定位:第 01 篇第五章用 4 节概览了四级分配器——本篇是对它的深度展开。第 17 篇讲 GC 怎么回收——本篇讲分配器怎么"生产"。两者是一个硬币的两面:分配器决定"对象怎么放"(size class、span 密度),GC 决定"对象怎么收"(扫描效率、归还时机)。
# 2. 架构概览
# 2.1 tcmalloc 速览
Go 分配器的设计灵感来自 Google 的 tcmalloc (Thread-Caching Malloc)——核心思想就一个:给每个线程(Go 中是每个 P)一个本地缓存,大多数分配从本地缓存走——无锁。
tcmalloc 思想 → Go 分配器映射:
tcmalloc Go 对应 说明
───────── ──────── ────
ThreadCache → mcache (per-P) 本地缓存——无锁分配
CentralCache → mcentral (per-class) 全局供给——有锁但竞争少
PageHeap → mheap 从 OS 申请页——全局锁
Span → mspan 页管理单位——size class 的容器
SizeClass → 67 个 size class 按对象大小分桶
2
3
4
5
6
7
8
9
为什么 Go 不用 malloc/free——见第 01 篇 2.2 的疑惑—论证—结论(malloc 不知道指针位图、goroutine 需要小栈、无锁优先)。本篇不再重复——直接展开 Go 自己的实现。
# 2.2 Go 分配器四层模型
goroutine 调用 new(T) 或 make([]byte, n)
│
▼
┌──────────────────────────────────────┐
│ 1. mcache (per-P) │ ← 一级缓存——无锁最快速
│ alloc[spanClass] 数组 │
│ tiny + tinyoffset 微型分配器 │
├──────────────────────────────────────┤
│ 2. mcentral (per-spanClass) │ ← 二级缓冲——有锁但竞争少
│ 136 个 (68 classes × 2) │
│ partial[2] + full[2] spanSet │
├──────────────────────────────────────┤
│ 3. mheap (全局一个) │ ← 三级总管——全局锁
│ pageAlloc 页分配器 │
│ central[136] mcentral 嵌入 │
│ arenas 虚拟地址空间管理 │
├──────────────────────────────────────┤
│ 4. OS (mmap / sysAlloc) │ ← 终极入口——系统调用
│ 匿名 mmap 映射 │
│ madvise 归还 │
└──────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
数据流:
99% 的分配走:
mcache → O(1) 拿空闲槽 → 返回
0.9% 的分配走:
mcache 用完了 → mcentral.cacheSpan() → 拿到新 mspan → 给 mcache → 返回
0.1% 的分配走:
mcentral 也没了 → mheap.alloc(npages) → OS mmap → 初始化 mspan → 返回
2
3
4
5
6
7
8
# 3. mspan 页管理单元
# 3.1 完整的字段解读
mspan 是 Go 分配器的最小管理单位——一个 span 对应 N 个连续的 8KB 页,页内切成固定大小的槽:
// runtime/mheap.go (简化)
type mspan struct {
next *mspan // mcentral 双向链表的前驱
prev *mspan // mcentral 双向链表的后继
list *mSpanList // 所属的 mcentral 链表 (partial/full)
startAddr uintptr // 这段内存的起始地址 (虚拟地址)
npages uintptr // 这段 span 包含的页数 (1+)
spanclass spanClass // 大小级 (0~67) + noscan 标志位
state mSpanState // 状态: mSpanInUse / mSpanManual / mSpanFree
nelems uint16 // 这个 span 中有多少个槽
allocCount uint16 // 已分配的对象数 (allocCount == nelems → 满)
allocBits *gcBits // 分配位图: 每个 bit 代表一个槽——1=已分配, 0=空闲
gcmarkBits *gcBits // GC 标记位图: 每个 bit——1=被标记为存活
freeIndex uintptr // 从该位置开始找空闲槽——加速查找
elemsize uintptr // 每个槽的大小 (= class_to_size[spanclass])
limit uintptr // span 的内存结束地址
specials *special // finalizer 链表 (Go 对象的析构函数)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
字段分组理解:
| 组 | 字段 | 作用 |
|---|---|---|
| 链成员 | next/prev/list | 挂在 mcentral 的 partial/full 链表上 |
| 地址 | startAddr/npages/limit | 这段内存的起止 |
| 分类 | spanclass/elemsize | 属于哪个 size class——决定了槽的大小 |
| 分配 | allocBits/allocCount/freeIndex | 哪些槽被分配了、还有多少空闲 |
| GC | gcmarkBits | GC 标记——哪些槽被标记为存活 |
| 状态 | state | mSpanInUse(正在用)/ mSpanFree(空闲)/ mSpanManual(用于栈等) |
# 3.2 分配位图与 freeIndex
allocBits 是分配的核心机制——它是一个位图,每个 bit 代表一个槽:
mspan: startAddr = 0xc000100000, nelems = 8, elemsize = 128B
内存布局:
0xc000100000 [slot0] ← allocBits[0] = 1 (已分配)
0xc000100080 [slot1] ← allocBits[1] = 1 (已分配)
0xc000100100 [slot2] ← allocBits[2] = 0 (空闲) ← freeIndex = 2
0xc000100180 [slot3] ← allocBits[3] = 0 (空闲)
...
0xc000100380 [slot7] ← allocBits[7] = 1 (已分配)
2
3
4
5
6
7
8
9
分配一个槽——从 freeIndex 开始找第一个 0 bit:
// runtime/mbitmap.go (简化)
func (s *mspan) nextFreeIndex() uintptr {
// 从 freeIndex 位置开始扫描 allocBits
// 找到第一个 0 bit → 置为 1 → 更新 freeIndex → 返回槽索引
s.allocCount++
freeIndex := s.freeIndex
// 位运算加速: 一次检查 64 个 bit
for i := freeIndex / 64; uintptr(i) < uintptr(s.nelems)/64; i++ {
if s.allocBits[i] != ^uint64(0) { // 不是全 1——有空位
bit := sys.Ctz64(^s.allocBits[i]) // 找第一个 0 的位置
s.allocBits[i] |= 1 << bit // 置为 1
s.freeIndex = uintptr(i)*64 + uintptr(bit) + 1
return s.freeIndex - 1
}
}
// 全是 1——满了
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
freeIndex 的优化意义——避免每次都从 0 开始扫描。这是一个"单调递增"的搜索起点——虽然有碎片化的可能,但大多数场景下分配和释放是局部性的——freeIndex 的近似效果很好。
# 3.3 状态机与 mcentral 链表
mspan 在 mcentral 的不同链表之间迁移——构成一个状态机:
mheap.alloc(npages)
→ mspan state = mSpanInUse
│
v
┌────────────────────────┐
│ mcentral.partial │ ← allocCount < nelems (有空间)
│ (非满 span) │
└────┬──────────────┬────┘
│ allocCount++ │ allocCount-- (sweep 回收)
│ (分配) │
v │
┌────────────────────────┐
│ mcentral.full │ ← allocCount == nelems (满了)
│ (满 span) │ 等待下一次 GC sweep
└────────┬───────────────┘
│ GC sweep 回收对象
│ allocCount 减少
v
┌────────────────────────┐
│ mcentral.partial │ ← 又变成非满——可重新分配
│ (回到 partial) │
└────────────────────────┘
│
│ allocCount == 0 (所有对象都回收了)
v
┌────────────────────────┐
│ mheap free 列表 │ ← 整个 span 空闲——归还 mheap
│ (可回收给 OS) │
└────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 4. mcache 每 P 缓存
# 4.1 结构体完整字段
// runtime/mcache.go
type mcache struct {
nextSample uintptr // 采样计数器——触发 heap profile
// 小对象分配缓存——按 spanClass 索引
alloc [numSpanClasses]*mspan // 136 个槽——68 classes × 2 (scan/noscan)
// Tiny 分配器
tiny uintptr // 当前 tiny 块的起始地址
tinyoffset uintptr // 当前 tiny 块的已用偏移 (最大 16B)
tinyAllocs uintptr // tiny 分配计数
// 栈缓存——预分配 goroutine 栈
stackcache [_NumStackOrders]stackfreelist
// 清扫代——用于 GC 并发清扫
sweepgen uint32
// 每个 P 的 flush 指针——用于批量将 mcache 归还 mcentral
flushGen uint32
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
关键:alloc 数组有 numSpanClasses = 136 个槽。为什么是 136?68 个 size class × 2(scannable 和 noscan)。noscan 的 span 中的对象不包含指针——GC 扫描时直接跳过——大幅减少扫描开销。
# 4.2 无锁分配的并发保证
mcache 是每个 P 一个——存储在 P 的 mcache 字段中:
// runtime/runtime2.go (简化)
type p struct {
mcache *mcache // ← 每个 P 有自己的 mcache
// ...
}
2
3
4
5
为什么无锁——goroutine 总是绑定到一个 P 上执行。在 P 上执行的 goroutine 访问 P.mcache——同一时刻只有一个 goroutine 在访问这个 mcache——所以不需要加锁。
Goroutine 迁移时不破坏并发安全——如果 goroutine 在系统调用后迁移到另一个 P,它不再访问原来的 P 的 mcache。新的 goroutine 使用新 P 的 mcache——同样是无锁的。
# 4.3 alloc 数组与 spanClass 映射
spanClass 编码了 class ID 和 noscan 标志:
// 一个 spanClass 同时表示 size class + 是否有指针
type spanClass uint8
// 从对象大小计算出 spanClass
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}
// sizeclass: 0~67 → spanClass: 0~135
// 偶数 = scan(有指针), 奇数 = noscan(无指针)
2
3
4
5
6
7
8
9
分配时的选择逻辑:
// 如果有指针字段 → 用 scan span (GC 需要扫描)
var user User // User 包含 *Image 指针 → scan
// 如果全是值类型字段 → 用 noscan span (GC 跳过)
var buf [256]byte // 全是 byte → noscan
2
3
4
5
# 5. mcentral 全局供给
# 5.1 partial 与 full 双向链表
当一个 size class 的 mcache.alloc 用完了——去对应的 mcentral 申请:
// runtime/mcentral.go
type mcentral struct {
spanclass spanClass
// partial[0]: 未清扫的非满 span
// partial[1]: 已清扫的非满 span
partial [2]spanSet
// full[0]: 未清扫的满 span
// full[1]: 已清扫的满 span
full [2]spanSet
}
// spanSet 是基于 treap 的无锁集合——替代了旧版的双向链表
type spanSet struct {
spine atomic.UnsafePointer // 指向 []atomicSpanSetSpine
spineLen atomic.Uintptr
spineCap atomic.Uintptr
index atomicSpanSetIndex // head + tail 的索引
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
为什么 partial/full 各有两个——[0] 是未清扫的(swept=0),[1] 是已清扫的(swept=1)。GC 清扫时清理 span 中的白色对象——清扫后的 span 从 full[0] 移到 full[1] 或 partial[1]——可供分配。
# 5.2 cacheSpan 的补充逻辑
// runtime/mcentral.go (简化)
func (c *mcentral) cacheSpan() *mspan {
// 1. 优先从 partial[1](已清扫的非满 span)中拿
if s := c.partial[1].pop(); s != nil {
return s
}
// 2. 从 partial[0](未清扫的非满 span)中拿——先清扫
if s := c.partial[0].pop(); s != nil {
// 清扫这个 span——回收白色对象
s.sweep(false)
return s
}
// 3. 从 full[1](已清扫的满 span)中拿——可能清扫后有空间
if s := c.full[1].pop(); s != nil {
s.sweep(false)
return s
}
// 4. 从 mheap 申请新的 mspan
s := c.grow()
return s
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
核心逻辑:优先复用已清扫的 span(已清扫的不需要再扫描 GC 位图)→ 其次清扫未清扫的 span(可能能回收空间)→ 最终向 mheap 申请新的。
# 5.3 锁竞争分析
旧版 mcentral 用 sync.Mutex——高分配率场景下多个 P 的 mcache 同时用完同一个 size class 时——锁竞争明显。
Go 1.20+ 改进——用 spanSet(基于 lock-free 数据结构)替代双向链表 + Mutex——减少锁竞争。但 mcentral 层面的竞争依然存在——只是从"互斥锁"改为"CAS 循环"。
压力来源——mcache 的每个 size class 只缓存一个 mspan。当一个 mspan 的槽全部分配完——mcache 就空了——下次分配需要去 mcentral。如果这个 size class 的分配非常高频——就会频繁触发 mcentral 访问。
# 6. mheap OS 接口层
# 6.1 页分配器实现
当 mcentral 也没有 span 时——mheap 从 OS 申请:
// runtime/mheap.go (简化)
type mheap struct {
lock mutex // 全局堆锁
pages pageAlloc // 位图式页分配器——管理所有 arena 的空闲页
allspans []*mspan // 所有 span 的列表
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
}
// 分配 npages 个连续页——返回初始化的 mspan
func (h *mheap) alloc(npages uintptr, spanclass spanClass) *mspan {
h.lock()
// 1. 从 pageAlloc 分配 npages 个连续页
base, scav := h.pages.alloc(npages)
if base == 0 {
// 2. 虚拟地址空间不够 → 从 OS 申请新的 arena
h.sysAlloc(64 << 20) // 64MB arena
base, scav = h.pages.alloc(npages)
}
h.unlock()
// 3. 初始化 mspan——切割页为槽
s := h.allocMSpanLocked()
s.init(base, npages, spanclass)
return s
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
CacheLinePad 的设计——central 数组中每个 mcentral 后跟了 pad——填充到 CPU 缓存行大小(64 字节)。这避免了伪共享(false sharing)——两个相邻的 mcentral 不在同一缓存行——各自独立。
# 6.2 arena 与 heapArena
Go 的堆内存以 arena 为单位从 OS 申请——每个 arena 是 64MB 的连续虚拟地址空间:
Go 堆的虚拟地址空间 (64位):
高地址
┌────────────────────────────┐
│ arena[0] = 64MB │ ← heapArena
│ pages[0..8191] │ 8KB/page → 8192 pages
├────────────────────────────┤
│ arena[1] = 64MB │
├────────────────────────────┤
│ ... │
└────────────────────────────┘
低地址
2
3
4
5
6
7
8
9
10
11
12
// runtime/mheap.go (简化)
type heapArena struct {
// bitmap: 每 2 个字记录一个指针大小的单元
// 第一个字: 是否是指针 (gc marker)
// 第二个字: 是否已被扫描
bitmap [heapArenaBitmapBytes]byte
// spans: 每页指向其所属的 mspan
spans [pagesPerArena]*mspan
// pageInUse: 哪些页正在使用
pageInUse [pagesPerArena / 8]uint8
// pageMarks: GC 标记位图
pageMarks [pagesPerArena / 8]uint8
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 6.3 scavenger 归还 OS
scavenger 在后台扫描空闲 span——将"长时间未使用"的物理页通过 madvise(MADV_DONTNEED) 归还 OS:
// runtime/mgcscavenge.go (简化)
func (s *scavengerState) run() {
// 后台 goroutine——定期运行
// 1. 从 mheap 的 free 列表中找空闲 span
// 2. 如果 span 已空闲 > 5 分钟 → madvise(MADV_DONTNEED)
// 3. OS 回收物理页 → RSS 降
}
2
3
4
5
6
7
详见第 01 篇 8.3——本篇不再展开。
# 7. 三条分配路径
# 7.1 Tiny 微型分配器
Tiny allocator 是 mcache 内嵌的一个特殊优化——将多个小于 16 字节的无指针对象挤进同一个 16 字节块:
// runtime/malloc.go (简化)
const maxTinySize = 16
func (c *mcache) tinyAlloc(size uintptr) unsafe.Pointer {
off := c.tinyoffset
// 当前 tiny 块还有空间——塞进去
if off+size <= maxTinySize && c.tiny != 0 {
x := unsafe.Pointer(c.tiny + off)
c.tinyoffset += size
c.tinyAllocs++
return x
}
// 当前 tiny 块不够——分配新的 16 字节块
span := c.alloc[tinySpanClass]
v := span.freeIndex // 取一个 16 字节槽
c.tiny = uintptr(v)
c.tinyoffset = size
c.tinyAllocs++
return v
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Tiny allocator 的精妙示例:
一个 tiny 块 (16 字节) 可以紧凑存放:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│ B1 │ B2 │ B3 │ B4│ 空闲 │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
3B 3B 3B 1B 6B free
B1: bool (1B) + int16 (2B) 组合: struct { flag bool; val int16 }
B2: 3B string header 在 32 位系统
B3: [3]byte
B4: byte
2
3
4
5
6
7
8
9
10
限制:只有无指针的小对象才能走 tiny allocator——因为如果 tiny 块中有指针,GC 会扫描整个块——如果块中的某个"对象"已经不再被引用但块还没满——该对象的内存不会被独立回收。
# 7.2 Small 按级匹配
Small 对象(> 16B 且 ≤ 32KB)走标准路径:
// 分配流程:
size := 64 // 要分配 64 字节
class := size_to_class8[size/8] // 查表 → class=5
span := c.alloc[spanClass]
v := span.freeIndex // 取空闲槽
2
3
4
5
67 个 size class 片段(完整见表 10.4 速查):
| class | 对象大小 | 每页槽数 | 每槽浪费 | 浪费率 |
|---|---|---|---|---|
| 0 | 0 (tiny) | - | - | - |
| 1 | 8B | 1024 | 0 | 0% |
| 2 | 16B | 512 | 0 | 0% |
| 3 | 24B | 341 | 0.01B | <0.1% |
| 4 | 32B | 256 | 0 | 0% |
| ... | ... | ... | ... | ... |
| 38 | 1216B | 6 | ~18B | 1.5% |
| 39 | 1344B | 6 | ~15B | 1.1% |
| 40 | 1536B | 5 | ~28B | 1.8% |
| ... | ... | ... | ... | ... |
| 67 | 32768B | 0(跨页) | - | - |
浪费率设计目标——每个 size class 的知识浪费率控制在 5% 以内。大多数 class 的浪费率 < 2%。第 1 章案例中的 class 40(1550→1792 actually 1536)跳跃较大——因为 32KB 以下要保证槽能在单页内放下。
# 7.3 Large 直接 OS 路径
大对象(> 32KB)不经过 mcache/mcentral 三级缓存——直接到 mheap 分配专用 span:
// runtime/malloc.go (简化)
if size > maxSmallSize { // 32768
npages := size / pageSize
if size%pageSize != 0 {
npages++
}
span := mheap_.alloc(npages, spanClass)
span.freeindex = 1 // 整个 span 就是一个对象
span.allocCount = 1
span.nelems = 1
x := unsafe.Pointer(span.base())
}
2
3
4
5
6
7
8
9
10
11
12
为什么跳过三级缓存:
- 大对象的 mspan 只包含一个对象——存进 mcache 缓存意义不大(用一次就完了)
- 大对象占用多页——如果进 mcache——其他 size class 的分配会被延迟(mcache 一个 class 只缓存一个 span)
- 大对象的分配频率低——不值得为它做三级缓存
# 8. mallocgc 主流程源码
# 8.1 入口与路径分发
mallocgc 是所有堆分配的入口——从 new(T) 到 make([]int, 10) 最终都走到这个函数:
// runtime/malloc.go (简化但保持结构)
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
// ===== 辅助 GC =====
// 如果 GC 在进行且分配速度超过标记速度 → 协助 GC 标记
if assistG != nil {
assistG.gcAssistBytes -= int64(size) // 消耗 assist 配额
}
// ===== 获取当前 P 的 mcache =====
c := getMCache()
// ===== 路径 1: Tiny (< 16B, 无指针) =====
if size <= maxTinySize && noscan {
off := c.tinyoffset
if off+size <= maxTinySize && c.tiny != 0 {
x = unsafe.Pointer(c.tiny + off)
c.tinyoffset += size
c.tinyAllocs++
return x
}
// tiny 块不够 → 从 mcache 的 tinySpanClass 拿新的 16B 槽
span := c.alloc[tinySpanClass]
v := span.nextFreeIndex()
x = unsafe.Pointer(v)
c.tiny = uintptr(x)
c.tinyoffset = size
return x
}
// ===== 路径 2: Small (≤ 32KB) =====
var span *mspan
if size <= maxSmallSize {
// 查 size class 表
if noscan && size < maxSmallSize {
size = uintptr(class_to_size[size_to_class8[divRoundUp(size, 8)]])
}
spc := makeSpanClass(size_to_class8[divRoundUp(size, 8)], noscan)
span = c.alloc[spc]
if span == nil {
span = mheap_.central[spc].cacheSpan() // ← 去 mcentral
}
v := span.freeIndex
if span.allocCount == span.nelems { // 满了
c.alloc[spc] = nil // ← mcache 清空该 class → 下次去 mcentral
}
x = unsafe.Pointer(v)
}
// ===== 路径 3: Large (> 32KB) =====
else {
span = mheap_.alloc(npages, spanClass)
x = unsafe.Pointer(span.base())
}
// ===== 初始化 =====
if needzero && span.needzero {
memclrNoHeapPointers(x, size) // 零值初始化
}
// ===== 写入对象头 (GC 元数据) =====
if !noscan {
// 设置对象头的 type 指针——供 GC 获取 gc mask
*(*uintptr)(unsafe.Pointer(uintptr(x) - 8)) = uintptr(unsafe.Pointer(typ))
}
return x
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# 8.2 与 GC 的协作
分配过程中三个与 GC 的交汇点:
gcAssistBytes——如果 GC 并发标记在进行且分配速度太快——goroutine 被要求协助标记——消耗 assist 配额。如果配额耗尽——goroutine 被暂停直到 GC catch up(mutator assist)。
写屏障——对象分配后第一次写入指针时——写屏障记录这个写入——防止 GC 漏标(见第 17 篇)。
span.needzero——GC 清扫后返回的 span 已经被 memclr 过(除非是大对象路径从 OS 直接分配——需要手动清零)。
# 8.3 零值初始化的优化
Go 保证所有分配的内存都被零值初始化(和 C 的 malloc 不确定值不同)。这在性能上有一个精妙的优化:
// runtime/malloc.go (简化)
if needzero {
if span.needzero {
memclrNoHeapPointers(x, size) // 清零
}
// 否则 span 已经被 GC sweep 清零过——跳过
}
2
3
4
5
6
7
GC sweep 预先清零——清扫 phase 将所有回收对象的槽置零——这样下次分配时不需要再清零。这在高频分配场景下节约大量 CPU——等同"清零和 GC 清扫合并为一个操作"。
# 9. 与 jemalloc/tcmalloc 对比
# 9.1 设计哲学差异
| Go | tcmalloc | jemalloc | |
|---|---|---|---|
| 缓存粒度 | per-P (mcache) | per-thread (ThreadCache) | per-thread (tcache) + per-CPU (percpu_arena) |
| size class 数量 | 67 + tiny | ~85 | ~200+ (精细分级) |
| 大对象阈值 | 32KB | 256KB (kMaxSize) | 可配置 (默认 14KB~) |
| GC 集成 | 紧密——写屏障、gcmarkBits、GC pacer | 无(垃圾回收由应用负责) | 无 |
| 元数据开销 | 每对象 16B 对象头(_type 指针 + GC 位) | 每对象 0~8B(取决于使用方式) | 每对象 ~8B(取决于配置) |
| 归还 OS | scavenger 后台 (5min) | 定期 tcmalloc::ReleaseFreeMemory() | 定期 jemalloc::malloc_stats_print() |
# 9.2 性能对比速览
- 小对象分配:Go 的 mcache 无锁路径 ≈ 20ns(比 tcmalloc 的 ThreadCache 快——因为没有线程局部存储的查找)
- 跨线程分配:Go 无此场景(goroutine 不跨 P 访问 mcache)——tcmalloc 跨线程需要 CentralCache 加锁
- 大对象:三者的 mmap 性能相近——差异在数微秒以内
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章 API 网关的七个疑问,逐条作答:
| 疑问 | 答案 |
|---|---|
| ① 67 个 size class 怎么设计——浪费率? | 第 3 章:每 class 浪费率 <5%——但在 class 边界(如 1216→1536)有跳跃——第 1 章现象 |
| ② mcache 为什么能无锁? | 第 4.2:per-P——同一时刻只有一个 goroutine 访问——天然无锁 |
| ③ mcentral 锁何时竞争? | 第 5.3:高频分配耗尽 mcache 的同一 class → 多个 P 竞争 mcentral.cacheSpan |
| ④ tiny allocator 怎么工作? | 第 7.1:多个 ≤16B 无指针对象挤进一个 16B 槽——组合分配 |
| ⑤ 大对象为什么跳过三级缓存? | 第 7.3:mspan 只包含一个对象——缓存无意义——直接 mheap |
| ⑥ mallocgc 的完整路径? | 第 8.1:assist → 路径分发 → tiny/small/large → 初始化 → 对象头 → return |
| ⑦ Go vs jemalloc/tcmalloc 差异? | 第 9 章:Go 有 GC 集成和写屏障——jemalloc/tcmalloc 是纯分配器 |
案例完整根因链条:
Response 大小从 1200B → 1550B → 跨 size class 边界
→ old: class 38 (1216B) → 6 个/span → 浪费 1.3%
→ new: class 40 (1536B) → 5 个/span → 浪费 ~5%*
* 实际上 1550 > 1536 → class 40 不够 → class 41 (1664B?)
等等——我需要修正: 第 1 章说 1550B → 1792B——这需要核对 size class 表
class 39: 1344B
class 40: 1536B
→ 1550B > 1536 → class 41: 1664B? 浪费 114B (6.8%)
或者 1550 到 class 40 的 1536 不够 → 下一个 class: 1792B (class 41 是 1664, class 42 是 1792)
→ 浪费 1792 - 1550 = 242B (13.5%)
→ 每个 span 只能放 4 个对象(之前 6 个)→ 更多 span → mcentral 竞争加剧
→ 内部碎片增多 → RSS 虚高 ~400MB
→ GC 扫描更多 span → GC CPU 从 5% 升到 18%
2
3
4
5
6
7
8
9
10
11
12
13
14
修复方案:
// ✅ 方案 A: 用 sync.Pool 复用 Response 对象——减少分配频率
var respPool = sync.Pool{
New: func() interface{} { return &APIResponse{} },
}
func handleAPIV2(w http.ResponseWriter, r *http.Request) {
resp := respPool.Get().(*APIResponse)
defer respPool.Put(resp)
// 只重置需要变化的字段——减少分配 + zeroing 开销
resp.Code = 0
resp.Message = "success"
resp.Data = nil
resp.Extra = nil
resp.Meta = nil
// 填充业务数据...
json.NewEncoder(w).Encode(resp)
}
// ✅ 方案 B: 结构体字段排列——减少 padding
// 将 Meta 字段改为值类型而非指针——避免额外 8B 指针 + 堆分配
type APIResponseV2 struct {
Meta ResponseMeta `json:"meta,omitempty"` // 值类型
Data json.RawMessage `json:"data"`
Extra map[string]string `json:"extra,omitempty"`
TraceID string `json:"trace_id"`
Message string `json:"message"`
Code int `json:"code"`
}
// 重新排列后: 可能在 class 39 (1344B) 内 → 每 span 6 个 → 浪费率更低
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 10.2 一次分配的完整旅程
var resp = &APIResponse{}
─────────────────────────────────────────────────────────
│
├─ 编译期: 逃逸分析
│ &APIResponse{} → 返回给调用方 → 逃逸到堆
│ → 编译器生成: CALL runtime.newobject(SB)
│
├─ runtime.newobject → runtime.mallocgc(size=1552, typ=...)
│
├─ Step 1: GC 协作
│ assistG.gcAssistBytes -= 1552
│ 如果 assist 配额不足 → 协助 GC 标记——暂停业务
│
├─ Step 2: 路径分发
│ 1552 > 16 → 跳过 tiny
│ 1552 ≤ 32768 → 走 small 路径
│ size_to_class8[1552/8] = size_to_class8[194]
│ (查 size class 表) → class 42 (1792B)
│ spanClass = (42<<1) | 1 (noscan=1? ——有指针字段 → noscan=0)
│ spanClass = 84 (偶数 → scan——GC 需要扫描)
│
├─ Step 3: mcache 尝试
│ c.alloc[84] → 检查 span 是否有空闲槽
│
│ ├─ 有空闲槽:
│ │ freeIndex → 从 allocBits 找第一个 0 → 返回槽地址
│ │ allocCount++ (如果达到 nelems → c.alloc[84] = nil——清空缓存)
│ │ → 返回: unsafe.Pointer(0xc0001a0000)
│ │ (约 20ns——无锁)
│ │
│ └─ 无空闲槽 (span 满了) / 无 span:
│ c.alloc[84] = nil → 去 mcentral
│
│ Step 4: mcentral 缓存
│ mheap_.central[84].cacheSpan()
│ ├─ partial[1] (已清扫的非满) → 有 → 返回
│ ├─ partial[0] (未清扫的非满) → sweep → 返回
│ └─ 都没有 → 去 mheap
│ (~1µs + sweep 时间——有锁)
│
│ Step 5: mheap 分配
│ mheap_.alloc(npages=1, spanclass=84)
│ ├─ pageAlloc.alloc(1) → 找到 1 个 8KB 空闲页
│ ├─ 切出 1792B × 4 = 7168B 的 4 个槽 (+ 1024B 浪费在碎片)
│ ├─ 分配 allocBits = [0,0,0,0]——4 个槽初始全空闲
│ └─ 返回 mspan → 给 mcentral → 给 mcache → 执行 Step 3
│ (~10µs + mmap 可能更慢——全局锁)
│
├─ Step 6: 零值初始化
│ memclrNoHeapPointers(0xc0001a0000, 1552)
│ → 将 1552B 全部置零——汇编级 memset
│
├─ Step 7: 写入对象头
│ *objectHeader = &_type.APIResponse
│ GC 位: white
│ 写屏障: 记录这个写入 (如果需要)
│
└─ 返回: unsafe.Pointer(0xc0001a0000)
用户代码: resp.Code = 0, resp.Message = "success" ...
写屏障记录每次指针写入 → GC 可见
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# 10.3 设计哲学回扣
哲学 1:用 size class 表做"空间换时间"——O(1) 查表替代 O(logn) 查找
Go 分配器不在堆上"找"合适大小的块——它直接查 size class 表:size_to_class8[size/8]。这个表在编译时就初始化好了——67 个条目——一次数组索引就完成匹配。代价是内部碎片(每个对象平均浪费 ❤️% 的大小)——但换来了 20ns 的分配速度。这是 Go 分配器"快"的核心。
哲学 2:per-P 设计是 Go 并发模型的自然延伸——分配器不需要发明锁
mcache 的"无锁"不是因为它用了 CAS 或 lock-free 数据结构——是因为 GMP 模型本身就保证了每个 P 上只有一个 goroutine 执行。分配器直接挂在这个模型上——不需要额外的同步机制。如果把 mcache 改成 per-G——G 迁移到不同 P 时就会产生数据竞争。per-P 是 GMP 模型下的最优选择。
哲学 3:tiny allocator 把"分配器的微观优化"做到极致
16 字节的一个 slot——里面挤了 3~4 个小对象。如果每个都单独分配——需要 3~4 次 mcache 操作——GC 需要扫描 3~4 个独立对象头。tiny allocator 把它们合并成一次——节约的不只是分配时间——还有 GC 扫描的开销。
哲学 4:三级缓存 + 大对象直通——"热路径"和"冷路径"的时间成本分离
99% 的分配走 mcache(~20ns)、0.9% 走 mcentral(~1µs)、0.1% 走 mheap(~10µs+)。这个三级缓存和 CPU 的 L1/L2/L3 缓存一样——把最频繁的放在最快、最无锁的地方——把稀疏的推送更慢、更有锁的地方。大对象 >32KB 不走缓存——因为它们本身就太"重"了——不值得为一次分配预热三级缓存。
# 10.4 速查表
67 个 size class 完整表(32 位和 64 位通用部分):
| class | size | npages | 每页槽数 | 浪费 | 浪费率 |
|---|---|---|---|---|---|
| 0 | 0 (tiny) | - | - | - | - |
| 1 | 8B | 1 | 1024 | 0 | 0% |
| 2 | 16B | 1 | 512 | 0 | 0% |
| 3 | 24B | 1 | 341 | 0.01B | 0.04% |
| 4 | 32B | 1 | 256 | 0 | 0% |
| 5 | 48B | 1 | 170 | 0.12B | 0.25% |
| 6 | 64B | 1 | 128 | 0 | 0% |
| ... | ... | ... | ... | ... | ... |
| 40 | 1536B | 1 | 5 | 28B | 1.82% |
| 67 | 32768B | 4(跨页) | 1 | 0 | 0% |
三条分配路径速查:
| 路径 | 对象大小 | 容器 | 路径 | 耗时(估) | GC扫描 |
|---|---|---|---|---|---|
| Tiny | ≤ 16B, noscan | mcache.tiny 组合 | mcache → O(1) | ~15ns | 跳过 (noscan) |
| Small | ≤ 32KB | mspan 的槽 | mcache → mcentral → mheap | ~20ns~10µs | 按 class 扫描 |
| Large | > 32KB | 专用 mspan | mheap → OS mmap | ~10µs+ | 标记为 no-span-scan |
诊断命令:
# 查看 heap profile —— 内存 inuse / alloc 统计
go tool pprof http://localhost:6060/debug/pprof/heap
go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap
# 查看 size class 分配统计
GODEBUG=allocfreetrace=1 ./app 2>&1 | grep "malloc"
# 查看 HeapInuse vs HeapAlloc —— 内部碎片
curl -s localhost:6060/debug/pprof/heap?debug=1 | grep -E "Heap(Inuse|Alloc)"
# 查看 mspan 统计
curl -s localhost:6060/debug/pprof/heap?debug=1 | grep "MSpan"
2
3
4
5
6
7
8
9
10
11
12
下一篇:我们已经把 Go 分配器的四级结构、67 个 size class、三条分配路径和 mallocgc 主流程剖开,下一步进入 19.defer延迟执行机制——把
_defer链表、栈分配 defer(1.13)、开放编码 defer(1.14+)的三代演进剖开。