Interview Lab / Go / Slice

Go Slice 从 0 到底层:先搞懂“为什么”,再记 len、cap

Slice(切片)是 Go 中常用的可变长度数据集合。标题里的 len 表示当前有几个元素,cap 表示原数组最多还能利用多少个位置;先不用背公式,正文会从具体数据一步步把它们推导出来。

日常可以直接创建 Slice 并追加数据;需要处理已有数据的一部分时,也可以把它想成 Excel 里框出来的一块“选区”,不用先复制整份数据。

这篇先解决“它平时怎么用、为什么这样设计”,再一步步推导 len(当前能访问多少个元素)、cap(原数组最多还能利用多少个元素)、扩容和底层内存,适合第一次系统学习 Slice 的读者。

len:当前元素数量cap:原数组可利用范围append:追加元素扩容:容量不足时换数组
版本边界:本文的语言规则依据 Go 语言规范,扩容细节依据当前 Go runtime(Go 运行时,负责内存分配和垃圾回收等底层工作)的 slice.go。源码实现会演进,不要把某个版本的容量序列当成永远不变的语法规则。
一、先会用:Slice 日常就是一串可追加的数据

学习 Slice 不需要先创建数组,也不需要先背底层结构。最常见的方式,是直接写出一组数据:

s := []int{10, 20, 30}
fmt.Println(s) // [10 20 30]

如果一开始还没有数据,可以用 make(Go 内置的创建函数)得到一个空 Slice,再用 append(Go 内置的追加函数)逐个加入元素:

s := make([]int, 0)
s = append(s, 10)
s = append(s, 20)
s = append(s, 30)

直接给出数据

[]int{10, 20, 30} 适合已经知道初始内容的场景。

从空集合开始

make([]int, 0) 配合 append,适合循环收集查询结果、日志或用户输入。

第一步只记这个:Slice 是一串长度可以变化的数据。数组切片、len、cap 和扩容,是解释后续特殊行为时才需要引入的知识。
二、为什么又会出现数组:便宜地选中已有数据的一部分

下面代码不是在说“创建 Slice 必须先创建数组”。这里的 a 代表已经存在的一整批数据,s 代表现在只想处理其中一部分:

a := [8]int{10, 20, 30, 40, 50, 60, 70, 80}
s := a[2:5] // 只看 30、40、50

a[2:5] 遵守“左边包含、右边不包含”:从下标 2 开始,取下标 2、3、4,不取下标 5。它更像在 Excel 表格中框出一个选区,而不是复制一份新表格。

a 是整批数据,s 是其中的一个选区 下标 010 下标 120 下标 230 下标 340 下标 450 下标 560 下标 670 下标 780 s := a[2:5],只框选,不复制 右边的 5 是停止位置,所以选中的最后一个下标是 4。
最应记住:s 没有复制出一份 30、40、50,它只是选中了 a 中的三个位置。

为什么不默认复制?

假设已有 100 万个元素,现在只处理第 100 到 199 个。直接切出 data[100:200],只需建立一个很小的描述;如果每次都复制,还要重新申请内存并搬运 100 个元素。数据越大、切分越频繁,这个差距越明显。

设计取舍:Slice 用“共享已有数据”的复杂内存关系,换取少申请内存、少复制数据的运行效率。理解这个存在理由后,后面的三个字段就可以从需求自然推导出来。
三、从需求推导结构:为什么必须有 pointer、len、cap

既然 Slice 只是一个选区,Go 至少要记住三件事:选区从哪里开始、现在框了几个元素、追加数据时原数组最多还能利用到哪里。于是可以一步步得到 pointer(起点地址)、len(length 的缩写,表示当前长度)和 cap(capacity 的缩写,表示最大可利用容量)。

实际问题需要记录的信息正式字段
选区从原数据的哪里开始?起点的内存地址pointer / array
现在能正常访问几个元素?当前元素数量len
append 前最多还能利用多少位置?从起点到可利用范围末尾的总位置数cap

这三个信息合起来,正式名称叫 Slice Header(切片头,也就是记录这个选区信息的小结构)。它在当前 Go 运行时实现中可理解为下面三个字段;其中 unsafe.Pointer 是只记录内存地址、不附带元素类型的底层指针:

