小对象(≤32KB)走mcache分配,大对象(>32KB)直连mheap;需避免逃逸、预分配、用sync.Pool复用固定尺寸对象,并控制不可信输入长度以防GC压力激增。

小对象分配走 mcache,但别让它频繁触发逃逸
Go 在高并发下能扛住大量请求,核心之一就是小对象(mcache 分配,全程无锁。但前提是:对象不能逃逸到堆上。一旦逃逸,哪怕只是个 16 字节的 struct,也会绕过 mcache 走 mcentral,引入锁竞争。
常见逃逸场景包括:
- 返回局部变量指针(如
return &smallStruct{}) - 作为函数参数传入接口类型(如
fmt.Println(x)中 x 是非接口值但被装箱) - 闭包捕获了栈上变量且该闭包被返回或长期持有
验证方式固定:go tool compile -m main.go。看到 escapes to heap 就得重构——要么改用值传递,要么预分配对象池,或者把结构体字段对齐到更紧凑尺寸(减少 span 规格跃迁)。
大对象(>32KB)直接走 mheap,注意页对齐和复用成本
单次分配超过 32KB 的内存(比如一个 64KB 的 byte slice),Go 会跳过所有缓存层级,直连 mheap 向操作系统要 page。这类分配慢、易碎片、且释放后不会立刻归还 OS(除非闲置超 5 分钟)。
实际业务中容易踩的坑:
- HTTP body 解析时未限制最大长度,导致恶意请求触发巨量大对象分配
- 日志批量写入前拼接字符串,
strings.Builder底层扩容可能跨 32KB 阈值 - protobuf 反序列化未设
SizeLimit,原始字节流稍大就升格为大对象
对策不是禁止大对象,而是控制它出现的频率和生命周期:用 sync.Pool 缓存常见尺寸的大 buffer;对不可信输入强制加长限制;关键路径避免反复 make([]byte, N),改用预置切片复用。
别迷信 sync.Pool,它只缓解分配压力,不解决根本逃逸
sync.Pool 确实能显著降低 GC 压力,但它本质是“延迟释放+复用”,不是“阻止分配”。如果对象本身因逃逸必须堆分配,sync.Pool 只是让这块内存多活几轮 GC 周期。
典型误用:
- 把本可栈分配的临时 struct 放进 Pool(浪费初始化开销,且增加 Pool 查找成本)
- Put 进去的对象仍持有外部引用(导致整个 Pool 实例无法被回收)
- 在 HTTP handler 中 New 一个 struct 再 Put,但该 struct 字段含
map或slice未清空,下次 Get 时带着脏数据
真正有效的 Pool 使用模式是:固定尺寸、无外部依赖、构造/销毁开销明显高于复用收益——比如 JSON 解码器、压缩 writer、固定大小的 ring buffer。
GC 不是万能的,高频短命对象仍是性能黑洞
即便 Go 的三色标记 GC 已做到毫秒级 STW,每秒百万级请求若产生同等数量的短命对象(如每个请求 new 一个 map[string]string),GC 标记阶段的扫描负载仍会线性上涨。这不是 GC 慢,而是“标记工作量”本身太大。
比调 GC 参数更有效的是源头控制:
- 用数组替代小 map(
[4]struct{key, val string}查找快、无指针、不逃逸) - HTTP header 解析复用
http.Header实例,而非每次make(map[string][]string) - 数据库查询结果用结构体切片接收,而非
[]map[string]interface{}
最常被忽略的一点:goroutine 泄漏比内存泄漏更隐蔽。一个忘了 close 的 channel 或阻塞的 select,会让整个 goroutine 栈 + 所有逃逸对象一直挂着——这种“活垃圾”GC 根本不收。


















