← [ BACK_TO_NOTES ]

Go 内存分配器概览

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

#concept #growing #dev #go

[!info] related notes

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+-benchmem 看 allocs/op。
不要窥探 mcache 私有结构——size class 表是实现细节。优化优先减少分配次数与存活集。

package main

import (
	"fmt"
	"runtime"
)

// 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", (int64(after.HeapAlloc)-int64(before.HeapAlloc))/1024)
	fmt.Printf("Mallocs delta   = %d\n", after.Mallocs-before.Mallocs)
	runtime.KeepAlive(sink) // 保证观察窗口内这些缓冲仍存活;_ = sink 没有此保证
}

保存到单独的 alloc_test.go:

package main

import "testing"

var benchmarkBuffer []byte

// 故意让结果逃逸;测量的是保留缓冲引用的调用场景。
func BenchmarkAlloc(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		benchmarkBuffer = make([]byte, 64)
	}
}

第一个代码块保存为新模块的 main.go,先 go mod init example.com/allocator-demo;第二个放入 alloc_test.go。两者放同一目录后运行:

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

结合场景再看关注点

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

核心模型

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

小对象:mcache 的 span 槽位
           ↓ 槽位耗尽,补给 span
        mcentral(相应 size class)
           ↓ 无可用 span,申请页
        mheap → 必要时向 OS 申请

大对象:直接按页从 mheap 分配,绕过 mcache/mcentral

这是层次模型;微小无指针对象还可能共享 tiny allocation block,具体阈值与快路径以工具链源码为准。

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

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

本篇按 Go 1.26.8 源码核对层次:mcache 无可用槽位时向 mcentral 补给 span,mcentral 再向 mheap 要页;不存在“中等对象直接走 mcentral”的独立尺寸分类。源码入口(随版本变化,仅供阅读):runtime/malloc.go、runtime/mheap.go、runtime/mcache.go。

2. 为什么要 size class

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

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

折中是:向上取整到档位。代价是内部碎片;缓存命中时取槽很快,但补给 span、清零大块、GC assist 等使总体分配延迟并非固定 O(1)。

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

调度器把可运行的 G 放到 P 上执行。分配热路径挂在 P 本地缓存上,可让持有该 P 的 M 在缓存命中时避免全局锁;缓存随 P,而不是固定绑定某个 OS 线程。这解释了:

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

详见 调度器与 G-M-P。

4. 大对象路径不同

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

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

5. 与 GC 的接口

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

  • 需要让 GC 知道对象起止与指针位置(位图/类型信息等,实现细节)
  • 分配消耗当前堆增长预算,可能触发 GC 或分配辅助;heap goal 由 pacer、GOGC 与内存限制等共同决定,并非每次分配都直接抬高目标
  • 回收后的 span/页可回到分配器复用,未必立刻归还 OS

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

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

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

规范 vs 实现分界

层次你能依赖什么不能依赖什么
语言规范有垃圾回收;无法手动 free 堆对象;语义正确性不依赖具体堆布局size class 表、span 大小、缓存结构字段名
工具与文档testing.B.ReportAllocs、runtime.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. 区分“分配次数”和“驻留集”
    分配率、存活字节与指针密度共同影响成本;大 []byte 的数据区无需按指针扫描,但仍占内存。RSS 还包含未归还的空闲页、栈及非 Go 内存。

  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
}

把两个 grow 函数加入 main.go,在 alloc_test.go 追加:

var benchmarkInts []int

func BenchmarkGrow(b *testing.B) {
    b.Run("NoCap", func(b *testing.B) {
        for i := 0; i < b.N; i++ { benchmarkInts = growNoCap(1024) }
    })
    b.Run("WithCap", func(b *testing.B) {
        for i := 0; i < b.N; i++ { benchmarkInts = growWithCap(1024) }
    })
}
go test -run='^$' -bench=BenchmarkGrow -benchmem .

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

实验 B:heap profile 看支配项

  1. 在服务中导入 net/http/pprof 或使用 runtime/pprof。
  2. 打负载后抓 heap。
  3. inuse_space 查看仍存活对象的分配栈,alloc_space 查看累计分配栈;heap profile 不直接给出“哪个对象正在持有引用”的完整引用图。

累计 alloc_space 不是每秒分配速率;要计算速率需限定采样窗口或比较差值,并说明采样误差。

实验 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. 不能仅凭这两个指标归因:可能是长命对象、未归还空闲页、栈或 cgo/mmap。应结合 HeapAlloc、HeapReleased、栈指标和分配 profile 缩小范围。
  4. 不能。那是未导出实现细节,版本间可变更;对象池应建立在可导出 API 与明确所有权上。
  5. (示例)预分配容量、复用缓冲/减少转换与装箱、收紧对象生命周期避免逃逸与长命引用;均以 profile 验证。

延伸阅读

创建于 2026/7/14 更新于 2026/10/7
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本