type slice struct {
    array unsafe.Pointer // 指向底层数组中窗口起点
    len   int            // 当前能直接访问的元素个数
    cap   int            // 从起点到当前底层数组末尾最多还能容纳多少元素
}

不要先背公式:直接从数组里数出 3 和 6

仍看 s := a[2:5]。当前选中的下标是 2、3、4,所以 len 是 3;从起点下标 2 一直到数组末尾,还能利用 2、3、4、5、6、7 六个位置,所以 cap 是 6。cap 不是“还剩几个空位”,而是从 Slice 起点开始的整个可利用范围。

字段决定什么越界规则
array第 0 个元素从哪里开始它可能指向底层数组中间,不一定是整个数组首地址
len当前 Slice 的逻辑长度索引必须满足 0 <= i < len
cap不换底层数组时最多能扩到哪里重新切片的上界通常不能超过 cap
Slice Header:s := a[2:5] array→ a[2] len3 cap6 底层数组 a:长度 8 01234567 len = 3:当前可直接访问 cap = 6:从 a[2] 到数组末尾
先数位置,再记公式:当前选中的 2、3、4 是 len=3;从 2 数到数组末尾的 2~7 是 cap=6。Slice Header 很小,真正的数据仍在底层数组。
关键结论:把 Slice 传给函数时,复制的是这三个字段;复制后的两个 Slice 仍可能指向同一块底层数组。
四、共享底层数组:为什么改一个 Slice,另一个也变了

先做一个只改变创建方式的对照

自己创建一组数据

own := []int{30, 40, 50}
own[0] = 999

own 修改的是自己这组数据,不涉及变量 a。

从 a 框出一段

view := a[2:5]
view[0] = 999

view[0] 对应 a[2],所以 a 也会变化。

区别不在“Slice 能不能修改”,而在它们是否指向同一个底层数组。底层数组就是实际连续存放元素的那块数据;多个 Slice 可以用不同选区共享它。

base := []int{10, 20, 30, 40, 50}
left := base[1:3]  // [20 30],len=2,cap=4
right := base[2:5] // [30 40 50]
left[1] = 999       // 实际改的是 base[2]

此时 base 变成 [10 20 999 40 50],right[0] 也变成 999。原因不是“Slice 互相复制”,而是它们的窗口重叠在同一个底层数组上。

两个 Slice 窗口可以看到同一个数组位置 left := base[1:3]覆盖下标 1、2 right := base[2:5]覆盖下标 2、3、4 底层数组 base [0]10 [1]20 [2] 重叠999 [3]40 [4]50 left right
最应记住:left[1] 和 right[0] 都是底层数组的 base[2],改的是同一个内存位置。

普通切片表达式

s[low:high] 得到 len=high-low,cap=cap(s)-low。high 决定当前窗口长度,low 同时移动指针并减少容量。

完整切片表达式

s[low:high:max] 得到 cap=max-low。它可限制新 Slice 使用原数组的剩余容量,常用于控制后续 append 是否必须分配新数组。

child := parent[:2:2] → len(child)=2,cap(child)=2 → 再次 append 必须扩容

完整切片表达式不是复制数据。它只是把 cap 截短,让下一次越过 cap 的 append 更早触发扩容,从而与原数组分离。

同一个选区,max 决定 append 能否继续使用原数组 普通:a[2:5]append 999 → 写入 a[5] [0]10 [1]20 [2]30 [3]40 [4]50 [5]60 → 999 [6]70 [7]80 append 前 len = 3 append 前 cap = 6:仍能使用下标 5、6、7 完整:a[2:5:5]max = 5:可利用范围在下标 5 之前停止 [0]10 [1]20 [2]30 [3]40 [4]50 [5]60 [6]70 [7]80 cap = 3,append 999 必须创建新数组;原数组的 60 不变
最应记住:第三个下标 max 不复制数据,只把 cap 截到指定边界;因此下一次 append 会更早与原数组分离。

传给函数为什么也会改到外面?

func change(s []int) {
    s[0] = 999
}

nums := []int{10, 20, 30}
change(nums) // nums 变成 [999 20 30]

