Go 内存分配器概览

Go 运行时堆分配器的教学模型:按尺寸分级、P 本地缓存、span/mheap 与 GC 协作;明确区分实现细节与应用层可观察行为。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 内存分配器概览

这个概念为什么出现

业务代码里很少直接写 malloc,但几乎所有热路径都在间接分配:

  • make([]T, n)append 扩容
  • new(T)、复合字面量逃逸到堆
  • map 增长、字符串拼接、接口装箱
  • 反射、编码、日志格式化产生的临时对象

堆上每一次分配都要回答三个工程问题:

  1. 速度:热路径能否少抢全局锁?
  2. 碎片:不同尺寸对象如何复用页,避免地址空间浪费?
  3. 与 GC 协作:分配器如何更新元数据,让扫描/标记知道对象边界?

Go 运行时的分配器吸收了 TCMalloc 一类“按尺寸分级 + 线程/P 本地缓存”的思想,并随版本持续调参。具体 size class 表、字段名、缓存阈值都是实现细节,不能当语言规范背。

[!abstract] 一句话理解 小对象按尺寸档位从 P 本地缓存快速取出,大对象走专门路径;分配器与 GC 共享堆元数据。应用层优化应优先减少分配次数与存活集,而不是重写分配器。

最小可观察示例

先把示例放进可验证实验场景,再看代码:

场景:解释「批次分配后 HeapAlloc / Mallocs 为什么涨」

日志或网关热路径里,每次请求 make([]byte, 64) 拼缓冲;流量上来后 RSS 与 GC 压力跟着涨。
工程师需要合法应用层入口观察分配结果:MemStats 看趋势,Benchmark+-benchmemallocs/op
不要窥探 mcache 私有结构——size class 表是实现细节。优化优先减少分配次数与存活集。

package main

import (
	"fmt"
	"runtime"
	"testing"
)

// allocN:模拟“一次请求批次里分配 n 块 64B 缓冲”。
// 业务类比:读 body 缓冲、编码临时片、连接池外的短生命周期片。
func allocN(n int) [][]byte {
	out := make([][]byte, 0, n)
	for i := 0; i < n; i++ {
		out = append(out, make([]byte, 64))
	}
	return out
}

func main() {
	var before, after runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&before)

	sink := allocN(10_000) // 故意制造可观察分配
	runtime.ReadMemStats(&after)

	// 期望:HeapAlloc / Mallocs 相对 before 明显上升(具体数字随实现与 GC 时机波动)
	fmt.Printf("HeapAlloc delta ≈ %d KB\n", (after.HeapAlloc-before.HeapAlloc)/1024)
	fmt.Printf("Mallocs delta   = %d\n", after.Mallocs-before.Mallocs)
	_ = sink // 防止编译器认为结果无用而优化掉
}

// 配套基准(放在 *_test.go)更稳定:
func BenchmarkAlloc(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		_ = make([]byte, 64)
	}
}

建议观察:

go run .                                 # 看 MemStats 增量趋势
go test -bench=BenchmarkAlloc -benchmem  # 看 B/op、allocs/op
go tool pprof -http=:0 heap.pb.gz        # 先用 runtime/pprof 或 net/http/pprof 采样

结合场景再看关注点

  1. -benchmemB/opallocs/op 是优化反馈环主指标。
  2. MemStats 是过程量,粗看趋势,不作严格事务计数。
  3. 应用层不依赖 mcache 指针;减少分配比“懂内部 free list”更有用。

核心模型

1. 分配路径分层(教学级)

flowchart TB
    A["程序请求堆内存<br/>new / make / 逃逸对象"] --> B{"尺寸类别"}
    B -->|微小/小对象| C["P 本地缓存<br/>教学名: mcache"]
    B -->|中等对象| D["中心空闲列表<br/>教学名: mcentral"]
    B -->|大对象| E["按页直接从堆取<br/>教学名: mheap"]
    C --> F["span 上的 size class 槽位"]
    D --> F
    E --> G["多页 span"]
    F --> H["返回指针给用户代码"]
    G --> H
    H --> I["GC 扫描 / 回收 / 页复用"]

