Theory · Golang · Runtime

切片:结构与扩容

24 字节 header 三元组 → 共享底层数组 → growslice 渐进扩容 —— 一切"切片玄学"都能从内存布局推导出来

结构先行

runtime.slice{array, len, cap} 是栈上值;底层数组在堆上被多个切片共享——共享是全部陷阱的根源

扩容规则

Go 1.18 起阈值 256 + 2.0→1.25 渐进过渡;roundupsize 按 size class 对齐,实际 cap 与直觉不符

高频考点

截取共享陷阱、nil vs empty、传参只拷 header、string↔[]byte 开销——15 组 QA 覆盖

slice 是 Go 面试的第一道必考题,看似简单,追问链却很长:header 结构、共享底层数组、扩容因子、roundupsize、传参语义、string 转换。这份 deck 的主线是把所有现象还原到"24 字节 header 指向堆上数组"这一张内存布局图上,让你在面试里可以现场推导而不是背结论。扩容规则对照 Go 1.27 的 runtime/slice.go,1.18 前后的差异会专门讲。

开场 · Why Slices

如果 Go 只有数组,这三件事得你自己记账

先想清楚 slice 到底替我们省掉了什么,后面的结构才讲得通。

数组 [8]int 的三个硬伤

硬伤具体后果
长度写进类型[8]int[9]int两个不同类型,函数签名被长度绑死
传参整体拷贝1000 个 int64 是 8KB,每调一次函数就整块拷一次——语言层面没法只传"其中一段"
没法"追加"想要更大空间,只能手写"新建更大的数组 + 拷旧数据",拷贝次数由你自己管
算一笔账:往容器里逐个加 1 万个元素。若每次都"新建更大的数组再整体拷贝",一共要搬 约 5000 万个元素;改成"容量不够就翻倍"只需搬 约 2 万个——差 2500 倍。这不是微优化,是能不能用的分界线。

slice 的两个回答 = 本 deck 的前半段

① 把"一段内存"变成一个可以随便传的值

用 24 字节的 header 记下"数据在哪(array)、能看几个(len)、还能写几个(cap)"。传 slice 只拷这 24 字节,底层数组留在原地不动。——见第 4、5 页

② 空间不够时自动"翻倍搬家"

append 在容量不足时分配更大的数组、拷走旧数据,并把扩容策略设计成摊销 O(1)(先翻倍,再平滑降到 1.25 倍)。——见第 9–12 页

代价(也是本 deck 的后半段):多个 header 可以指向同一块底层数组。省下的拷贝换来了"共享"——截取不拷贝、append 可能静默写进别人的地盘、小切片钉住大数组不释放。slice 的全部玄学都来自这里。
开场讲清楚 slice 替我们省掉了什么。数组的三个硬伤:长度写进类型导致函数签名被绑死、传参整块拷贝 8KB、没法追加只能手写搬迁。算一笔账:1 万个元素逐个加,每次新建要搬五千万次,翻倍扩容只要两万次,差两千五百倍。slice 的两个回答:把指针长度容量打包成 24 字节的 header 当值传,以及 append 自动翻倍搬家做到摊销 O(1)。最后点出代价——多个 header 共享同一块底层数组,后面截取陷阱、append 静默覆盖、小切片钉住大数组全部由此而来。

读之前 · Before You Read

先花一分钟对齐十个词,后面每一页都会用到

术语全部是本 deck 真正会用到的,没有凑数;前置知识不在本 deck 内的,用链接指过去。

术语(中英对照)一句话白话定义
数组 array一段定长、连续的内存。长度是类型的一部分,[8]int[9]int 是两个类型
切片 slice对某个数组一段连续区间的"视图"。它自己不存元素,只是一个指过去的便签
头部 header切片变量本身,24 字节三个字段:array 指针 / len / cap
长度 len当前可见的元素个数——能安全读写的上界,越界就 panic
容量 cap从 array 指针到数组末尾还剩多少槽位,即"不搬家还能写几个"
术语(中英对照)一句话白话定义
底层数组 backing array真正存元素的那块连续内存。可以被多个 header 同时指向——共享由此而来
截取 reslices[a:b]:在已有切片上取子区间。不分配、不拷贝,只是换一张便签
追加 append / growslice加元素。容量够就写槽位;不够则由 runtime 分配更大的数组、拷过去、换 header
大小类 size class分配器预定义的固定档字节数(8B 起步,共 67 档)。申请时向上取最近档,决定了你实际拿到的 cap
摊销 O(1) amortized单次扩容可能是 O(n),但连续 n 次 append 的总代价是 O(n),摊到每次就是 O(1)
最小心智模型(后面所有机制都是这句话的实现细节):把 slice 记成一个动作——拿一张 24 字节的便签(header),写上"从哪开始、能看几个、还能写几个",贴在共享的那块底层数组上;append 就是看便签上还有没有余量,没有就换一块更大的数组、拷过去、改写便签。
三篇前置(本 deck 不展开):memory-allocator.html —— size class 与 roundupsize,解释"为什么 cap 比公式大";../../algorithm/complexity/complexity-analysis.html —— 摊销分析的正式定义;../data-structure/array-linkedlist.html —— 连续数组与链表的取舍。
术语页给十个词:数组、切片、header、len、cap、底层数组、截取、append 与 growslice、size class、摊销 O(1)。最小心智模型一句话:拿一张 24 字节的便签写上从哪开始、能看几个、还能写几个,贴在共享的底层数组上;append 就是看便签上还有没有余量,没有就换更大的数组、拷过去、改写便签。前置指三个 deck:内存分配讲 size class 与 roundupsize,复杂度分析讲摊销的正式定义,数组与链表讲连续内存的取舍。