Go 的参数传递仍然是值传递:函数得到的是 Slice Header 的一份副本,不是全部元素的副本。两份 Header 中的地址仍指向同一个底层数组,所以通过其中一份修改元素,另一份也能看到。

值传递复制了“说明书”,没有复制数组元素 main 中的 nums Header地址 → 数组起点len = 3 cap = 3 change 中的 s Header地址 → 同一个数组起点len = 3 cap = 3 复制三个字段 唯一的一块底层数组 [0]999 [1]20 [2]30 change 执行 s[0] = 999真正修改的是共享数组的第 0 个元素
最应记住:函数里外各有一份 Slice Header,但两份地址指向同一个数组,所以修改元素会影响外部;这仍然是值传递。
一句话记忆:说明书复制了,仓库没有复制;两张说明书仍然带你去同一个仓库取货。
五、append 的两条路:容量够就原地写,不够才扩容

append(Go 内置的追加函数)会把新元素放到 Slice 末尾。容量不足时,它会进入 growslice(运行时负责扩大 Slice 容量的函数),再通过 memmove(复制连续内存的底层操作)搬走旧元素。

append(s, 要追加的数据) 追加后的长度 ≤ 当前 cap?新数据是否仍放得下 容量足够:复用原数组把新值写入原底层数组只更新 len,地址通常不变 容量不足:growslice1. 计算目标 cap2. 按内存规格分配新空间3. memmove 复制旧元素4. 返回新的地址 / len / cap 是否
最应记住:append 是否更换底层数组,只取决于新长度能否放进当前 cap;容量足就共享,容量不足才分配和复制。
必须接住 append 的返回值:s = append(s, x)。append 可能返回指向新数组的新 Slice Header;忽略返回值,调用者仍拿着旧窗口。

为什么“改没改原数组”不能只看 append 这个动作

要看 append 前的 len 与 cap。如果容量够,append 复用原数组,其他共享者可能看到变化;如果容量不够,append 换新数组,之后的修改通常与旧数组分离。

六、扩容算法:不是永远 2 倍,也不是固定 1.25 倍

growslice 先调用 nextslicecap(根据新长度和旧容量计算目标容量的函数)。当前实现的主线可以简化为:

func nextslicecap(newLen, oldCap int) int {
    newcap := oldCap
    doublecap := newcap + newcap

    if newLen > doublecap {
        return newLen // 一次追加很多,直接容纳请求
    }

    const threshold = 256
    if oldCap < threshold {
        return doublecap // 小 Slice 倾向 2 倍
    }

    for newcap < newLen {
        newcap += (newcap + 3*threshold) >> 2
    }
    return newcap
}
情况目标容量原因
newLen > 2×oldCap至少直接取 newLen一次追加非常多时,逐次翻倍没有意义
oldCap < 256倾向 2×oldCap小对象多留一些空间,减少频繁分配和复制
oldCap ≥ 256用平滑公式逐步增长从接近 2 倍平滑过渡到接近 1.25 倍,降低大对象浪费
nextslicecap 先看“一次要多少”,再看“旧容量多大” newLen > 2 × oldCap? 是 直接容纳这次追加newcap = newLen 否 oldCap < 256? 是 否 小 Slice 倾向翻倍newcap = 2 × oldCap 平滑增长逐渐接近 1.25 倍
最应记住:一次追加很多时先保证放得下;否则小容量倾向翻倍,大容量才进入平滑增长。
newcap += (newcap + 3×256) / 4 = 1.25×newcap + 192(整数运算)

当容量刚超过阈值时,额外的 192 让增长率仍明显高于 1.25;随着容量越来越大,192 的影响变小,增长率才逐渐接近 1.25。

面试纠错:“小于 1024 扩 2 倍,大于 1024 扩 1.25 倍”描述的是较老版本经验,不能当作当前源码规则。更稳妥的回答是说明阈值、平滑公式和版本差异。
七、底层内存:算完 newcap 后,为什么实际 cap 还能变化

逻辑容量还不是最终分配结果。Go 运行时会把 newcap × 元素大小 换算成字节数,再通过 roundupsize(把请求字节数向上对齐的函数)匹配 size class(内存分配器预先划分的字节规格),最后用“实际字节数 ÷ 元素大小”反推出真正的 cap。mallocgc 是运行时申请这块堆内存的函数。