术语说明(实现命名,非规范):

教学名直觉职责
size class把“任意字节数”对齐到有限档位,便于复用
span连续页上切出的同尺寸对象区
mcache与 P 绑定的本地缓存,热路径少锁
mcentral跨 P 的某 size class 补给站
mheap向 OS 要页、管理大对象与页堆

源码入口(随版本变化,仅供阅读):runtime/malloc.goruntime/mheap.goruntime/mcache.go

2. 为什么要 size class

若每个 make([]byte, 17) 都精确按 17 字节向 OS 申请:

  • 元数据开销相对对象过大
  • 释放后极难找到刚好合适的空洞
  • 并发分配锁竞争爆炸

折中是:向上取整到档位。代价是轻微内部碎片;收益是 free list / bitmap 管理简单、分配 O(1) 量级。

3. 为何与 P(逻辑处理器)绑定

调度器把可运行的 G 放到 P 上执行。分配热路径挂在 P 本地缓存上,可让同一 OS 线程上的连续分配尽量不抢全局锁。这解释了:

  • 高并发分配时,吞吐与 GOMAXPROCS、P 数量相关
  • “分配很快”不等于“没有 GC 成本”——对象仍进入堆,仍可能被扫描

详见 调度器与 G-M-P

4. 大对象路径不同

超过一定阈值(实现定义)的对象不再塞进小对象 size class,而是按页分配。结果是:

  • 大 slice/缓冲的分配与释放路径更“重”
  • 偶发超大分配会在 profile 里非常显眼
  • 复用 []byte 缓冲池(需自己管理生命周期)往往比反复 make 大块更稳

5. 与 GC 的接口

分配器不只是“给地址”:

  • 需要让 GC 知道对象起止与指针位置(位图/类型信息等,实现细节)
  • 分配抬高 heap live / heap goal,触发 GC 辅助或并发标记
  • 回收后的 span/页可回到分配器复用,未必立刻归还 OS

应用层应读 GC guide,而不是假设 Free 语义。

6. 栈分配不走这套堆分配器

未逃逸的局部变量可在 goroutine 栈上分配,函数返回即失效,不经堆分配器与 GC 回收路径。见 逃逸分析栈与堆

规范 vs 实现分界

层次你能依赖什么不能依赖什么
语言规范有垃圾回收;无法手动 free 堆对象;语义正确性不依赖具体堆布局size class 表、span 大小、缓存结构字段名
工具与文档testing.B.ReportAllocsruntime.MemStats、pprof heap、GC guide 的调参建议某版本 mallocgc 内部分支永远不变
源码阅读帮助建立直觉、做性能诊断在业务代码 import runtime 未导出符号;用 //go:linkname 绑私有分配 API

明确结论:

  1. “小对象走本地缓存、大对象走页分配”是稳定教学模型
  2. “第 N 档是 96 字节、mcache 有多少个 slot”是版本相关实现
  3. 生产逻辑只应依赖可观测指标(allocs/op、heap profile、延迟),不依赖分配器内部几何。

边界

  1. 分配器快 ≠ 无成本
    分配本身可能很快,但增加活对象会抬高 GC 与 CPU cache 压力。

  2. 对齐与档位导致的浪费
    请求 65 字节可能落到更大 class;大量“略大一点”的对象会放大内部碎片。

  3. 终态器(finalizer)不是资源管理主路径
    回收时间不确定;文件、连接应 Close + defer

  4. 同步分配与异步 GC
    在分配路径上可能做 GC assist(实现细节),表现为“分配忽然变慢”的尾延迟。

  5. cgo / 非 Go 堆
    C 侧 malloc 不走 Go 分配器;跨界所有权要格外小心。见 cgo

常见误区

[!warning] 背 size class 表当规范 面试式背诵档位表无法保证跨版本正确,也不能指导 API 设计。用 benchmark 与 profile 说话。

[!warning] 认为“减少一次 make 就一定更快” 若对象本可栈分配,或分配不在热点,改动可能测不出差异,却降低可读性。