Slice Header

slice = 24 字节 header(栈上)+ 底层数组(堆上)

这就是开场说的那张"便签":它自己只有三个字段,真正的元素住在它指过去的那块共享数组里。

// runtime/slice.go · 对照 Go 1.27
type slice struct {
    array unsafe.Pointer // 指向底层数组
    len   int            // 元素个数
    cap   int            // 底层数组长度(从 array 起)
}

// var s []int 的零值:{nil, 0, 0}
// 64 位平台 sizeof = 8+8+8 = 24B
"引用类型"是误导说法:slice 赋值/传参拷贝的是 3 个 word 的 header——值语义;但 header 里 array 指针指向同一块底层数组,所以表现为"共享"。理解了这一点,所有陷阱都能推出来。
操作代价与效果
len(s) / cap(s)O(1) 直读 header 字段,无任何计算
s[i]*(array + i×elemsize),带边界检查
赋值 s2 := s拷 24B header,不拷数组——共享开始
s[a:b]新 header{array+a×size, …},不分配内存
appendcap 够:写槽位返回原 header;不够:growslice 分配新数组

与数组的关系

数组 [8]int 是值类型:赋值/传参整体拷贝、长度是类型的一部分([8]int ≠ [9]int)。切片把"长度"从类型里拿出来放进 header,才有了动态和共享。

先把 slice 的本质立住:它不是引用类型,是 24 字节的值类型 header,array、len、cap 三个 word。所谓共享,只是两个 header 的 array 指向同一块堆上数组。左边源码结构,右边是五个核心操作的代价表。记住 len 管可见边界、cap 管可写边界,后面截取和 append 的所有行为都是这两条边界的推论。

Memory Layout

一张图:两个 header,一块共享数组

slice header 与底层数组的内存布局 栈上两个 slice header:s 的 array 指向堆数组槽 0,len=4 cap=8;s2 等于 s 的 3 起截取,array 指向槽 3,len=5 cap=5。两者共享同一块堆上底层数组。 array array+3×8B Goroutine 栈 · 函数局部变量 s 的 header · 24B array ─→ &堆[0] len = 4 cap = 8 s := make([]int, 4, 8) s2 的 header · 24B array ─→ &堆[3] len = 5 cap = 5 s2 := s[3:] 截取共享 堆 · 底层数组 [8]int(连续 64B,一次分配) 10 20 30 40 50 60 70 80 [0][1][2][3] [4][5][6][7] s: len(s)=4 s: cap(s)=8 s2: len=5 · cap=5 s2[0] 就是 s[3]、也是同一块内存——任一侧写入,两侧都"看得见" s2 的 cap 到数组末尾为止(从起点 3 算)——这是 s2 append 的伏笔
这页是全 deck 的锚点图。栈上两个 24 字节 header,堆上一块连续数组:s 的 array 指槽 0,s2 由 s 截取而来,array 指槽 3,不发生拷贝。s 可见的 len 是 4,cap 覆盖到数组末尾;s2 从槽 3 起算,cap 只剩 5。注意底部两行:s2 的下标 0 对应 s 的下标 3,同一段内存两个视角,谁写都看得见。记住这张图,后面所有推演都回到它。

Creation · nil vs empty

五种创建方式:header 状态各不相同

写法header(array/len/cap)s == nil说明
var s []int{nil, 0, 0}truenil slice;append 到 nil 会正常分配,只是首元素必须走 growslice
s := []int{}{指向 zerobase, 0, 0}falseempty slice;字面量空则指向 runtime.zerobase(0 字节分配的哨兵地址)
make([]int, 3){堆指针, 3, 3}false分配并清零:拿到 3 个零值元素,不是空切片
make([]int, 0, 8){堆指针, 0, 8}false预分配 8 槽:hint 一次到位,避免反复 growslice 搬迁
arr[1:3]{&arr[1], 2, 7}false截取:不分配,与 arr 共享底层数组(下一页推导)

nil vs empty 的实际影响:JSON