nextslicecap得到目标 cap按增长策略 乘元素大小cap × sizeof(T)换算成字节 roundupsize对齐 size class可能多分一点 mallocgc申请新内存区分指针类型 memmove复制旧数据返回新 Header growslice:容量策略只是第一步,分配器对齐决定最终 cap 因此 []byte、[]int64、[]struct 的容量序列可能不同,不要背固定数字表。
最应记住:目标 cap 还要换算成字节并按分配器规格向上对齐,所以元素大小会影响最终 cap。

元素里有没有指针,为什么会影响分配

不含指针的元素

例如 []byte、[]int64。runtime 可申请不需要 GC(Garbage Collection,垃圾回收器负责找出不再使用内存)扫描的内存;append 即将覆盖的新增区间不必提前清零,只清理尾部未使用区域。

含指针的元素

例如 []string、[]*User。新内存必须处于 GC 可安全扫描的状态,复制指针时还可能触发写屏障(帮助垃圾回收器记录指针更新的机制)。

扩容的真实成本

  • 申请一块更大的连续内存;
  • 把旧的 len 个元素复制过去,复制量约为 len × sizeof(T);
  • 更新 Slice Header 指向新数组;
  • 旧数组只有在没有任何引用后,才可能被 GC 回收。

所以单次扩容是 O(n)(成本随元素数量近似线性增长),但容量按比例增长后,连续追加的均摊复杂度仍可视为 O(1)(把多次操作合起来看,单次平均成本近似不随数据量增长)。

八、内存与别名坑点:代码能跑,不代表内存关系正确

最容易误判的是“Slice 已经很小,所以占用的内存也很小”。例如从 100 兆字节(MB)的数组中只保留 10 字节,只要 Slice 记录的地址仍指向大数组,即使 len 变成 0,那块大数组仍可能被保留;需要长期保存少量数据时,应显式复制到独立的小数组。

仍然共享:大数组被留住 小 Slice Headerlen=10;即使 s=s[:0] 变成 len=0地址和 cap 仍然保留 100 MB 底层数组 10字节 仍有地址指向它:整块数组暂时不能回收 显式复制:小数据独立保存 新的小 Slice Header地址改为指向独立的小数组 独立的 10 字节只保留真正需要的数据 原来的 100 MB 数组 没有其他引用后,才可能被垃圾回收
最应记住:缩短 len 只会缩小窗口,不会清除地址;显式复制让小 Slice 改指向独立数组,原来的大数组才有机会被回收。

1. 子 Slice 留住大数组

从 100 兆字节(MB)缓冲区切出 10 字节并长期保存,这个小 Slice 仍指向原数组,可能让整块内存无法回收。需要独立保存时,可以追加到一个新 Slice,或用 slices.Clone(Go 标准库提供的 Slice 复制函数)。

2. s = s[:0] 不等于释放

它只把 len 设为 0,底层数组地址和 cap 仍在,适合复用缓冲区;要放弃引用可设 s = nil。但具体何时回收仍由 GC 和变量存活性决定。

3. 删除指针元素后仍被引用

对 []*T 做移位删除后,尾部槽位可能还保留旧指针。可用 clear(s[newLen:]) 清除无效引用,再缩短长度。

4. append 覆盖兄弟 Slice

两个 Slice 共用数组且写入仍在 cap 内时,一个 Slice append 出来的元素可能覆盖另一个 Slice 认为属于自己的数据。

5. 循环头删导致保留与搬移

反复 s = s[1:] 会移动窗口起点但不主动释放底层数组;需要真正队列语义时,应使用环形缓冲区或定期整理。

6. 并发写 Slice Header

append 可能同时修改底层数组地址、len 和 cap。多个 goroutine(Go 运行时调度的轻量并发任务)对同一个 Slice 变量并发 append 会产生数据竞争,必须通过锁、Channel(并发任务之间传递数据的通道)或分片后汇总结果。

