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 覆盖
开场 · Why Slices
先想清楚 slice 到底替我们省掉了什么,后面的结构才讲得通。
| 硬伤 | 具体后果 |
|---|---|
| 长度写进类型 | [8]int 与 [9]int 是两个不同类型,函数签名被长度绑死 |
| 传参整体拷贝 | 1000 个 int64 是 8KB,每调一次函数就整块拷一次——语言层面没法只传"其中一段" |
| 没法"追加" | 想要更大空间,只能手写"新建更大的数组 + 拷旧数据",拷贝次数由你自己管 |
用 24 字节的 header 记下"数据在哪(array)、能看几个(len)、还能写几个(cap)"。传 slice 只拷这 24 字节,底层数组留在原地不动。——见第 4、5 页
append 在容量不足时分配更大的数组、拷走旧数据,并把扩容策略设计成摊销 O(1)(先翻倍,再平滑降到 1.25 倍)。——见第 9–12 页
读之前 · Before You Read
术语全部是本 deck 真正会用到的,没有凑数;前置知识不在本 deck 内的,用链接指过去。
| 术语(中英对照) | 一句话白话定义 |
|---|---|
| 数组 array | 一段定长、连续的内存。长度是类型的一部分,[8]int 和 [9]int 是两个类型 |
| 切片 slice | 对某个数组一段连续区间的"视图"。它自己不存元素,只是一个指过去的便签 |
| 头部 header | 切片变量本身,24 字节三个字段:array 指针 / len / cap |
| 长度 len | 当前可见的元素个数——能安全读写的上界,越界就 panic |
| 容量 cap | 从 array 指针到数组末尾还剩多少槽位,即"不搬家还能写几个" |
| 术语(中英对照) | 一句话白话定义 |
|---|---|
| 底层数组 backing array | 真正存元素的那块连续内存。可以被多个 header 同时指向——共享由此而来 |
| 截取 reslice | s[a:b]:在已有切片上取子区间。不分配、不拷贝,只是换一张便签 |
| 追加 append / growslice | 加元素。容量够就写槽位;不够则由 runtime 分配更大的数组、拷过去、换 header |
| 大小类 size class | 分配器预定义的固定档字节数(8B 起步,共 67 档)。申请时向上取最近档,决定了你实际拿到的 cap |
| 摊销 O(1) amortized | 单次扩容可能是 O(n),但连续 n 次 append 的总代价是 O(n),摊到每次就是 O(1) |
Slice 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
| 操作 | 代价与效果 |
|---|---|
| len(s) / cap(s) | O(1) 直读 header 字段,无任何计算 |
| s[i] | *(array + i×elemsize),带边界检查 |
| 赋值 s2 := s | 拷 24B header,不拷数组——共享开始 |
| s[a:b] | 新 header{array+a×size, …},不分配内存 |
| append | cap 够:写槽位返回原 header;不够:growslice 分配新数组 |
数组 [8]int 是值类型:赋值/传参整体拷贝、长度是类型的一部分([8]int ≠ [9]int)。切片把"长度"从类型里拿出来放进 header,才有了动态和共享。
Memory Layout
Creation · nil vs empty
| 写法 | header(array/len/cap) | s == nil | 说明 |
|---|---|---|---|
| var s []int | {nil, 0, 0} | true | nil slice;append 到 nil 会正常分配,只是首元素必须走 growslice |
| s := []int{} | {指向 zerobase, 0, 0} | false | empty 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 共享底层数组(下一页推导) |
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 这类比较语义。
Reslicing · s[a:b]
Aliasing Walkthrough
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 脱钩
len < cap 时写的是原数组的槽位——"append 静默覆盖"是共享陷阱里最阴的一题:b 以为自己 append,a 无辜被改。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 的函数 | 意识到底层可能被写:要么传三索引副本,要么文档注明 |
growslice Pipeline
Growth Factor · Go 1.18 分界
// 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.17 | cap < 1024 → 2×;否则一律 1.25×(1024 处硬切换,增长曲线跳变) |
| ≥ Go 1.18 | 阈值降到 256;256 以下 2×,以上按渐进公式平滑过渡到 1.25×——大 slice 不再"从翻倍突降四分之一"的内存模式抖动 |
① newLen > 2×oldCap 时(一次 append 一大截)直接给 newLen,保证至少装得下;② 公式算出的是"期望 cap",还要过 roundupsize 才是真实 cap;③ 规则只约束 runtime 的 append——语言规范不承诺任何增长因子,不同版本可变。
为什么 256?小切片翻倍的内存浪费可忽略,扩容搬迁成本才是大头;大切片翻倍浪费显著,改用缓增长。源码注释给出同样权衡。
Size Class Alignment
| 写法 | 申请字节 | size class | 实际 cap |
|---|---|---|---|
| make([]byte, 5) | 5 B | 8 B | 8(≠5) |
| make([]int64, 3) | 24 B | 24 B | 3 |
| make([]bool, 17) | 17 B | 24 B | 24(≠17) |
| make([]int8, 1) 后 append | 2 B | 8 B | 8(≠2) |
newcap × elemsize 字节,mallocgc 按最近 size class 向上分配,再由 roundupsize(bytes) 反推真实 cap = 分配字节数 ÷ elemsize。小元素吃对齐红利最大。// class bytes/obj … 1 8 // 最小 8B 2 16 3 24 4 32 5 48 6 64 … 32 1024 … 67 32768 // ≤32KB 走小对象分级
渐进公式给 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 生成
Empirical Growth · Go 1.21+ slices
| append 至 len | cap(实测) | 依据 | 说明 |
|---|---|---|---|
| 1 → 2 → 4 → 8 … | 1, 2, 4, 8, 16, 32, 64, 128 | oldCap < 256 → 2× | 纯翻倍段,且每步都恰好落在 size class 上 |
| 256 → 512 | 512 | 公式:256×2.0(1.25+192/256) | 渐进段第一跳,系数仍是 2 |
| 512 → 832 期望 | 848 | 512×1.625=832 → 6656B → class 6784B | roundupsize 抬高的一跳 |
| 848 → 1252 期望 | 1280 | 848×(1.25+192/848)≈1252 → 10016B → class 10240B | 此后增长放缓、被对齐"取整" |
slices.Grow(s, n):一次性扩到至少 len+n,消除多次 append 的反复搬迁(内部同款 growslice);slices.Clone 独立副本;slices.Clip 三索引掐掉余量;slices.Concat 合并。批量构建前先 Grow 是消除搬迁毛刺的标准姿势。
已知规模:make([]T, 0, n) 一次分配、零搬迁,但 hint 过大反而浪费(分配器照单全收);未知规模且逐个 append:从 nil 开始,让渐进扩容自己找节奏。两难时用 append(nil-ish, xs...) 一把 append 整批,runtime 按 newLen 直接给足。
copy() · Pass-by-value
按 min(len(dst), len(src)) 拷贝,返回拷贝数;不扩容、不改 dst 的 len/cap——空间不够就静默截断(返回值提醒你)。dst 必须先有空间。字符串也支持:copy(b, "hi")。
返回新 header(len 变了/数组换了),必须接住返回值:s = append(s, x)。不接住时 len 增长丢失,若搬家了甚至丢数据。二者一个管"往已有空间里放",一个管"空间从哪来"。
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 触发扩容 | 完全不可见(新数组只在被调方) |
func add(s []int) []int)由调用方接住;或传 *[]T。标准库全是前者——append 的返回值语义决定了这是唯一一致的 API 形态。Conversion Cost · Unicode
| 操作 | 底层 |
|---|---|
| string → []byte | 分配新数组 + copy:string 不可变,若不拷贝,写 []byte 会破坏 string 的不变量 |
| []byte → string | 分配 + copy:保证结果不可变;否则 b 变了 s 跟着变 |
| []rune(s) | UTF-8 解码为 []int32:每 rune 4B,长度 ≠ 字节数 |
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)。一旦赋值给变量逃逸了,就回落到真拷贝。
len("中") == 3(字节数);utf8.RuneCountInString("中") == 1;for i, r := range s 按 rune 迭代且 i 是字节下标(不连续)。逐字符处理长文本:rune 切片便于随机访问,代价是 4 倍展开 + 解码分配。
Pitfall Checklist
| 坑 | 现象 | 修法 |
|---|---|---|
| ① 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) |
| ⑥ 并发 append | header 的 len/array 竞态(撕裂写),元素错乱甚至越界 | 锁 / channel / 分片聚合 |
| ⑦ 遍历中 append 自身 | for range s { s = append(s, x) }:range 用开始时的 header 快照,扩容后行为"错位" | 先收集再合并,或复制迭代 |
| ⑧ nil/empty 序列化 | JSON 输出 null 与 [] 混用打爆前端 | 统一初始化口径,或自定义 MarshalJSON |
速查 · Cheat Sheet
面试前扫一眼:左列是"拿到一个切片,它的 len/cap 是多少",右列是"用的时候别踩什么"。
① header 速算:先判创建方式,再判截取
| 写法 | len / cap 结果 |
|---|---|
| var s []T | len=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−a,cap = cap(源) − a;不分配不拷贝 |
| s[a:b:c] | len = b−a,cap = 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 可查 |
Interview QA · 1/2
先盖住答案自答一遍,再逐题对照——答不上来的那几题,就是今晚要补的地方。
runtime.slice{array, len, cap} 三元组,64 位下 24B,栈上按值传递。数组是值类型、赋值整体拷贝、长度属于类型;slice 把长度放进 header,底层数组在堆上,可被多个 header 共享。
len = b−a;cap = 原cap − a(array 指针偏移到 a,cap 语义是"从指针到数组末尾的余量")。截取不分配内存,与源共享底层数组,改动互相可见。
需求优先(newLen>2×cap 直接给 newLen);否则 oldCap<256 翻倍;以上走渐进公式 newcap += (newcap+3×256)/4,系数从 2.0 平滑降到 1.25。1.18 前是 1024 阈值两段式硬切换。规范不承诺增长因子。
growslice 按 newcap×elemsize 申请,mallocgc 按 size class 向上分配,roundupsize 反推真实 cap=分配字节÷elemsize。例:渐进公式给 832(512×1.625),6656B 对齐到 6784B 类,真实 cap=848。
var s []int 的 header 是 {nil,0,0},==nil 为真;[]int{} 的 array 指向 zerobase。运行时行为等价(len=0、append/range 一致);差异在 JSON 序列化(null vs [])与 reflect.DeepEqual 比较。
不一定。编译器内联检查 cap−len 够就直接写槽位,零分配;不够才调 growslice 搬家。反过来,cap 够但别处共享数组时,append 会"静默"写进共享区——比分配更危险的坑。
full slice expression 把 cap 钉成 c−a。常用 s[i:j:j] 把 cap 归零,append 立即触发扩容搬新家,杜绝写进共享原数组。适合"截取后还要 append"和防御性复制场景。
改内容(s[i]=x)可见——同一底层数组;cap 有余量时 append 写入 cap 区但外部 len 不变——"看不见却写入";触发扩容则完全不可见。规范做法:函数返回新 slice 由调用方接住。
Interview QA · 2/2
同样先自答再对照。这组题偏工程,答的时候要能说出"为什么必须这么做",而不只是给出函数名。
copy 按最小长度拷内容、不动 dst 的 len/cap、空间不足静默截断;append 管理"空间从哪来",可能换底层数组,必须接住返回值。一个管已有空间,一个管扩容。
big[:1] 的 header 指向整个大数组,GC 无法回收其余元素。解法:slices.Clone 复制所需部分;或 copy 到新建小切片;或三索引——三索引只解决 append 覆盖,不解决"钉住内存"(cap 区仍被引用)。
unsafe.Slice(unsafe.StringData(s), len(s)) 直接把 string 的内存映射成 []byte,零分配。风险:写它会破坏 string 不可变约定(map key 等场景直接出错);空串时 StringData 未定义,需判空。热点路径上编译器对不逃逸转换本来就有栈上零拷贝优化。
len 数 UTF-8 字节:"你好"=6。字符数用 utf8.RuneCountInString 或 len([]rune(s))(后者多一次解码分配)。for range 按 rune 迭代,下标是字节偏移不连续。乱码类 bug 多半是按字节下标切进了多字节字符中间。
未扩容:仍共享,且写入落在共享区;触发扩容后:append 方拥有新数组,从此独立,原数组归剩余引用方。所以"是否还共享"取决于该次 append 是否跨过 cap——这正是共享陷阱难以肉眼判断的原因。
前者 len=0 cap=100,预分配容量;后者 len=cap=100,含 100 个零值元素(分配同时清零)。要预分配追加空间用前者;后者适合"定长数组语义"。hint 过大时分配器照单全收,反成浪费。
append 对 header(array/len/cap)的更新不是原子的:并发下 len 竞态导致覆盖、array 撕裂写、甚至越界 panic;即使写不同下标,扩容搬迁也可能与读并发。方案:互斥锁、分片各自 append 再合并、或 channel 收集。go test -race 能稳定暴露。
Related & References
内存分配与逃逸分析 →(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/slices | Rob Pike:Arrays, slices (and strings) — The mechanics of 'append'(append 与共享的官方叙述) |
| src/runtime/slice.go(对照 Go 1.27) | growslice / nextslicecap:256 阈值、渐进公式与源码注释、roundupsize 调用点 |
| src/runtime/sizeclasses.go | 67+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:官方零拷贝转换及其约束 |