json.Marshal 对 nil slice 输出 null,对 empty slice 输出 []。API 契约上 null/[] 语义不同(前端判空写法、数据库反序列化),这是"到底要不要初始化成 []T{}"争论的技术根源。

语义层结论

len/cap/range/append 对 nil 与 empty 行为完全一致(len 都是 0)。差别只在 ==nil 与序列化、以及 reflect.DeepEqual([]int{}, nil-slice) 为 false 这类比较语义。

面试口径:"nil slice 和 empty slice 在运行时行为上等价(len=0、可 append、可 range),区别是 header 的 array 是否为 nil,以及 JSON 序列化输出 null 还是 []。规范也明确:nil slice 与 empty slice 可互换使用。"
创建方式决定 header 初始状态,这页表格要能默写。重点是 nil 和 empty 的分界:array 字段是否为 nil。运行时行为两者等价,len 都是零、append 都正常;实际影响有两处——JSON 序列化输出 null 还是方括号,以及 DeepEqual 判不等。第三行特别注意:make([]int, 3) 是三个零值元素,不是空切片,这是 make 两个参数混淆的高频笔误。

Reslicing · s[a:b]

截取不分配:len/cap 是两个减法

s[a:b] 截取的 len 与 cap 推导 源数组 a 有 8 个元素,s 等于 a 的 1 到 4 截取:len 等于 b 减 a 等于 3,cap 等于原 cap 减去起点 a 等于 7,新 array 指针指向原数组第 1 个元素的地址。 a := []int{1,2,3,4,5,6,7,8} · len=8 cap=8 1 2 3 4 5 6 7 8 a[0]a[1]a[2]a[3] a[4]a[5]a[6]a[7] 新 array = &a[1](同地址) s := a[1:4] len = 4−1 = 3 cap = 8−1 = 7 一般式: len = b − a cap = cap(源) − a array 偏移: a×elemsize 2 3 4 5 6 7 8 s[0]s[1]s[2] len(s)=3 可见区 cap(s)=7 可写余量 为什么 cap 不是 3?——cap 的语义是"从 array 指针到数组末尾还能放多少":起点已经偏移到 a[1],末尾还在 a[7],余量自然 = 8−1 = 7 s[0] 与 a[1] 是同一块内存:截取视角换了,内存没换——下一页的陷阱全部由此而来
截取的两个减法要张口就来:len 等于 b 减 a,cap 等于原 cap 减去起点 a。为什么 cap 要减起点?因为 cap 的语义是从 array 指针到数组末尾的余量,起点偏移了 1,余量就从 8 变成 7。实线是 len 的可见区,虚线是 cap 的可写余量,s 的第 0 个元素就是 a 的第 1 个元素。这个 cap 余量正是 append 能"写进别人地盘"的原因。

Aliasing Walkthrough

共享陷阱逐行推演 + 三索引 s[a:b:c]

a := []int{1, 2, 3, 4, 5}
b := a[1:3]         // len=2 cap=4 · 共享

b[0] = 99           // 同一内存 → a[1]=99
// a == [1 99 3 4 5]  修改互相可见

b = append(b, 777)
// len 2+1=3 ≤ cap 4 → 不扩容!
// 777 写进 a[3] → a == [1 99 3 777 5]

b = append(b, 888)  // len=cap=4 → growslice
// 分配新数组搬走 → b 从此与 a 脱钩
推演要点:append 在 len < cap 时写的是原数组的槽位——"append 静默覆盖"是共享陷阱里最阴的一题:b 以为自己 append,a 无辜被改。

解法:三索引限制 cap

c := a[1:3:3]       // a[low:high:max]
// len = 3−1 = 2
// cap = 3−1 = 2  ← max 也减起点

// c 的可写余量被掐死为 0:
c = append(c, 100)
// len=cap → 立即 growslice 搬新家
// 原数组 a 不再被触碰
场景建议
截取后还要 append用三索引 a[i:j:j],cap 归零触发安全扩容
要独立副本slices.Clone(s)(Go 1.21+)或 copy 到新 slice
传给会 append 的函数意识到底层可能被写:要么传三索引副本,要么文档注明
左边是经典推演题,四行走完共享生命周期:写互相可见;第一次 append 因为 cap 够,直接把 777 写进 a 的第四个位置,a 被污染;第二次 append len 等于 cap 才真正搬走脱钩。右边是标准解法:三索引把 max 设成 high,cap 归零,任何 append 都会立即扩容,不动原数组。面试时能主动给出三索引方案,说明你踩过真实的坑。

growslice Pipeline

append 的完整决策链:从内联检查到搬迁