copy 与 append 的区别:copy(dst, src) 只复制 min(len(dst), len(src)) 个元素,不会自动扩大 dst 的长度,也不会替你分配足够空间。
九、常见面试追问:从基础一路追到底层
1. Slice 和数组有什么区别?
数组类型包含长度,数据直接属于数组值;Slice 是对底层数组一段连续区域的描述,包含指针、len、cap,本身不保存那些元素。
2. len 和 cap 分别是什么?
len 是当前可直接索引的元素数量;cap 是从 Slice 起点到当前底层数组末尾最多可使用的元素数量。索引看 len,原地扩展能力看 cap。
3. append 一定会申请新内存吗?
不一定。新长度不超过 cap 时复用原数组;超过 cap 才进入 growslice,分配新数组并复制旧元素。
4. Slice 扩容规则是不是小于 1024 两倍、大于 1024 1.25 倍?
这不是当前源码的准确规则。当前 nextslicecap 以 256 为阈值,小 Slice 倾向 2 倍,大 Slice用平滑公式逐渐过渡到约 1.25 倍;最终 cap 还会经过内存 size class 对齐。
5. 为什么不同元素类型扩容后的 cap 可能不同?
runtime 最终按字节分配。目标 cap 乘元素大小后要对齐到分配器的 size class,再除以元素大小反推 cap,因此元素大小会影响最终结果。
6. 把 Slice 传入函数,函数里改元素为什么影响外部?
传参复制的是 Slice Header,内部和外部的 Header 往往仍指向同一底层数组;修改元素就是修改共享数组。若函数内部 append 触发扩容,它之后可能转向新数组。
7. 为什么 append 必须接收返回值?
append 可能返回新的底层数组地址、len 和 cap。即使没有扩容,调用方也需要拿到更新后的 len;发生扩容时,更必须拿到新数组地址。
8. 如何让子 Slice 后续 append 不影响父 Slice?
需要强隔离时直接复制。只想让下一次 append 触发扩容,可用完整切片表达式把 cap 限制为 len,例如 child := parent[:n:n];但 append 前直接改 child 元素仍会改共享数组。
9. nil Slice 和空 Slice 有什么区别?
两者 len、cap 都可为 0,也都能 append;nil Slice 等于 nil,底层指针为 nil,空但非 nil 的 Slice 不等于 nil。JSON(常见的文本数据交换格式)编码等边界场景可能表现不同。
10. Slice 扩容为什么是均摊 O(1)?
某次扩容要复制 O(n) 个元素,但容量按比例增长,昂贵复制不会每次 append 都发生;把多次追加总成本摊开,单次平均成本为常数级。
十、可运行示例:观察共享、扩容、地址与隔离

保存为 main.go,执行 go run main.go。地址值每次运行不同,但“扩容前相同、扩容后分离”的关系不变。

完整 Go 示例
package main

import "fmt"

func main() {
    base := make([]int, 2, 4)
    base[0], base[1] = 10, 20

    alias := base
    fmt.Printf("初始  base=%v len=%d cap=%d addr=%p\n",
        base, len(base), cap(base), &base[0])
    fmt.Printf("初始 alias=%v len=%d cap=%d addr=%p\n",
        alias, len(alias), cap(alias), &alias[0])

    // 容量够:仍使用同一底层数组。
    base = append(base, 30)
    alias[0] = 99
    fmt.Println("未扩容后 base =", base) // [99 20 30]

    // 一次追加到超过 cap:触发扩容并复制。
    base = append(base, 40, 50)
    base[0] = 777
    fmt.Printf("扩容  base=%v len=%d cap=%d addr=%p\n",
        base, len(base), cap(base), &base[0])
    fmt.Printf("旧的 alias=%v len=%d cap=%d addr=%p\n",
        alias, len(alias), cap(alias), &alias[0])

    // 完整切片表达式:限制 cap,强制下一次 append 分离。
    parent := []int{1, 2, 3, 4}
    child := parent[:2:2]
    child = append(child, 100)
    fmt.Println("parent =", parent) // [1 2 3 4]
    fmt.Println("child  =", child)  // [1 2 100]
}
观察重点:地址只用于验证底层数组是否变化,不要在业务逻辑里依赖地址或某次运行的具体 cap 数字。
十一、总结话术:面试前最后一分钟
Slice 是窗口:len 管“现在能看多远”,cap 管“原数组还能长多远”。
3 个字段地址 / len / cap
容量够原地写,共享数组
容量不足分配、复制、换地址
最终 cap还要做内存对齐
  1. Slice Header:只描述数组窗口,不直接保存元素。
  2. append:必须接住返回值;是否扩容取决于新长度和 cap。
  3. 扩容策略:小容量倾向 2 倍,大容量平滑过渡到约 1.25 倍;一次追加很多则至少直接容纳 newLen。
  4. 实际容量:目标 cap × 元素大小,再按分配器 size class 对齐。
  5. 复制成本:扩容时分配新数组并 memmove 旧元素,单次 O(n)、连续 append 均摊 O(1)。
  6. 共享风险:子 Slice、函数参数、Header 复制都可能继续指向同一数组。
  7. 内存风险:小 Slice 可能留住大数组;缩短 len 不等于释放底层内存。
  8. 隔离手段:需要真正独立就 copy / slices.Clone;完整切片表达式只限制 cap。