[!warning] 用 sync.Pool 当通用缓存银弹 Pool 适合可丢弃的临时对象复用;它不保证对象留存,也不替代正确的生命周期设计。误用会引入隐蔽的数据串扰。

[!warning] 把 runtime.GC() 塞进业务热路径“降内存” 强制 GC 会扰动调度与延迟;诊断期可用,服务默认路径应靠 GC 自调节与减少分配。

[!warning] 从源码抄未导出结构到生产 字段顺序、大小、算法都可变更;升级 Go 版本即埋雷。

工程实践

  1. 先度量分配,再谈优化
    go test -bench=. -benchmem 与 pprof heap 是第一工具。

  2. 热路径减少“可避免的堆分配”

    • 预分配:make([]T, 0, n)
    • 复用缓冲:明确所有权的 []byte 池或结构体字段复用
    • 避免热路径 fmt.Sprintf、多余 string([]byte) 转换
    • 减少接口装箱与闭包捕获导致的逃逸
  3. 区分“分配次数”和“驻留集”
    短命小对象主要打分配器与 GC;长命大对象主要打 RSS 与扫描成本。

  4. API 设计避免隐藏分配
    例如让调用方传入 []byte 缓冲,或返回值明确是否复用底层数组。

  5. 版本升级后重跑基准
    分配器与 GC 调参会变;性能结论要可复现。

  6. 可读性优先于微优化
    只在 profile 证明的热点做分配整形。

可验证实验

实验 A:预分配 vs 反复增长

func growNoCap(n int) []int {
	var s []int
	for i := 0; i < n; i++ {
		s = append(s, i)
	}
	return s
}

func growWithCap(n int) []int {
	s := make([]int, 0, n)
	for i := 0; i < n; i++ {
		s = append(s, i)
	}
	return s
}
go test -bench=grow -benchmem

预期:带 cap 的版本 allocs/op 显著更低(具体数字随 n 与版本变化)。

实验 B:heap profile 看支配项

  1. 在服务中导入 net/http/pprof 或使用 runtime/pprof
  2. 打负载后抓 heap
  3. inuse_space / alloc_space 看是谁在分配。

目标:把“感觉慢”变成“某函数每秒分配 X MB”。

实验 C:逃逸与分配联动

go build -gcflags="-m" .

对照:返回 *T、装入 interface{}、闭包捕获前后的 moved to heap 与 benchmem 变化。见 逃逸分析

本节总结

  • Go 堆分配器的教学模型是:尺寸分级 + 本地缓存 + 页堆 + 与 GC 共享元数据
  • 名字如 mcache/mcentral/mheap、具体档位表属于实现,不是语言保证。
  • 应用层正确姿势是:用 benchmem/pprof 定位,减少热路径分配与不必要存活,而不是“优化分配器”。
  • 栈上分配绕过堆分配器;逃逸分析决定对象是否进入本笔记讨论的路径。

自测题

  1. 为什么分配器要把对象归入有限 size class,而不是精确按请求字节分配?
  2. “P 本地缓存”主要优化的是锁竞争还是 GC 扫描速度?
  3. 某接口在生产 RSS 很高,但 allocs/op 很低,更可能是什么问题?
  4. 能否在业务代码中依赖 runtime 里某个 span 字段布局做对象池?
  5. 减少堆分配的三条优先手段是什么?
参考答案
  1. 精确按字节分配会导致碎片、元数据与并发管理成本过高;有限档位便于 free list/span 复用,换取轻微内部碎片。
  2. 主要优化并发分配路径的锁竞争与局部性;GC 扫描效率另有自己的机制,二者协作但不是同一目标。
  3. 更可能是长命对象驻留(缓存、全局 map、未释放的 slice 底层数组、goroutine 泄漏)而不是分配速率本身。
  4. 不能。那是未导出实现细节,版本间可变更;对象池应建立在可导出 API 与明确所有权上。
  5. (示例)预分配容量、复用缓冲/减少转换与装箱、收紧对象生命周期避免逃逸与长命引用;均以 profile 验证。

延伸阅读

创建于 2026/7/14 更新于 2026/7/15