append 与 growslice 决策流程 编译器先内联检查剩余容量,容量足够时直接写槽位零分配;不足时调用 runtime.growslice,经 nextslicecap 计算新容量、mallocgc 加 roundupsize 对齐分配、memmove 拷贝旧数据并清零扩展区,最后由调用方写入新元素。 cap 够 cap 不够 快路径(内联) *(array+len) = x;len++;零分配零调用 s = append(s, x…) 编译器内联检查:cap − len ≥ 本次新增数? cap 够? 够 → 快路径直写;不够 → 走 runtime runtime.growslice(oldPtr, newLen, oldCap, num, et) et 是元素 *_type;newLen = oldLen + num nextslicecap(newLen, oldCap) 算 newcap newLen > 2×oldCap → newLen;<256 → 翻倍;否则渐进(第 8 页) mallocgc(newcap×elemsize) + roundupsize 对齐 实际分配字节按 size class 向上取整(第 9 页)→ cap 被反推 memmove 拷旧数据 [0, oldLen) · 清零新增区 返回新 header{newPtr, newLen, newcap};新元素由调用方写入 面试追问预备 · 旧数据只拷 oldLen 个——append 多个元素时 新增部分不经过 growslice 的拷贝 · 含指针元素:memclrHasPointers 清零扩展区, 保证 GC 不会扫到旧指针(类型安全底线) · 搬家后旧数组交给 GC:是否回收看是否仍被 其他切片引用(内存泄漏伏笔,第 13 页) · 多次小 append = 反复 growslice → 已知大小 时用 make(..., 0, n) 或 slices.Grow 预留 源码:src/runtime/slice.go(对照 Go 1.27)
append 的完整链路分两层。快路径是编译器内联的容量检查,够就直接写槽位,零分配。慢路径进 runtime.growslice:先由 nextslicecap 定新容量,再 mallocgc 加 roundupsize 对齐分配,memmove 拷旧数据,清零扩展区,最后新元素由调用方写入。右边四条追问预备值得背:只拷 oldLen、清零含指针区是 GC 安全底线、旧数组交给 GC、多次小 append 要预分配。

Growth Factor · Go 1.18 分界

nextslicecap:从"两段式"到"渐进过渡"

// runtime/slice.go · nextslicecap(对照 Go 1.27)
func nextslicecap(newLen, oldCap int) int {
    newcap := oldCap
    doublecap := newcap + newcap
    if newLen > doublecap {
        return newLen      // 需求说了算
    }
    const threshold = 256
    if oldCap < threshold {
        return doublecap   // 小切片翻倍
    }
    for {
        // Transition from growing 2x to 1.25x
        // and adjust the growth rate gradually.
        newcap += (newcap + 3*threshold) >> 2
        if uint(newcap) >= uint(newLen) {
            break
        }
    }
    return newcap
}
渐进公式:每轮 newcap += (newcap + 3×256)/4,即增长系数 = 1.25 + 192/oldCap。oldCap=256 时恰为 2.0,随 cap 增大单调降到 1.25——源码注释原文即 "Transition from growing 2x to 1.25x gradually"。
版本规则
≤ Go 1.17cap < 1024 → ;否则一律 1.25×(1024 处硬切换,增长曲线跳变)
≥ Go 1.18阈值降到 256;256 以下 2×,以上按渐进公式平滑过渡到 1.25×——大 slice 不再"从翻倍突降四分之一"的内存模式抖动

三个容易答错的点

① newLen > 2×oldCap 时(一次 append 一大截)直接给 newLen,保证至少装得下;② 公式算出的是"期望 cap",还要过 roundupsize 才是真实 cap;③ 规则只约束 runtime 的 append——语言规范不承诺任何增长因子,不同版本可变。

为什么 256?小切片翻倍的内存浪费可忽略,扩容搬迁成本才是大头;大切片翻倍浪费显著,改用缓增长。源码注释给出同样权衡。

扩容规则是必考题,必须分版本答。1.18 之前是两段式:1024 以下翻倍、以上 1.25,曲线在 1024 处有个跳变。1.18 起阈值降到 256,并且不再是硬切换:每轮 newcap 加上 newcap 加 768 再除以四,换算成系数就是 1.25 加 192 除以 oldCap,在 256 处恰好等于 2,随容量增大平滑降到 1.25。最后强调:规范不承诺增长因子,这属于 runtime 实现细节,版本间可变。

Size Class Alignment

真实 cap ≠ 公式 cap:roundupsize 的反推

对齐从 make 就开始

写法申请字节size class实际 cap
make([]byte, 5)5 B8 B8(≠5)
make([]int64, 3)24 B24 B3
make([]bool, 17)17 B24 B24(≠17)
make([]int8, 1) 后 append2 B8 B8(≠2)
机制:growslice 先算 newcap × elemsize 字节,mallocgc 按最近 size class 向上分配,再由 roundupsize(bytes) 反推真实 cap = 分配字节数 ÷ elemsize。小元素吃对齐红利最大。

size class 表节选(runtime/sizeclasses.go)

