减少内存分配
问题
如何减少 Go 程序的内存分配,降低 GC 压力?
答案
为什么要减少分配
每次堆分配都意味着:
- 分配器的开销(mcache → mcentral → mheap)
- GC 需要扫描和回收的对象增加
- CPU 缓存不友好
减少分配 = 更少 GC = 更低延迟 + 更高吞吐。
优化技巧
1. 预分配 Slice
// ❌ 频繁扩容
var result []int
for i := 0; i < n; i++ {
result = append(result, i) // 多次扩容,每次复制
}
// ✅ 预分配
result := make([]int, 0, n)
for i := 0; i < n; i++ {
result = append(result, i) // 不触发扩容
}
2. strings.Builder 拼接字符串
// ❌ 每次拼接创建新字符串
s := ""
for i := 0; i < 1000; i++ {
s += strconv.Itoa(i) // O(n²) 分配
}
// ✅ strings.Builder
var b strings.Builder
b.Grow(4000) // 预估容量
for i := 0; i < 1000; i++ {
b.WriteString(strconv.Itoa(i))
}
s := b.String()
3. 避免不必要的 []byte ↔ string 转换
// ❌ 每次转换都分配新内存
func contains(s string, sub string) bool {
return bytes.Contains([]byte(s), []byte(sub)) // 2 次分配
}
// ✅ 直接用 strings 包
func contains(s string, sub string) bool {
return strings.Contains(s, sub) // 0 分配
}
4. 用值类型代替指针
// ❌ 指针可能导致逃逸
type Point struct{ X, Y float64 }
func newPoint() *Point { return &Point{1, 2} } // 逃逸到堆
// ✅ 值类型在栈上
func newPoint() Point { return Point{1, 2} } // 留在栈上
5. 数组代替 Slice(已知小尺寸)
// ❌ slice 的底层数组可能分配在堆上
buf := make([]byte, 64)
// ✅ 数组在编译时确定大小,通常在栈上
var buf [64]byte
6. 复用对象(sync.Pool)
var bufPool = sync.Pool{
New: func() any { return make([]byte, 0, 4096) },
}
func process() {
buf := bufPool.Get().([]byte)[:0]
defer bufPool.Put(buf)
// 使用 buf...
}
7. 避免装箱(interface 参数)
// ❌ int 装箱为 interface{} 导致堆分配
func log(v any) { fmt.Println(v) }
log(42) // 42 被装箱
// ✅ 用泛型避免装箱
func log[T any](v T) { fmt.Println(v) }
log(42) // 不装箱
8. 减少 fmt.Sprintf
// ❌ fmt.Sprintf 内部大量分配
s := fmt.Sprintf("%d", n)
// ✅ strconv 直接转换
s := strconv.Itoa(n)
// strconv.FormatFloat, strconv.FormatBool 等
测量分配
func BenchmarkXxx(b *testing.B) {
b.ReportAllocs() // 报告分配统计
for i := 0; i < b.N; i++ {
// 被测代码
}
}
// 输出: BenchmarkXxx-8 5000000 256 ns/op 64 B/op 2 allocs/op
// ↑ ↑
// 每次字节数 每次分配次数
# 逃逸分析
go build -gcflags="-m" ./...
# pprof 查看分配热点
go tool pprof -alloc_objects http://localhost:6060/debug/pprof/heap
常见面试问题
Q1: Go 中哪些操作是"零分配"的?
答案:
- 栈上的局部变量(不逃逸)
- 数组和固定大小的结构体(小于一定阈值)
strings.Builder最终的String()(Go 1.10+ unsafe 优化)strconv.Itoa、strconv.FormatInt等小数字转换- 预分配的 slice 不触发 append 扩容
Q2: 如何写零分配的代码?
答案:
核心原则:
- 避免逃逸——返回值不返回指针
- 预分配——slice 和 map 给够容量
- 复用对象——sync.Pool
- 避免 interface——用泛型或具体类型
- 避免 fmt——用 strconv