Files
cs-note/hzh/GolangStar/Go语言原理/slice原理.md
T

6.1 KiB

tags, create time
tags create time
go
golang
go-principle
slice
2026-06-07 15:10

Slice 底层原理

概述

本文从 runtime 源码角度深入解析 Go Slice 的三字段结构、扩容策略(含 1.18 版本变更),以及截取、复制、append 等操作背后的内存行为。Slice 是 Go 中最常用的数据结构之一,理解其底层机制能有效避免常见的"切片共享数组"陷阱。

[!question] ❓ 思考 为什么 arr2 := arr1[1:3] 之后修改 arr2[0] 会影响 arr1?Slice 赋值到底是深拷贝还是浅拷贝?如果容量满了再 append,Go 会怎么做?

正文

一、Slice 的结构:一个指针 + 两个长度

Slice 在 runtime 层面的定义非常简洁:

// src/runtime/slice.go
type slice struct {
    array unsafe.Pointer // 指向底层数组
    len   int            // 当前已使用的元素个数
    cap   int            // 底层数组的总容量
}

三个字段共 24 字节(64 位系统)。用一个图来描述:

graph LR
    S["slice struct<br/>array | len=3 | cap=5"] --> A["底层数组<br/>[1][2][3][4][5]"]
    style S fill:#e1f5fe
    style A fill:#fff9c4
字段 含义 示例值
array 指向底层连续内存的指针 0xc00001a180
len 可访问的元素数量 3
cap 底层数组总大小(不含偏移) 5

[!tip] 💡 理解要点 cap >= len 始终成立。cap - len 表示还能追加多少个元素而不触发扩容。

二、Slice 的三种初始化方式

// 1. 从已有切片截取
s1 := s[low : high : max]

// 2. 字面量初始化
s2 := []int{1, 2, 3}

// 3. make 创建
s3 := make([]int, 3)       // len=3, cap=3
s4 := make([]int, 3, 5)    // len=3, cap=5

注意截取操作 s[low : high : max] 中:

  • 新切片的 len = high - low
  • 新切片的 cap = max - low
  • 新切片与原切片共享同一块底层数组

三、截取与赋值:共享底层数组

这是初学者最容易踩坑的地方——截取和赋值都不会复制底层数据:

src := []int{1, 2, 3, 4, 5}
dst := src[1:3]       // dst 指向 src 的底层数组
dst[0] = 100          // src 变成了 [1, 100, 3, 4, 5]

这是因为 slice 赋值实际上只是复制了三个字段(24 字节),其中 array 指针指向同一片内存区域:

flowchart LR
    src["slice[src]<br/>array→▲"] --> A["底层数组<br/>[1][100][3][4][5]"]
    dst["slice[dst]<br/>array→▲"] --> A
    style A fill:#fff9c4

[!warning] ⚠️ 常见陷阱 函数间传递 slice 时,接收方对元素的修改会影响调用方。如果需要完全独立的副本,必须用 copy() 或手动分配新切片。

如何真正复制一个 slice

src := []int{1, 2, 3, 4, 5}
dst := make([]int, len(src))
copy(dst, src)        // 真正的独立副本
dst[0] = 100           // src 不受影响

四、Append 与扩容策略

4.1 Append 的行为

append 的本质是对 slice 结构体的值拷贝——每次调用都产生一个新的 slice 结构体(但共享底层数组),然后写入新元素:

arr1 := make([]int, 0, 4)
arr1 = append(arr1, 1)    // arr1=[1]
arr2 := append(arr1, 2)   // arr2=[1,2]
arr3 := append(arr1, 3)   // arr3=[1,3] — arr2 被覆盖了!

为什么 arr2 变成了 [1, 3]?因为 arr1、arr2、arr3 共享同一底层数组,arr3 的写入覆盖了 arr2 写入的位置。

4.2 扩容公式

当 len == cap 时,append 需要分配更大的底层数组。扩容策略经历了版本变化:

Go 1.17 及之前:

flowchart TD
    Start["cap < 1024?"] -->|是| Doubles["newCap = oldCap * 2"]
    Start -->|否| Quarter["newCap = oldCap + oldCap/4"]
    Doubles --> Check{"newCap < 需要的容量?"}
    Quarter --> Check
    Check -->|是| Doubles2["newCap = newCap * 2"]
    Check -->|否| Final["newCap = 需要的容量"]
    Doubles2 --> Final

Go 1.18 及之后(更平滑的增长曲线):

newCap = oldCap + (oldCap + 3*256) / 4

这个公式让小容量时接近翻倍,大容量时逐渐趋近于 1.25 倍,避免了大切片扩容时的内存浪费。

[!note] 📝 源码要点 扩容逻辑位于 src/runtime/slice.go 的 growslice 函数。它会根据元素类型(是否包含指针)选择不同的内存分配策略,并在某些情况下直接复用旧数组(当容量增长不超过 2 倍时)。

4.3 growslice 核心流程

// src/runtime/slice.go (伪代码简化)
func growslice(et *_type, old slice, cap int) slice {
    if et.ptrdata == 0 {
        // 无指针类型:一次 mallocgc 分配 hchan + buf
        newBuf = mallocgc(newSize, nil, true)
        memmove(newBuf, old.array, old.len)  // 拷贝旧数据
    } else {
        // 有指针类型:分别分配
        newBuf = mallocgc(newSize, et, true)
        memmove(newBuf, old.array, old.len)
    }
    return slice{array: newBuf, len: old.len, cap: newCap}
}

关键点:

  • 扩容一定会分配新内存并拷贝旧数据
  • 如果元素包含指针,GC 会在拷贝后的新区间上重新扫描
  • 当请求的新容量不超过旧容量的 2 倍时,Go 会尝试直接扩展原有内存(零拷贝扩容)

五、性能建议

  1. 预分配容量:已知最终大小的场景,用 make([]T, 0, n) 避免多次扩容
  2. 截取慎用:截取后原数组无法被 GC 回收整片内存(只要有一个切片引用着)
  3. 深拷贝需求:用 copy() 而非切片赋值,避免隐式共享

小结

  • Slice 是 (ptr, len, cap) 三字段结构,本质是一个"窗口"指向底层数组
  • 截取和赋值都是浅操作,共享底层数组;深拷贝需用 copy()
  • 扩容策略因版本而异:1.17 前是小容量翻倍/大容量加 1/4,1.18+ 用平滑公式
  • 预分配容量能显著减少扩容带来的开销

关联笔记