Go语言:切片(Slice)的底层原理

Go的切片是日常开发中最常用的数据结构之一,但很多人对它的底层机制一知半解,踩坑后才回头补课。本文从源码层面拆解切片的内存布局和扩容策略。

数组 vs 切片

数组是定长的值类型,赋值和传参都会发生完整拷贝:

a := [3]int{1, 2, 3}
b := a       // 值拷贝,b 和 a 是两块独立内存
b[0] = 100
fmt.Println(a[0]) // 1,不受影响

切片是对底层数组的一个引用视图,本身是一个轻量结构体。

SliceHeader 结构

reflect.SliceHeader 定义了切片在运行时的内存布局:

type SliceHeader struct {
    Data uintptr // 指向底层数组的指针
    Len  int     // 当前长度
    Cap  int     // 当前容量
}

一个切片变量在栈上只占 24 字节(64位系统),传参成本很低。

底层数组共享

多个切片可以引用同一个底层数组,修改其中一个会影响另一个:

arr := [5]int{1, 2, 3, 4, 5}
s1 := arr[1:3] // [2, 3], len=2, cap=4
s2 := arr[2:4] // [3, 4], len=2, cap=3

s1[1] = 99
fmt.Println(s2[0]) // 99,因为 s1[1] 和 s2[0] 指向 arr[2]

这是切片最容易踩坑的地方。如果需要独立的数据,用 copyappend 到一个新切片。

append 扩容机制

append 时 len 即将超过 cap,Go 会分配新的底层数组。扩容策略在 runtime/slice.gogrowslice 函数中:

// Go 1.18+ 的扩容逻辑(简化)
func growslice(oldCap, newCap int) int {
    if newCap > 2*oldCap {
        return newCap
    }
    if oldCap < 256 {
        return 2 * oldCap // 小切片直接翻倍
    }
    // 大切片按 1.25 倍增长,逐步递增
    cap := oldCap
    for cap < newCap {
        cap += cap/4 + 192
    }
    return cap
}

核心规则:

  • 如果新长度超过旧容量的2倍,直接用新长度
  • 旧容量小于 256 时,翻倍
  • 否则按约 1.25 倍增长(加上一个常数做平滑过渡)

验证扩容行为

package main

import "fmt"

func main() {
    s := make([]int, 0)
    prevCap := 0
    for i := 0; i < 2000; i++ {
        s = append(s, i)
        if cap(s) != prevCap {
            fmt.Printf("len=%-5d cap=%-5d growth=%.2f\n",
                len(s), cap(s), float64(cap(s))/float64(max(prevCap, 1)))
            prevCap = cap(s)
        }
    }
}

func max(a, b int) int {
    if a > b { return a }
    return b
}

运行结果会清晰展示:小容量阶段倍增,大容量阶段增长率逐渐降到 1.25 左右。

常见陷阱

1. append 后原切片可能失效

s := make([]int, 3, 3)
s2 := append(s, 4) // 触发扩容,s2 指向新数组
s[0] = 100
fmt.Println(s2[0]) // 0,不是 100

2. 切片作为函数参数

切片传参传的是 SliceHeader 的拷贝。函数内修改元素会影响外部(共享底层数组),但 append 导致的扩容不会反映到外部变量:

func addItem(s []int) {
    s = append(s, 999) // 如果触发扩容,外部看不到
}

要让外部感知变化,返回新切片或传指针 *[]int

3. 大数组的内存泄漏

// 读取一个大文件到 data
data := readHugeFile() // len=1000000
// 只取前10个字节
header := data[:10]
// data 的底层数组无法被 GC,因为 header 还在引用

解决方案:copy 到一个新的小切片。

小结

理解 SliceHeader 的三元组(Data/Len/Cap)是掌握切片的关键。记住两个原则:多个切片可能共享底层数组;append 可能导致底层数组更换。写代码时遇到切片相关的 bug,先画出内存布局图,问题往往迎刃而解。