松林AI说头像
关注
Go 切片扩容机制:深入剖析 append 源码及安全改写方案封面图

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

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

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--