面试总结话术:Slice 本身只是指向底层数组的一个窗口,记录起点、长度和容量。append 后的新长度没超过 cap 时,它会复用原数组;超过 cap 时,运行时会计算新容量、按内存规格分配新数组,再复制旧元素。所以工程中要同时关注预分配、底层数组是否共享,以及子 Slice 是否会意外留住大块内存。
十二、应用场景:这个知识点实际用在哪里

Slice 适合处理“元素按顺序存放,数量可能变化”的数据。真正做业务时,不只要问“能不能 append”,还要问“能否预估数量”、“是否允许共享数组”和“这块内存要保留多久”。

批量查询与结果收集

什么时候用:分页读取商品或订单,把本页结果转换后追加到结果 Slice,最后批量写库或返回接口。

为什么适合:数据按顺序产生和访问;能预估数量时,还可以预分配容量,减少扩容与复制。

注意:数据量不受控时不要无限追加,应分页处理、边读边消费或设置上限。

预分配批处理缓冲区

什么时候用:已知最多处理 1000 个商品编号时,用 make([]string, 0, 1000) 建立长度为 0、容量为 1000 的结果容器。

为什么适合:仍可以自然地追加元素,同时减少重复分配;len=0 也避免把尚未写入的位置当成有效数据。

注意:预留容量远大于实际数量会浪费内存,元素较大或并发请求较多时尤其明显。

原地过滤与复用内存

什么时候用:输入数据之后不再需要保留时,用 out := items[:0] 把合格元素追加回 out。

为什么适合:它可复用同一个底层数组,避免再创建一个同等大小的 Slice,减少分配和垃圾回收工作。

注意:元素含指针时要清理尾部无效位置;其他共享该数组的 Slice 也会看到修改。

需要隔离的快照

什么时候用:缓存配置更新前复制一份 Slice,或把大响应缓冲区中的一小段复制后长期保存。

为什么适合:copy 或 slices.Clone 可创建独立数组,既避免调用方改到内部状态,也避免小窗口长期留住大数组。

注意:复制会随元素数量增加而变慢,且占用额外内存,只在修改权或保留时间确实需要隔离时使用。

什么时候不该硬用

  • 长度固定且类型就应表达固定尺寸时,优先用数组,例如 [32]byte 表示固定长度的字节标识。
  • 需要频繁从头部删除的长队列时,优先考虑环形缓冲区或专用队列。
  • 多个并发任务要同时追加时,不能直接共享写同一个 Slice;应加锁、分片后汇总或用 Channel 交接。
  • 需要按唯一编号高频查找时,不要反复遍历 Slice;应考虑 map(Go 中按键快速查找数据的映射类型)或数据库索引。

面试里可以这样业务化表达

“比如批量查询订单时,我会根据分页大小用 make([]Order, 0, n) 预分配,减少追加过程中的扩容和复制。如果要把结果长期放进缓存,我还会确认它是否与临时大数组共享内存;需要隔离时就显式复制。”

十三、资料依据与版本说明

本页以 Go 官方语言规范和 runtime 源码为依据。语言语义相对稳定,扩容阈值、公式、内存分配细节可能随版本变化。