// class  bytes/obj  …
1        8          // 最小 8B
2        16
3        24
4        32
5        48
6        64
…
32       1024
…
67       32768      // ≤32KB 走小对象分级

推演示例:int64 从 cap=512 再 append

渐进公式给 newcap = 512×1.625 = 832;832×8 = 6656 B → 向上取到 class 6784 B → 真实 cap = 6784/8 = 848。"公式 832、实测 848",面试报出这一对数字即满分。

同款对齐规则也服务 memory-allocator(见 ../golang/memory-allocator.html):class 表由 makesizeclasses.go 生成

这页解释"公式算的 cap 和打印出来不一样"。分配器按 size class 分配,8 字节一档起步,所以 make 一个 5 字节的 byte 切片实际给 8 字节,cap 是 8 不是 5。bool 十七个元素申请 17 字节,对齐到 24 类,cap 直接是 24。右下角的推演是杀手锏:渐进公式给 832,经过 6784 字节的类对齐,真实 cap 是 848。这套对齐和内存分配器的 size class 是同一套,后面 allocator 那份 deck 会展开。

Empirical Growth · Go 1.21+ slices

[]int64 从空 append:cap 序列长什么样

append 至 lencap(实测)依据说明
1 → 2 → 4 → 8 …1, 2, 4, 8, 16, 32, 64, 128oldCap < 256 → 2×纯翻倍段,且每步都恰好落在 size class 上
256 → 512512公式:256×2.0(1.25+192/256)渐进段第一跳,系数仍是 2
512 → 832 期望848512×1.625=832 → 6656B → class 6784Broundupsize 抬高的一跳
848 → 1252 期望1280848×(1.25+192/848)≈1252 → 10016B → class 10240B此后增长放缓、被对齐"取整"

控制扩容的标准库:slices(Go 1.21+)

slices.Grow(s, n):一次性扩到至少 len+n,消除多次 append 的反复搬迁(内部同款 growslice);slices.Clone 独立副本;slices.Clip 三索引掐掉余量;slices.Concat 合并。批量构建前先 Grow 是消除搬迁毛刺的标准姿势。

make hint 的取舍

已知规模:make([]T, 0, n) 一次分配、零搬迁,但 hint 过大反而浪费(分配器照单全收);未知规模且逐个 append:从 nil 开始,让渐进扩容自己找节奏。两难时用 append(nil-ish, xs...) 一把 append 整批,runtime 按 newLen 直接给足。

答题口径:"增长序列是翻倍段、渐进段、对齐取整三者的叠加;逐元素 append 的摊销代价 O(1),但单次最坏 O(n)(搬家+拷贝),延迟敏感路径要 Grow 预留。"
这页把前两页的规则落到一条真实序列上:从空开始逐个 append,前八步纯翻倍;到 256 时渐进公式恰好还是二倍;512 之后公式给 832 但对齐到 848,848 之后给 1252 对齐到 1280。曲线是翻倍、渐进、对齐三者的叠加。工程侧记住 slices.Grow,Go 1.21 进的标准库,批量构建前预留容量是消除搬迁毛刺的标准动作。

copy() · Pass-by-value

copy 与 append 的分工;传参只拷 24 字节

copy(dst, src):改内容,不动 header

min(len(dst), len(src)) 拷贝,返回拷贝数;不扩容、不改 dst 的 len/cap——空间不够就静默截断(返回值提醒你)。dst 必须先有空间。字符串也支持:copy(b, "hi")。

append:改 header,可能换数组

返回新 header(len 变了/数组换了),必须接住返回值:s = append(s, x)。不接住时 len 增长丢失,若搬家了甚至丢数据。二者一个管"往已有空间里放",一个管"空间从哪来"。

经典陷阱:函数内 append,外部看不见

func add(s []int) {
    s = append(s, 99) // len 3→4 < cap → 写原数组
}
func caller() {
    a := make([]int, 3, 4) // cap 有余量
    add(a)
    // a[3] 被 add 改成了 99,但 a 的 len 仍是 3
    // 外部 len(a)==3 → "看不见",却又真实写入
}

func grow(s []int) {
    s = append(s, 1, 2, 3, 4) // 触发 growslice
    // 换了新数组:caller 侧完全无感
}
情况caller 看到的
函数内 s[i] = x(改内容)可见(同一底层数组)
append 未扩容内容真实写入但 len 不变 → 位于"看不见的 cap 区"
append 触发扩容完全不可见(新数组只在被调方)
正确姿势:让函数返回 slice(func add(s []int) []int)由调用方接住;或传 *[]T。标准库全是前者——append 的返回值语义决定了这是唯一一致的 API 形态。
copy 和 append 的分工一句话:copy 改内容不动 header,append 改 header 可能换数组。传参陷阱是推演重点:slice 传参拷的是 24 字节 header,函数内改内容外部可见;cap 有余量时 append 会写进外部看不见的 cap 区,最阴;一旦扩容,新数组只有被调方知道。所以标准库的惯例是函数返回新 slice 让调用方接住,这是 append 返回值语义决定的唯一一致写法。

