slice 是"数组的三元视图" — ptr/len/cap 三元组, append 扩容搬家, 共享底层数组的暗雷
slice 不是"动态数组", 是数组上的一张三元视图: (ptr, len, cap)。赋值和传参拷的只是这三个字段, 数据原地不动 —— 这是它便宜的原因, 也是一切暗雷的来源: 两个切片可以看同一段底层数组, 一方的写入另一方立刻可见。
append 有双重性格: cap 还够就原地写 (可能踩到隔壁切片的地盘), 不够就整段搬家到新数组 (旧数组留给还引用它的人)。所以"s = append(s, x) 必须赋回"和"函数里改元素外部看得见"这对看似矛盾的现象, 都是同一张 header 图画出来的。
{ptr, len, cap} 三个字段: ptr 指向底层数组某元素, len 是当前视野, cap 是到数组末尾的余量。赋值/传参/比较都是拷这 24 字节, 不碰数据。s := make([]int, 3, 5) fmt.Println(len(s), cap(s)) // → 3 5 b := s // 关键: 只拷 24B header, 数据不动 fmt.Println(len(b), cap(b)) // → 3 5, 与 s 同看一段格子
var s []int 是 nil (header 全零); s := []int{} 非 nil, ptr 指向 zerobase。两者 len==0 行为一致, 但 json 序列化一个是 null 一个是 []。var a []int // nil: header 全零 b := []int{} // 非 nil, len=0 fmt.Println(a == nil, b == nil) // → true false // json: a → null, b → []
make([]T, 0, n) 免掉全部中间拷贝。s := make([]int, 0) s = append(s, 1, 2, 3) fmt.Println(len(s), cap(s)) // → 3 3 (按需+对齐 size class) s = append(s, 4, 5, 6, 7) fmt.Println(len(s), cap(s)) // → 7 8 关键: 搬家即拷贝
s2 := s[1:3] 不拷数据, 两个 header 指向同一段格子; 改 s2[0] 等于改 s[1]。读多写少的分页/视图场景是福利, 乱写就是数据污染。s := []int{1, 2, 3, 4} s2 := s[1:3] // 不拷数据, 同看一段格子 s2[0] = 99 // 改 s2[0] 等于改 s[1] fmt.Println(s[1]) // → 99
b := a 拷 header; copy(b, a) 才逐元素复制数据, 返回实际拷贝条数 (受 min(len) 限制) —— 想要独立副本必须先 make 再 copy。a := []int{1, 2, 3} b := a // 只拷 header b[0] = 99 // a[0] 也变 99 c := make([]int, len(a)) n := copy(c, a) // 关键: 逐元素复制, n=3
s[a:b:c] 把 cap 钉死为 c-a, append 一撞墙就搬家 —— 与兄弟切片隔离的标准姿势, 标准库接口返回内部缓冲时常用。s := []int{1, 2, 3} s2 := s[0:2:2] // 关键: cap 钉死为 2 _ = append(s2, 99) // 撞墙搬家, 写进新数组 fmt.Println(s) // → [1 2 3], 本体没被踩
make([]T, 0, n): 一次分配到位; make([]T, n) 则是 n 个零值起步 —— 长度和容量语义别混。a := make([]int, 3) // len=3: 3 个零值起步 a = append(a, 1) // → [0 0 0 1] b := make([]int, 0, 3) // 关键: 空但备好空间 b = append(b, 1) // → [1], 0 次再分配
func touch(s []int) { s[0] = 99 // 外部可见: 共享数组 s = append(s, 7) // 外部不可见: header 分叉 }
[5]int 是值类型: 赋值/传参全量拷贝, 可 ==; []int 是 header: 拷贝极廉价, 不可 ==。数组是切片的"地基", 业务代码几乎永远用切片。a := [3]int{1, 2, 3} b := a // 数组: 全量拷贝 b[0] = 99 fmt.Println(a[0]) // → 1 不受影响 s1 := []int{1, 2, 3}; s2 := s1 // 切片: 只拷 header s2[0] = 99 // s1[0] → 99
== 直接编译错 (引用语义无法定义相等), 只能与 nil 比; 内容比较用 bytes.Equal / slices.Equal 或自写循环。_ = s1 == s2 // 编译错: slice can only be compared to nil fmt.Println(slices.Equal( []int{1}, []int{1})) // → true, 内容比较
s[i]: 0≤i<len; 切片表达式 s[a:b]: 0≤a≤b≤cap(s); 三索引 s[a:b:c]: 还要求 c≤cap(s)。越界都是运行时 panic: index out of range / slice bounds out of range。x := make([]int, 3, 8) _ = x[3] // panic: index out of range [3] with length 3 _ = x[0:9] // panic: slice bounds out of range [:9] with capacity 8 _ = x[0:2:9] // panic: [::9] with capacity 8, c 不得超 cap
s = s[:0] // 错: 槽位仍引用旧对象, GC 回收不了 for i := range s { s[i] = nil } // 对: 复用前清槽 s = s[:0] // 再复用, 零分配
keep := big[:10] 让 100MB 底层数组活到 keep 死 —— GC 只看引用不看 len。想只留小片: copy 出去再弃大数组。big := make([]byte, 100<<20) keep := big[:1024] // 错: 100MB 活到 keep 死 own := make([]byte, 1024) copy(own, big[:1024]) // 对: copy 出去再弃大数组
unsafe 零拷贝必须保证"不再修改 + 生命周期", 生产要 -race 验证 + 评审背书。s := string(b) // 全量拷贝 (安全) b2 := []byte(s) // 也是拷贝, 改 b2 不影响 s // unsafe 零拷贝: 不再修改 + 生命周期可控才可用
热路径上切片从空长起, 每次翻倍都是"分配+拷贝+旧数组进 GC", 三重开销:
func withoutPrealloc(n int) []int64 { s := make([]int64, 0) // 起点 cap=0: 之后 log2(n) 轮搬家拷贝 for i := 0; i < n; i++ { s = append(s, int64(i)) } return s } func withPrealloc(n int) []int64 { s := make([]int64, 0, n) // 已知规模: 一次到位, 0 次再分配 for i := 0; i < n; i++ { s = append(s, int64(i)) } return s } // go test -bench=Pre -benchmem // BenchmarkWithout-8 5841722 205.1 ns/op 16376 B/op 11 allocs/op // BenchmarkWith-8 7628761 78.4 ns/op 8192 B/op 1 allocs/op
每页 copy 一份 80MB 数据, P99 直接劣化; 返回视图只花 24 字节:
// 100 万行的导出报表: 每次翻页拷贝 = 80MB/页, 网关内存被打爆 func (q *PageQuery) View(all []Row) []Row { start := (q.page - 1) * q.size if start >= len(all) { return nil // 越界页: 返回空由上层决定 404 } end := start + q.size if end > len(all) { end = len(all) // 末页不满一页: clamp 到边界 } return all[start:end] // 视图: 只拷 header, 数据不动 } // 契约注释必写: 返回值与 all 共享底层, 调用方只读不可改 // 一旦有人改元素, 本体数据就被污染 (共享数组暗雷)
收益: 翻页内存恒定, GC 停顿从 40ms 级降到不可测。
抓取第三方接口, 响应体大小不可控, io.ReadAll 一路翻倍峰值≈2×终值:
// 事故: 256MB 容器抓到 800MB 响应, 直接 OOMKilled const maxBody = 64 << 20 // 64MB: 按业务最大合法响应定 var buf bytes.Buffer buf.Grow(1 << 20) // 预热 1MB, 减少翻倍次数 n, _ := io.CopyN(&buf, resp.Body, maxBody+1) // 多读 1 字节探测超限 if n > maxBody { return nil, fmt.Errorf("body exceeds %d bytes", maxBody) // 熔断 } // 未超限: buf.Bytes() 就是完整数据, 峰值内存 = 实际大小 + 1MB
rows 来自 sync.Pool 缓冲, 异步写库还没落, 缓冲已被下轮复用填充 —— 数据悄悄串单:
func insertAll(ctx context.Context, rows []Row) error { const batch = 500 for start := 0; start < len(rows); start += batch { end := min(start+batch, len(rows)) chunk := rows[start:end] own := make([]Row, len(chunk)) // 拷独立副本: 切断与池化缓冲的共享 copy(own, chunk) // 池子复用会改写底层, 异步侧还拿着旧引用 if err := db.InsertBatch(ctx, own); err != nil { return fmt.Errorf("batch %d-%d: %w", start, end, err) } } return nil }
高频路径上 strconv/日志拼接 12% CPU 花在无谓拷贝; unsafe 能救但要立规矩:
// 高频日志路径: []byte(string) 每次全量拷贝, profile 里 12% 在转换 func unsafeString(b []byte) string { return *(*string)(unsafe.Pointer(&b)) // 零拷贝: string 头直接盖在 slice 头上 } // 铁律 1: 返回值生命周期内 b 不得被修改 (string 不可变契约被打破 = 数据竞争) // 铁律 2: b 之后被 append 搬家没关系, 但底层数组不能被复用写入 // 生产流程: go test -race 全绿 + 压测对比 + 注释写明约束与评审人 // 不确定就别用: strings.Builder / bytes.Buffer 池化足够覆盖 90% 场景
从 []Row 投影 []DTO, 往往比数据本身还高频; 遍历次数和分配次数都要抠:
func toDTOs(rows []Row) []DTO { out := make([]DTO, 0, len(rows)) // 上限预分配: 一次到位 0 扩容 for _, r := range rows { out = append(out, DTO{ ID: r.ID, Name: r.Name, State: stateName(r.State), // 枚举码翻译在投影层一次做完 }) } return out // rows 为空时返回 [] 而非 null, 前端少判空 }
监控滑窗只要最近 512 个点, append 版每轮都白扔旧数据给 GC:
type Window struct { buf []float64 // 固定容量 512: 满后环形覆盖 head int // 下一个写入位 } func NewWindow(n int) *Window { return &Window{buf: make([]float64, 0, n)} // len 从 0 长到 cap } func (w *Window) Push(v float64) { if len(w.buf) < cap(w.buf) { w.buf = append(w.buf, v) // 未满: 正常增长 return } w.buf[w.head] = v // 已满: 原地覆盖, 永零分配 w.head = (w.head + 1) % cap(w.buf) // 环形推进 }
分片下载聚合, 盲 append 会经历 log2(总大小) 次搬家; 按元数据预估一步到位:
func collect(ch <-chan Chunk) []byte { out := make([]byte, 0, estimate()) // 按文件大小预估, 偏差 20% 内都赚 for c := range ch { out = append(out, c...) // cap 够就纯 memcpy; 不够一次搬家 } return out // 预估失败也只是多一次拷贝, 不是错误 } func estimate() int { if fi, err := os.Stat(path); err == nil { return int(fi.Size()) + 4096 // 文件大小 + 头部余量 } return 1 << 20 // 未知: 1MB 起步 }
map 无序、set 不存在; 要"去重且保持首次出现顺序"就得双结构配合:
func dedupKeepOrder(in []string) []string { seen := make(map[string]struct{}, len(in)) // 上限预分配: map 也不扩容 out := make([]string, 0, len(in)) // 最坏全保留: 按 n 预留 for _, v := range in { if _, ok := seen[v]; ok { continue // map 负责判重 } seen[v] = struct{}{} out = append(out, v) // 切片负责保序 — 双结构配合 } return out }
heap profile 只有 30MB, 容器却涨到 512Mi 被 OOMKill —— 滞留的引用不在 heap 常驻统计里显眼:
# 事故: 容器 limit 512Mi, RSS 缓涨到 OOMKilled; heap profile 却只有 30MB # 根因: 100MB 大切片切出 1KB 视图长期持有, GC 无法回收底层数组 apiVersion: apps/v1 kind: Deployment spec: template: spec: containers: - name: api resources: requests: { memory: "384Mi" } limits: { memory: "512Mi" } env: - name: GOMEMLIMIT # Go 1.19+: 让 GC 看见容器边界 value: "450MiB" - name: GOGC value: "100" # 代码侧正解: 视图要独立存活时, copy 出小片再弃大数组 # view := make([]byte, 1024); copy(view, big[:1024]); big = nil
改完 RSS 稳在 60Mi; GOMEMLIMIT 只是止血带, 根治还得砍掉滞留引用。
s2 := s[1:5] 后 append s 或 s2, 另一方数据"莫名"被改. 原因: 两者 cap 罩着同一段格子, 原地写互相踩. 正解: 要隔离就 s2 := s[1:5:5] 或 copy 出独立副本。s := []int{1, 2, 3, 4} s2 := s[1:3] // 错: cap 直通到数组末尾 s2 = append(s2, 99) // 原地写: s → [1 2 3 99] s3 := s[1:3:3] // 对: 钉死 cap, append 必搬家
return append(s, x) 或传 *[]T。func add1(s []int, x int) { s = append(s, x) // 错: 新 header 留在函数里 } func add2(s []int, x int) []int { // 对: 返回新切片 return append(s, x) }
func f(s []int) { s[0] = 99 // 生效: 元素写进共享数组 s = append(s, 7) // 错: 不生效, header 分叉 } // 对: 契约写明谁拥有切片, 追加必须返回
s[i] = nil 清引用, 或池化结构明确生命周期。s = s[:0] // 错: 槽位仍引用旧元素, GC 不收 for i := range s { s[i] = nil } // 对: 复用前清引用 s = s[:0] // 再复用, 零分配
min(len(dst), len(src)) 条, dst 太短就悄悄丢数据. 正解: if n := copy(dst, src); n != len(src) { ... } 校验。copy(dst, src) // 错: dst 短则静默截断 if n := copy(dst, src); n != len(src) { // 对: 校验条数 return fmt.Errorf("copied %d/%d", n, len(src)) }
s[1:3] 的 cap 直通到数组末尾, append 照样踩兄弟. 正解: s[1:3:3], 第三个数字才是隔离钉子。s2 := s[1:3] // 错: cap 直通到数组末尾 s2 = append(s2, 7) // 照样踩兄弟切片 s3 := s[1:3:3] // 对: 第三个数字才是隔离钉子
null, 前端 if (arr.length) 直接 TypeError. 正解: 出参统一 make([]T, 0) 或 []T{} 起步。var a []string // json → null b := make([]string, 0) // json → [] // 错: 前端 arr.length 对 null 报 TypeError // 对: 出参统一 []T{} 起步
ptrs = append(ptrs, &v) 存的全是同一个地址, 最后全指向最后一轮的值. 正解: 升 1.22+ (每轮新变量) 或循环内 v := v / &s[i]。for _, v := range items { ptrs = append(ptrs, &v) // 错(1.22前): 全是同一个地址 v := v // 对: 老版本定格副本 ptrs = append(ptrs, &v) // 或直接 &items[i] }
var m [][]int; m[0] = append(m[0], 1) 直接 index out of range. 原因: 外层 len 为 0. 正解: 先 append(m, make(...)) 或 make 外层长度。var m [][]int m[0] = append(m[0], 1) // 错: panic: index out of range [0] m = append(m, []int{1}) // 对: 先长外层 m2 := make([][]int, 3) // 对: 或 make 外层长度
make([]T, n) 出发即 n 个零值, 再 append 从第 n+1 位接上, 遍历时前面带一截零值. 正解: 要"空但备好空间"是 make([]T, 0, n)。s := make([]int, 3) // 错: 想要空间却得到 3 个零值 s = append(s, 1) // → [0 0 0 1] s2 := make([]int, 0, 3) // 对: 空但备好空间
keep := make([]T, k); copy(keep, big[:k]) 再弃 big。keep := big[:1024] // 错: 100MB 底层数组活着 own := make([]T, 1024) // 对: copy 出小片 copy(own, big[:1024]) big = nil // 再弃大数组
slices.SortStableFunc, 或比较器补唯一键做全序。sort.Slice(rows, func(i, j int) bool { return rows[i].key < rows[j].key // 错: 相等 key 顺序乱 }) slices.SortStableFunc(rows, cmp) // 对: 稳定排序
dst = append(src, dst...), 结果是 src 前缀 + dst 尾巴, 数据错乱. 正解: dst = append(dst, src...), 永远"往谁里面加"在前。dst = append(src, dst...) // 错: src 前缀 + dst 尾巴 dst = append(dst, src...) // 对: "往谁里面加"在前
s[i] 超 len、s[a:b] 的 b 超 cap、三索引 c 超 cap, 报错都是 runtime panic. 正解: 外部输入先 clamp (min/max) 再切片。_ = s[5] // 错: index out of range [5] with length 3 _ = s[0:9] // 错: slice bounds out of range [:9] with capacity 8 end := min(end, len(s)) // 对: 外部输入先 clamp 再切
b := []byte(s); b[0]='X' 以为 s 也变了, 其实互不影响. 原因: 转换是全量拷贝. 正解: 要共享必须 unsafe 且遵守不可变约束 (见场景 5)。b := []byte(s) b[0] = 'X' // 错: 以为 s 也变了, 其实是副本 fmt.Println(s) // → 原 string 不变 // 对: 要共享必须 unsafe 且守不可变约束
for i := range s { if bad(s[i]) { s = append(s[:i], s[i+1:]...) } } 删除后下一轮 i 照加, 跳过紧邻的元素. 正解: 倒序删, 或写过滤循环重建切片。for i := range s { if bad(s[i]) { s = append(s[:i], s[i+1:]...) // 错: 跳过紧邻元素 } } for i := len(s) - 1; i >= 0; i-- { // 对: 倒序删 if bad(s[i]) { s = append(s[:i], s[i+1:]...) } }
bytes.Equal, 通用用 slices.Equal, 结构复杂自写循环。_ = s1 == s2 // 错: slice can only be compared to nil fmt.Println(bytes.Equal(a, b)) // 对: 字节串 fmt.Println(slices.Equal(s1, s2)) // 对: 通用内容比较
dst := make([]byte, 0, n) 只给了容量, copy 受 min(len(dst), len(src)) 限制返回 0, 数据全丢. 正解: make([]T, len(src)) 按"长度"分配, 或 copy 后 dst = dst[:n]。dst := make([]byte, 0, n) // 错: len=0, copy 返回 0 copy(dst, src) dst := make([]byte, len(src)) // 对: 按"长度"分配 copy(dst, src)
-race 直接报 DATA RACE. 原因: header 各自拷贝, len/cap 更新互相踩. 正解: mutex 保护, 或各建局部结果最后合并, 或 chan 收集。// 错: 多 goroutine 并发 append 同一切片 // go test -race → WARNING: DATA RACE, 数据随机丢失 mu.Lock() // 对: mutex 保护 out = append(out, x) mu.Unlock()
append(s, x) 单独一行, 编译器不报错, 扩容后数据全在"没人引用的新家"里丢了. 正解: s = append(s, x) 永远赋回; 评审时专门盯这个模式。append(s, x) // 错: 扩容后数据在"没人引用的新家" s = append(s, x) // 对: 永远赋回 // 评审时专门盯这个模式