Go 切片扩容机制:深入剖析 append 源码及安全改写方案

前言
有个线上服务,每天定时 OOM。查了三天,发现是一个 slice 不断 append 导致内存反复分配和 GC。
slice 扩容这个看似简单的操作,在高并发下会要了你的命。今天聊聊 slice 扩容的底层实现。
一、底层原理
1.1 Slice 扩容机制
slice 就是对数组的"切片",扩容时要分配新数组并复制旧数据:
graph TD
A["append 触发扩容"] --> B{"容量 < 256?"}
B -->|是| C["2倍扩容"]
B -->|否| D["1.25倍扩容"]
C --> E["分配新数组"]
D --> E
E --> F["复制旧数据"]
F --> G["返回新 slice"]
G --> H["旧数组变垃圾"]
H --> I["增加 GC 压力"]
关键点:
- 小于 256 时 2 倍扩容
- 大于等于 256 时 1.25 倍扩容
- 扩容要分配新内存
- 大 slice 扩容耗时显著
1.2 不同扩容策略对比
| 策略 | 优点 | 缺点 |
|---|---|---|
| 2 倍 | 扩容次数少 | 浪费空间 |
| 1.25 倍 | 节省空间 | 扩容次数多 |
| 预分配 | 最理想 | 需要预知大小 |
二、快速上手
看 slice 扩容的行为:
package main
import (
"fmt"
)
func main() {
s := make([]int, 0)
oldCap := cap(s)
for i := 0; i < 10000; i++ {
s = append(s, i)
newCap := cap(s)
if newCap != oldCap {
fmt.Printf("扩容: 容量 %d -> %d (元素 %d 个)\n", oldCap, newCap, i)
oldCap = newCap
}
}
}
可以看出,扩容次数其实不多。但问题是每次扩容都要做内存分配和数据拷贝。
三、核心 API / 深水区
3.1 减少扩容次数的方法速查
| 方法 | 做法 | 效果 |
|---|---|---|
| 预分配 | make([]T, 0, cap) | 最好 |
| 估算大小 | 按业务估算 | 好 |
| 分片追加 | 小批量操作 | 中 |
| 链表代替 | sync.List | 结构不同 |
3.2 预分配才是王道
// 不预分配:多次扩容
var s []int
for i := 0; i < 1000000; i++ {
s = append(s, i)
}
// 预分配:零次扩容
s := make([]int, 0, 1000000)
for i := 0; i < 1000000; i++ {
s = append(s, i)
}
3.3 扩容的并发安全
// 并发 append 导致 data race
var s []int
go func() { s = append(s, 1) }()
go func() { s = append(s, 2) }()
// 加锁保护
var mu sync.Mutex
mu.Lock()
s = append(s, 1)
mu.Unlock()
四、实战演练
对比预分配和不预分配的性能:
package main
import (
"fmt"
"time"
)
func main() {
n := 10000000
// 不预分配
start := time.Now()
var s1 []int
for i := 0; i < n; i++ {
s1 = append(s1, i)
}
fmt.Printf("不预分配: %v, len=%d\n", time.Since(start), len(s1))
// 预分配
start = time.Now()
s2 := make([]int, 0, n)
for i := 0; i < n; i++ {
s2 = append(s2, i)
}
fmt.Printf("预分配: %v, len=%d\n", time.Since(start), len(s2))
// 直接索引
start = time.Now()
s3 := make([]int, n)
for i := 0; i < n; i++ {
s3[i] = i
}
fmt.Printf("直接索引: %v, len=%d\n", time.Since(start), len(s3))
}
不预分配比预分配慢很多,直接索引最快。
五、避坑指南与最佳实践
💡 **技巧:知道大小就预分配make([]T, 0, cap) 是你的朋友。
⚠️ **警告:大 slice 复制很贵
1 GB 的 slice 扩容一次,复制 1 GB 数据。
✅ **推荐:小 slice 频繁 append 没事
小于 256 的大小,2 倍扩容效率高。
六、综合实战演示
高性能 slice 操作:
package main
import (
"fmt"
"sync"
"time"
)
type SafeSlice struct {
mu sync.Mutex
data []int
}
func NewSafeSlice(cap int) *SafeSlice {
return &SafeSlice{
data: make([]int, 0, cap),
}
}
func (s *SafeSlice) Append(val int) {
s.mu.Lock()
s.data = append(s.data, val)
s.mu.Unlock()
}
func (s *SafeSlice) Len() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.data)
}
func (s *SafeSlice) GetAll() []int {
s.mu.Lock()
defer s.mu.Unlock()
// 返回副本,避免并发访问
result := make([]int, len(s.data))
copy(result, s.data)
return result
}
func main() {
n := 1000000
workers := 100
s := NewSafeSlice(n)
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < workers; i++ {
wg.Add(1)
go func(base int) {
defer wg.Done()
for j := 0; j < n/workers; j++ {
s.Append(base + j)
}
}(i * (n / workers))
}
wg.Wait()
fmt.Printf("安全追加 %d 个元素: %v\n", s.Len(), time.Since(start))
}
七、总结
slice 扩容要点:
- 知道大小就预分配
- 扩容开销在分配和复制
- 并发 append 要加锁
- 大 slice 扩容成本高
预分配是最高性能的做法,能省就省。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/cannonjinx/article/details/161688173