Conversion Cost · Unicode

string↔[]byte:每次转换都是一次 memcpy

操作底层
string → []byte分配新数组 + copy:string 不可变,若不拷贝,写 []byte 会破坏 string 的不变量
[]byte → string分配 + copy:保证结果不可变;否则 b 变了 s 跟着变
[]rune(s)UTF-8 解码为 []int32:每 rune 4B,长度 ≠ 字节数
零拷贝(Go 1.20+):unsafe.Slice(unsafe.StringData(s), len(s))unsafe.String(&b[0], len(b)) 是官方收编的零拷贝转换。风险自负:通过 []byte 改了 string 就是未定义行为;b 为空时 StringData 返回未定义指针,需判空。

编译器免费优化:热点路径不拷贝

满足"转换结果不逃逸"时,编译器用栈上临时串直接完成:m[string(b)] map 查询、string(b) == s 比较、strings.Index(s, string(b)) 等——runtime 提供 slicebytetostringtmp 类快路径(cmd/compile walk)。一旦赋值给变量逃逸了,就回落到真拷贝。

[]rune 与 UTF-8 三件套

len("中") == 3(字节数);utf8.RuneCountInString("中") == 1for i, r := range s 按 rune 迭代且 i 是字节下标(不连续)。逐字符处理长文本:rune 切片便于随机访问,代价是 4 倍展开 + 解码分配。

string 和 slice 的转换成本来自不变量:string 不可变,所以双向都要 memcpy 保安全。零拷贝有两条路:编译器对不逃逸的热点路径自动走栈上临时串,比如 map 查询和比较,这是免费优化;显式需求走 Go 1.20 收编的 unsafe.StringData 和 String,但要自己承担改坏 string 的未定义行为。Unicode 部分:len 数字节、RuneCountInString 数字符、range 的下标是字节偏移,这三件套必背。

Pitfall Checklist

切片八大坑:每条都能追溯到 header 模型

现象修法
① append 覆盖共享数组b := a[:2]; b = append(b, x) 写进 a[2]三索引 a[:2:2] 或 Clone
② 大数组小引用big[0:1] 的 header 钉住整个大数组不释放Clone / copy 出小数组
③ 循环里复用 buf 截取results = append(results, buf[:n]),buf 每轮被覆盖,历史元素全变最后一份append(results, append([]byte{}, buf[:n]...)...) 即 Clone 后再 append
④ 丢弃 append 返回值append(s, x) 单独成句:len 不变,搬家后数据全丢s = append(s, x)
⑤ make 两参混淆make([]T, 3) 想要空切片却得到 3 个零值make([]T, 0, 3)
⑥ 并发 appendheader 的 len/array 竞态(撕裂写),元素错乱甚至越界锁 / channel / 分片聚合
⑦ 遍历中 append 自身for range s { s = append(s, x) }:range 用开始时的 header 快照,扩容后行为"错位"先收集再合并,或复制迭代
⑧ nil/empty 序列化JSON 输出 null 与 [] 混用打爆前端统一初始化口径,或自定义 MarshalJSON
万能推导口诀:遇到任何"诡异切片行为",先画 header 三元组和底层数组的指向关系,再问三个问题——谁和谁共享?len 划到哪?cap 余量到哪?答案自动浮现。
八大坑按真实事故频率排序。第三个最阴:循环里复用 buf,append 进 results 的只是 header,buf 一覆盖全部历史元素变成最后一份,解法是 clone 后再 append。第四个是笔误类:append 返回值不接住。第七个:range 用的是开始时的 header 快照,遍历中 append 自身会错位。最后教一个万能口诀:画 header、问共享、问 len、问 cap,所有诡异行为都能推出来。

速查 · Cheat Sheet

速查:header 速算 · 扩容三步 · 五条检查清单

面试前扫一眼:左列是"拿到一个切片,它的 len/cap 是多少",右列是"用的时候别踩什么"。

① header 速算:先判创建方式,再判截取

写法len / cap 结果
var s []Tlen=0 cap=0,s == nil(array 为 nil)
s := []T{}len=0 cap=0,array 指向 zerobase(非 nil)
make([]T, n)len=n cap=n —— 不是空切片,是 n 个零值元素
make([]T, 0, n)len=0 cap=n —— 预分配追加空间的正确写法
s[a:b]len = b−acap = cap(源) − a;不分配不拷贝
s[a:b:c]len = b−acap = c − a;写 s[i:j:j] 即 cap 归零

② 扩容三步判定(Go 1.18+)

