7.3 减少分配与 GC 压力
前两节都在调 GC 的「参数」,这一节换个方向:不改任何 GC 参数,只减少分配本身。GC 的输入是分配,把输入砍掉,输出自然变小。
本节要回答的问题是:常见的几类「少分配」写法,各自能省掉多少分配、又能否真的降低 GC 次数? 结论是:字符串拼接、slice/map 预分配、
sync.Pool三类的收益都在一个数量级以上,且同样的 200 万次迭代下 GC 次数从 6473 降到 307(约 21 倍)。本节的增量是基准数据与 GC 次数实测;「什么写法会逃逸、怎么改代码」属于卷三《Go 语言高级编程》6.1 的决策表,本节只从分配数量与 GC 压力的角度补数据。
7.3.1 实验:五类优化前后基准
复现基线
- Go 版本:
go version go1.27.0 darwin/arm64(GOTOOLCHAIN=go1.27.0) - 机器:Apple M1 Pro,10 核,32 GiB 内存;
GOMAXPROCS=10;未开-race - 基准:
go test -bench=. -benchmem -count=5,下表取 5 次区间 - GC 计数实验:
GOGC=100(默认),-benchtime=2000000x,用grep -c "^gc "数 GC 行
被测的五类写法放在同一个测试包里,对照组与优化组一一对应:
package alloc
import (
"fmt"
"strconv"
"strings"
"sync"
"testing"
)
var words = []string{"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta"}
func BenchmarkConcatNaive(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
s := ""
for j := 0; j < 64; j++ {
s += words[j%len(words)] // 每次拼接都重新分配
}
if len(s) == 0 {
b.Fatal("empty")
}
}
}
func BenchmarkConcatBuilder(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var sb strings.Builder
sb.Grow(64 * 8)
for j := 0; j < 64; j++ {
sb.WriteString(words[j%len(words)])
}
if sb.Len() == 0 {
b.Fatal("empty")
}
}
}
func BenchmarkSliceNoPrealloc(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var xs []int
for j := 0; j < 1024; j++ {
xs = append(xs, j) // 反复扩容
}
if len(xs) != 1024 {
b.Fatal("len")
}
}
}
func BenchmarkSlicePrealloc(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
xs := make([]int, 0, 1024)
for j := 0; j < 1024; j++ {
xs = append(xs, j)
}
if len(xs) != 1024 {
b.Fatal("len")
}
}
}
func BenchmarkMapNoHint(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
m := make(map[int]int) // 不给 hint,反复扩容
for j := 0; j < 256; j++ {
m[j] = j
}
if len(m) != 256 {
b.Fatal("len")
}
}
}
func BenchmarkMapWithHint(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
m := make(map[int]int, 256) // 预知容量,一次到位
for j := 0; j < 256; j++ {
m[j] = j
}
if len(m) != 256 {
b.Fatal("len")
}
}
}
func BenchmarkFmtSprintf(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
s := fmt.Sprintf("%d", i)
if len(s) == 0 {
b.Fatal("empty")
}
}
}
func BenchmarkStrconvItoa(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
s := strconv.Itoa(i)
if len(s) == 0 {
b.Fatal("empty")
}
}
}
type buf struct{ b [4096]byte }
var pool = sync.Pool{New: func() any { return new(buf) }}
var sinkAny any
func BenchmarkNoPool(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
p := new(buf)
p.b[0] = byte(i)
sinkAny = p // 存入接口,强制逃逸到堆
}
}
func BenchmarkWithPool(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
p := pool.Get().(*buf)
p.b[0] = byte(i)
pool.Put(p)
}
}
运行命令与结果:
cd /tmp/gbrt3/alloc
GOTOOLCHAIN=go1.27.0 go test -bench=. -benchmem -count=5
BenchmarkConcatNaive-10 470006 2484 ns/op 10488 B/op 63 allocs/op
BenchmarkConcatNaive-10 454257 2542 ns/op 10488 B/op 63 allocs/op
BenchmarkConcatBuilder-10 4085569 292.5 ns/op 512 B/op 1 allocs/op
BenchmarkConcatBuilder-10 4147999 282.0 ns/op 512 B/op 1 allocs/op
BenchmarkSliceNoPrealloc-10 430316 2817 ns/op 25208 B/op 12 allocs/op
BenchmarkSliceNoPrealloc-10 371859 3728 ns/op 25208 B/op 12 allocs/op
BenchmarkSlicePrealloc-10 1867983 586.9 ns/op 0 B/op 0 allocs/op
BenchmarkSlicePrealloc-10 1954765 644.4 ns/op 0 B/op 0 allocs/op
BenchmarkMapNoHint-10 79371 13445 ns/op 18856 B/op 13 allocs/op
BenchmarkMapNoHint-10 115260 11914 ns/op 18856 B/op 13 allocs/op
BenchmarkMapWithHint-10 488664 2710 ns/op 9512 B/op 3 allocs/op
BenchmarkMapWithHint-10 419073 4029 ns/op 9512 B/op 3 allocs/op
BenchmarkFmtSprintf-10 20441720 54.66 ns/op 16 B/op 2 allocs/op
BenchmarkFmtSprintf-10 22631296 56.04 ns/op 16 B/op 2 allocs/op
BenchmarkStrconvItoa-10 60215520 19.47 ns/op 7 B/op 1 allocs/op
BenchmarkStrconvItoa-10 75384379 18.13 ns/op 7 B/op 0 allocs/op
BenchmarkNoPool-10 1885946 708.1 ns/op 4096 B/op 1 allocs/op
BenchmarkNoPool-10 1560910 715.8 ns/op 4096 B/op 1 allocs/op
BenchmarkWithPool-10 126004419 9.839 ns/op 0 B/op 0 allocs/op
BenchmarkWithPool-10 129200612 8.863 ns/op 0 B/op 0 allocs/op
汇总(5 次区间):
| 对照 | 耗时 | 分配字节 | 分配次数 | 降幅 |
|---|---|---|---|---|
字符串 += | 2484–2633 ns/op | 10488 B/op | 63 | — |
strings.Builder | 273–292 ns/op | 512 B/op | 1 | 耗时 ↓8.9×,分配 ↓63× |
| slice 无预分配 | 2817–3728 ns/op | 25208 B/op | 12 | — |
slice make(…,0,1024) | 587–644 ns/op | 0 B/op | 0 | 分配归零 |
| map 无 hint | 11045–13445 ns/op | 18856 B/op | 13 | — |
map make(…,256) | 2710–4029 ns/op | 9512 B/op | 3 | 耗时 ↓3.5× |
fmt.Sprintf("%d") | 54.66–59.34 ns/op | 16 B/op | 1–2 | — |
strconv.Itoa | 18.13–22.84 ns/op | 7 B/op | 0–1 | 耗时 ↓2.7× |
每次 new(buf) | 620–782 ns/op | 4096 B/op | 1 | — |
sync.Pool 复用 | 8.86–10.44 ns/op | 0 B/op | 0 | 耗时 ↓70× |
分配次数如何变成 GC 次数
单看 allocs/op 还不够直观,把「同样的迭代次数」下 GC 触发的次数直接数出来:
cd /tmp/gbrt3/alloc
echo "== naive concat =="
GODEBUG=gctrace=1 GOTOOLCHAIN=go1.27.0 go test -run='^$' -bench='ConcatNaive$' -benchtime=2000000x 2>&1 | grep -c "^gc "
echo "== builder concat =="
GODEBUG=gctrace=1 GOTOOLCHAIN=go1.27.0 go test -run='^$' -bench='ConcatBuilder$' -benchtime=2000000x 2>&1 | grep -c "^gc "
== naive concat ==
6473
== builder concat ==
307
同样的 200 万次迭代,naive 版触发 6473 次 GC,builder 版只有 307 次,相差约 21 倍。这不是参数调出来的,而是从源头把分配量砍掉换来的——这也解释了为什么 7.1 里 GOMEMLIMIT 压不出效果:只要分配量还在,GC 就得一遍遍跑。
7.3.2 源码:分配快路径与 sync.Pool
所有堆分配最终都汇到 src/runtime/malloc.go 的 mallocgc:
// src/runtime/malloc.go:mallocgc(节选,约 1067 行)
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
// Short-circuit zero-sized allocation requests.
if size == 0 {
return unsafe.Pointer(&zerobase)
}
if sizeSpecializedMallocEnabled && size < uintptr(len(mallocNoScanTable)) {
if typ == nil || !typ.Pointers() {
if size >= maxTinySize {
return mallocNoScanTable[size](size, typ, needzero)
}
return mallocgcTinySC2(size, typ, needzero)
}
...
}
...
// Assist the GC if needed.
if gcBlackenEnabled != 0 {
deductAssistCredit(size)
}
...
}
两个关键点:尺寸特化(sizeSpecializedMallocTable 让常见尺寸直接跳到对应函数,省掉 size class 查表)和辅助标记扣费(deductAssistCredit)——后者正是 7.2 里那些 GCMarkAssistBegin 事件的来源。分配越多,用户 goroutine 被征用去标记的时间越多,这就是「减少分配」能同时改善吞吐与延迟的原因。
小对象的分配走 mcache 的本地空闲链表,nextFreeFast 是它的快路径:
// src/runtime/malloc.go:nextFreeFast(节选,约 969 行)
func nextFreeFast(s *mspan) gclinkptr {
theBit := sys.TrailingZeros64(s.allocCache) // Is there a free object in the allocCache?
if theBit < 64 {
result := s.freeindex + uint16(theBit)
if result < s.nelems {
freeidx := result + 1
...
return gclinkptr(uintptr(result)*s.elemsize + s.base())
}
}
return 0
}
sync.Pool 之所以能把 4096 字节的分配变成 0 次分配,是因为它把对象挂到每个 P 的本地池上,命中时完全不经过 mallocgc:
// src/sync/pool.go:Get(节选,约 132 行)
func (p *Pool) Get() any {
if race.Enabled {
...
}
l, pid := p.pin()
x := l.private
l.private = nil
if x == nil {
// Try to pop the head of the local shard.
x, _ = l.shared.popHead()
if x == nil {
x = p.getSlow(pid)
}
}
runtime_procUnpin()
...
return x
}
pin 会把当前 goroutine 钉在某个 P 上,从而复用该 P 的本地池;这也是为什么 sync.Pool 里的对象可能在任意一次 GC 后被清空——它的定位是「临时对象的缓存」,不是长期缓存。
7.3.3 决策:减少分配的七类手法
把实验覆盖到的手法与结论整理成清单:
| # | 手法 | 典型收益(实测) | 适用场景 |
|---|---|---|---|
| 1 | 字符串拼接改用 strings.Builder + Grow | 分配 ↓63×,耗时 ↓8.9× | 循环内拼字符串、构造大文本 |
| 2 | slice 用 make([]T, 0, n) 预分配 | 分配 12→0,耗时 ↓4.8× | 已知或可估长度的 append |
| 3 | map 用 make(map[K]V, n) 给 hint | 分配 13→3,耗时 ↓3.5× | 已知元素数量的填充 |
| 4 | fmt.Sprintf 换 strconv | 耗时 ↓2.7× | 单一数值/布尔转字符串 |
| 5 | 大对象用 sync.Pool 复用 | 耗时 ↓70×,分配归零 | 高并发下反复申请的大缓冲区 |
| 6 | 避免在热路径上构造临时切片/结构体 | 取决于逃逸,用 -gcflags='-m' 确认(见 8.3) | 请求级热路径 |
| 7 | 传值改传指针、复用对象而非每次 new | 减少单次分配 | 结构体较大的场景 |
三条纪律:
- 先 profile 再动手:
go test -benchmem看allocs/op,或-memprofile定位热点(见 4.3),不要凭感觉改。 sync.Pool不是缓存:对象会在 GC 时被清空,且只有并发下才有收益;单 goroutine 顺序分配反而可能更慢。- 分配归零不等于耗时归零:
SlicePrealloc的 0 allocs 仍要 587 ns/op,那是真实的计算与内存写入成本;减少分配省掉的是 GC 与辅助标记的连带开销。
阅读导航:上一节:7.2 GC trace 与延迟分析 · 下一节:8.1 从源码到 SSA:编译阶段与 dump 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。