newLen > 2×oldCap → 直接给 newLen(一次 append 一大截,需求说了算);② oldCap < 256翻倍;③ 否则每轮 newcap += (newcap + 3×256) / 4,系数从 2.0 平滑降到 1.25。最后都要过 roundupsize 按 size class 对齐才是真实 cap。

③ 要报得出的数字

24B header(3 个 word)· 小切片阈值 256 · 渐进系数 1.25 + 192/oldCap · size class 共 67 类(8B 起步)· []int64 从空 append 的 cap 序列 1, 2, 4 … 128, 256, 512, 848, 1280 · 最经典的一对数字:公式 832 → 实测 848

④ 五条检查清单

场景该怎么做
append 之后必须接住返回值:s = append(s, x)
截取后还要 append三索引 a[i:j:j],cap 归零 → 立即搬家不污染原数组
小切片来自大数组slices.Clone 才切断引用(三索引解决"钉住大数组")
已知最终规模make([]T, 0, n)slices.Grow 预留,消除搬迁毛刺
并发 append锁 / 分片聚合 / channel;header 更新非原子,-race 可查
速查页压两列。左列是 header 速算表加扩容三步判定:创建方式决定初始 header,截取用两个减法,扩容先判需求是否超过两倍、再看是否小于 256、否则走渐进公式,最后统一过 roundupsize。右列是要报得出的数字(24 字节、256 阈值、渐进系数、67 类、cap 序列、832 与 848 这一对)和五条检查清单:append 接返回值、截取后 append 用三索引、小切片防泄漏要 Clone、已知规模预分配、并发要加锁。

Interview QA · 1/2

结构与扩容 8 连问

先盖住答案自答一遍,再逐题对照——答不上来的那几题,就是今晚要补的地方。

1 · slice 底层结构?和数组什么区别?

runtime.slicearray/len/cap24B

runtime.slice{array, len, cap} 三元组,64 位下 24B,栈上按值传递。数组是值类型、赋值整体拷贝、长度属于类型;slice 把长度放进 header,底层数组在堆上,可被多个 header 共享。

2 · s[a:b] 的 len/cap 怎么算?

len=b−acap=cap(源)−a

len = b−a;cap = 原cap − a(array 指针偏移到 a,cap 语义是"从指针到数组末尾的余量")。截取不分配内存,与源共享底层数组,改动互相可见。

3 · append 的扩容规则?1.18 有什么变化?

threshold 256渐进过渡规范不保证

需求优先(newLen>2×cap 直接给 newLen);否则 oldCap<256 翻倍;以上走渐进公式 newcap += (newcap+3×256)/4,系数从 2.0 平滑降到 1.25。1.18 前是 1024 阈值两段式硬切换。规范不承诺增长因子。

4 · 为什么实测 cap 和公式对不上?

roundupsizesize class848 例子

growslice 按 newcap×elemsize 申请,mallocgc 按 size class 向上分配,roundupsize 反推真实 cap=分配字节÷elemsize。例:渐进公式给 832(512×1.625),6656B 对齐到 6784B 类,真实 cap=848。

5 · nil slice 和 empty slice 的区别?

array 是否 nilJSON null/[]

var s []int 的 header 是 {nil,0,0},==nil 为真;[]int{} 的 array 指向 zerobase。运行时行为等价(len=0、append/range 一致);差异在 JSON 序列化(null vs [])与 reflect.DeepEqual 比较。

6 · append 一定分配新数组吗?

cap 余量快路径零分配

不一定。编译器内联检查 cap−len 够就直接写槽位,零分配;不够才调 growslice 搬家。反过来,cap 够但别处共享数组时,append 会"静默"写进共享区——比分配更危险的坑。

7 · 三索引切片 s[a:b:c] 干嘛用的?

限 capcap=c−a防污染

full slice expression 把 cap 钉成 c−a。常用 s[i:j:j] 把 cap 归零,append 立即触发扩容搬新家,杜绝写进共享原数组。适合"截取后还要 append"和防御性复制场景。

8 · 函数传 slice,外部能看到修改吗?

值传 header24B 拷贝

改内容(s[i]=x)可见——同一底层数组;cap 有余量时 append 写入 cap 区但外部 len 不变——"看不见却写入";触发扩容则完全不可见。规范做法:函数返回新 slice 由调用方接住。

QA 第一组覆盖结构与扩容。第三题要主动报出 256 阈值和渐进公式,区别于背旧资料的 1024 两段式。第四题的 848 推演是拉开差距的点,能现场算说明真懂 roundupsize。第六题的"cap 够不分配反而更危险"是多数人没想到的角度。第七题三索引要能说出 cap 等于 c 减 a 而不是 c。

Interview QA · 2/2

工程与语义 7 连问

同样先自答再对照。这组题偏工程,答的时候要能说出"为什么必须这么做",而不只是给出函数名。

9 · copy 和 append 的区别?

copy 不扩容min(len)append 返回新 header

copy 按最小长度拷内容、不动 dst 的 len/cap、空间不足静默截断;append 管理"空间从哪来",可能换底层数组,必须接住返回值。一个管已有空间,一个管扩容。

10 · 大 slice 截取小引用导致内存泄漏,怎么办?

header 钉住大数组Clone

big[:1] 的 header 指向整个大数组,GC 无法回收其余元素。解法:slices.Clone 复制所需部分;或 copy 到新建小切片;或三索引——三索引只解决 append 覆盖,不解决"钉住内存"(cap 区仍被引用)。

11 · string→[]byte 零拷贝怎么实现?风险?

unsafe.StringDataGo 1.20+破坏不可变=UB

unsafe.Slice(unsafe.StringData(s), len(s)) 直接把 string 的内存映射成 []byte,零分配。风险:写它会破坏 string 不可变约定(map key 等场景直接出错);空串时 StringData 未定义,需判空。热点路径上编译器对不逃逸转换本来就有栈上零拷贝优化。

12 · len("你好") 是多少?怎么数字符?

UTF-8 字节6RuneCountInString

len 数 UTF-8 字节:"你好"=6。字符数用 utf8.RuneCountInString 或 len([]rune(s))(后者多一次解码分配)。for range 按 rune 迭代,下标是字节偏移不连续。乱码类 bug 多半是按字节下标切进了多字节字符中间。

13 · 两个 slice 共享数组,一方 append 后还共享吗?

分界:是否扩容

未扩容:仍共享,且写入落在共享区;触发扩容后:append 方拥有新数组,从此独立,原数组归剩余引用方。所以"是否还共享"取决于该次 append 是否跨过 cap——这正是共享陷阱难以肉眼判断的原因。

14 · make([]int, 0, 100) 和 make([]int, 100) 的区别?

len 0 vs 100零值元素

前者 len=0 cap=100,预分配容量;后者 len=cap=100,含 100 个零值元素(分配同时清零)。要预分配追加空间用前者;后者适合"定长数组语义"。hint 过大时分配器照单全收,反成浪费。

15 · 并发 append 会发生什么?

data raceheader 撕裂-race 可查

append 对 header(array/len/cap)的更新不是原子的:并发下 len 竞态导致覆盖、array 撕裂写、甚至越界 panic;即使写不同下标,扩容搬迁也可能与读并发。方案:互斥锁、分片各自 append 再合并、或 channel 收集。go test -race 能稳定暴露。

QA 第二组偏工程。第十题有个精妙区分:三索引防 append 覆盖,但不解决内存钉住,cap 区仍被引用,要释放必须 Clone 或 copy。第十一题零拷贝要主动报 Go 1.20 的 unsafe.StringData 和空串边界。第十三题把共享与否归因到"是否跨过 cap",这句话能串起前面所有陷阱。第十五题强调 header 竞态不是理论问题,race detector 一跑就出。

Related & References

相关知识点与参考

同领域 deck

内存分配与逃逸分析 →(roundupsize / size class 的完整版)
Go GC →(扩容弃掉的旧数组、泄漏的大数组由 GC 收尾)
map 底层实现与扩容 →(同为"扩容"主题,渐进式搬迁对照)
interface 底层实现 →(接口装箱同样涉及堆分配)
复杂度分析(算法系列)→(append 均摊 O(1) 的证明)
数组与链表(数据结构系列)→(动态数组抽象与缓存视角)

答题串联 · 一图流

结构 → 24B header{array,len,cap}
共享 → 截取不分配 → append 覆盖陷阱 → 三索引
扩容 → nextslicecap 渐进公式 → roundupsize 对齐
工程 → Grow 预留 / Clone 防泄漏 / 传参返回新 slice

参考来源(本 deck 全部结论可溯源至下列一手材料)

go.dev/blog/slices-intro官方博客 Go Slices: usage and internals:header 模型、nil/empty 互换、共享语义
go.dev/blog/slicesRob Pike:Arrays, slices (and strings) — The mechanics of 'append'(append 与共享的官方叙述)
src/runtime/slice.go(对照 Go 1.27)growslice / nextslicecap:256 阈值、渐进公式与源码注释、roundupsize 调用点
src/runtime/sizeclasses.go67+1 个 size class 表(8B–32KB),cap 反推的依据
go.dev/ref/spec §Slice types / §Slice expressions语言规范:full slice expression s[a:b:c]、0 ≤ a ≤ b ≤ c ≤ cap 约束
pkg.go.dev/unsafe(Go 1.20+)StringData / SliceData / String / Slice:官方零拷贝转换及其约束
收尾页给出同领域链接和参考来源。这份 deck 的版本敏感结论都注明了出处:扩容规则对照 Go 1.27 的 runtime/slice.go,nil 与 empty 的互换性出自官方博客原文,三索引的边界条件出自语言规范。复习时回到一手材料验证,别背二手博客的转述。