我以为 append 是复制,结果改了原数组:Go 切片的底层数组共享与扩容规则 个人主页 我不会起名字322 欢迎各位大佬莅临其他栏目 技术栈学习笔记 其他栏目 力扣Hot100题目解析 其他栏目 Go项目学习笔记 其他栏目 redis 其他栏目 mysql 文章目录我以为 append 是复制结果改了原数组Go 切片的底层数组共享与扩容规则先说结论我遇到的事为什么切片其实是个三字段的结构体反直觉一子切片和原切片共用一块内存反直觉二append 有时改原数组有时不改顺带一个容易误解的点扩容规则到底怎么算三种让子切片真正独立的办法一个更隐蔽的问题截取大切片会让内存释放不掉排查速查表小结我以为 append 是复制结果改了原数组Go 切片的底层数组共享与扩容规则摘要记录我第一次遇到 append 改掉原切片时的懵圈过程。讲清切片的三字段结构、子切片为什么共享底层数组、append 在什么条件下就地写入、什么条件下触发扩容并彻底解耦以及三种让子切片真正独立的写法copy、三索引表达式、借 nil 扩容。最后补一个更隐蔽的坑从大切片里截一小段会让整块内存释放不掉。文中所有结论都配了可运行代码和输出建议对着跑一遍。标签go · golang · 切片 · slice · append专栏Go 学习笔记先说结论切片不是数组它是一个{指针, 长度, 容量}的三字段描述符本身不存数据。用s[i:j]得到的子切片和原切片共用同一块底层数组改一个另一个跟着变。append时如果len cap会就地写入底层数组——这就是改了原数组的原因只有len cap触发扩容才会分配新数组和原切片彻底解耦。想让子切片独立用copy、用三索引表达式s[i:j:j]把容量卡死或者append([]T(nil), s...)。我遇到的事那天我在写一个函数想把切片的前两个元素取出来单独处理funcmain(){a:[]int{1,2,3,4,5}b:a[1:3]fmt.Println(b ,b)}输出b [2 3]一切正常。然后我往b里追加了一个元素funcmain(){a:[]int{1,2,3,4,5}b:a[1:3]bappend(b,99)fmt.Println(b ,b)fmt.Println(a ,a)}我预期a还是[1 2 3 4 5]。实际输出是b [2 3 99] a [1 2 3 99 5]a的第四个元素被改成了 99。我压根没碰过a。更让人崩溃的是在后面——我把截取范围改成a[1:4]同样的appenda又没被改。同一个操作两次结果不一样。这种时灵时不灵的 bug 最折磨人所以我把底层机制完整捋了一遍。为什么切片其实是个三字段的结构体Go 的切片在运行时长这样reflect.SliceHeadertypeslicestruct{data unsafe.Pointer// 指向底层数组的指针lenint// 当前长度能访问的元素个数capint// 容量从 data 起算底层数组还剩多少位置}注意data是个指针。切片自己不持有数据它只是底层数组上的一个窗口。关键在len和cap的区别a:[]int{1,2,3,4,5}fmt.Println(len(a),cap(a))// 5 5b:a[1:3]fmt.Println(len(b),cap(b))// 2 4b的长度是 2元素[2 3]但容量是 4。因为b.data指向的是a[1]从那个位置往后数底层数组还剩 4 个位置a[1]到a[4]底层数组 [1] [2] [3] [4] [5] ↑ ↑ b.data b 的容量边界 |---- cap4 ----| |- len2 -|容量大于长度意味着窗口后面还有看不见但存在的空间。这就是所有问题的根源。反直觉一子切片和原切片共用一块内存切片操作a[i:j]不会复制任何数据它只是创建一个新的描述符。所以a:[]int{1,2,3,4,5}b:a[1:3]b[0]999fmt.Println(a)// [1 999 3 4 5]fmt.Println(b)// [999 3]连append都不用直接改元素就已经互相影响了。这个还比较好理解。真正隐蔽的是append的行为。反直觉二append 有时改原数组有时不改append的逻辑其实只有两条分支条件行为对原切片的影响len cap直接写进底层数组的下一个空位原切片被修改len cap分配新数组、拷贝旧数据、再追加原切片不变彻底解耦把前面的例子拆开看。情况一len cap就地写入a:[]int{1,2,3,4,5}b:a[1:3]// len2, cap4bappend(b,99)// 2 4就地写入 a[3]fmt.Println(a)// [1 2 3 99 5] ← 被改了情况二len cap触发扩容a:[]int{1,2,3,4,5}b:a[1:3:3]// 三索引len2, cap2bappend(b,99)// 2 2必须扩容fmt.Println(a)// [1 2 3 4 5] ← 没被改fmt.Println(b)// [2 3 99]三索引表达式a[low:high:max]可以把容量精确限制成max - low。这里cap被卡成 2append立刻触发扩容b拿到一块新数组和a再无关系。所以时灵时不灵的真相是取决于当时容量够不够。而容量够不够又取决于你是怎么截的、以及 append 之前 cap 是多少——它不会写在代码表面这就是为什么这类 bug 难查。顺带一个容易误解的点funcadd(s[]int,vint){sappend(s,v)// 只改了形参 s 这个描述符}funcmain(){a:[]int{1,2,3}add(a,4)fmt.Println(a)// [1 2 3]没有 4}切片是描述符传参传的是这个结构体的副本。append返回的新描述符赋值给了形参s调用方的a毫发无损。除非append触发了扩容底层数组的修改也只发生在形参可见范围内。想让外面看到就得接收返回值a add(a, 4)。扩容规则到底怎么算既然会不会扩容这么关键那容量到底按什么规律增长Go 运行时runtime/slice.go的growslice的规则大致是cap 256直接翻倍newcap oldcap * 2cap 256每次增长约 25%newcap (newcap 3*256) / 4直到能装下新元素最后还有一步内存规格对齐roundupsize把容量向上取整到 Go 的内存尺寸等级第三步会让实际容量比公式算出来的略大所以别硬记具体数字跑一遍最准funcmain(){s:make([]int,0)prev:0fori:0;i2000;i{sappend(s,i)ifcap(s)!prev{fmt.Printf(len%-5d cap%d\n,len(s),cap(s))prevcap(s)}}}在我的环境Go 1.22上输出len1 cap1 len2 cap2 len3 cap4 len5 cap8 len9 cap16 len17 cap32 len33 cap64 len65 cap128 len129 cap256 len257 cap512 len513 cap848 len849 cap1280 len1281 cap1792 len1793 cap2560前 256 是干净的翻倍之后变成 512 → 848 → 1280 → 1792 —— 增长幅度确实是四分之一左右且都不是整数倍就是被内存对齐修过的结果。提醒扩容策略是实现细节不同 Go 版本可能微调。可以拿它估算别拿它做逻辑依赖。三种让子切片真正独立的办法按推荐顺序a:[]int{1,2,3,4,5}// 1. copy —— 最清晰可读性最好b:make([]int,len(a[1:3]))copy(b,a[1:3])// 2. 借 nil 一次性扩容 —— 一行搞定常用b:append([]int(nil),a[1:3]...)// 3. 三索引表达式 —— 卡死容量迫使后续 append 必须扩容b:a[1:3:3]bappend(b,99)三种方式得到的b都和a完全独立改谁都不影响对方。要完全复制一份来用选 1 或 2只是想继续 append 但不污染原切片选 3如果已知最终长度创建时就指定 capmake([]T, 0, n)能省掉所有中间扩容拷贝这是性能上最划算的做法。一个更隐蔽的问题截取大切片会让内存释放不掉这是我踩的第二个坑比第一个隐蔽得多。假设你读了一个 100MB 的文件到切片里只需要前 10 个元素funchead(data[]byte)[]byte{returndata[:10]}返回的切片len10但它的data指针仍指向那块 100MB 底层数组的开头cap依然是 100MB 量级。只要这个返回的小切片还活着GC 就无法回收后面那 100MB。正确做法同样是复制出来funchead(data[]byte)[]byte{out:make([]byte,10)copy(out,data[:10])returnout// 只持有 10 字节的新数组}这类问题在长生命周期的缓存、队列、全局 map 里杀伤力最大——程序看着只用了几 MBRSS 却下不来。排查速查表现象最可能的原因处理没改原切片它却变了子切片共享底层数组 append就地写入copy或三索引s[i:j:j]同一个append有时改有时不改取决于当时len是否等于cap用cap()打点确认函数里append了外面看不到描述符是值传递接收返回值重新赋值内存一直降不下来小切片持有着大底层数组复制出独立小切片想知道容量怎么涨的别猜打印上面那段循环直接跑小结切片的核心就一句话它是底层数组上的一个窗口窗口后面还有多少空间cap决定了append是就地写还是搬家。记住这三条就不会再踩s[i:j]不复制数据共享底层数组append在len cap时就地写会污染所有共享这块数组的切片需要独立就用copy或者用三索引s[i:j:j]把容量卡死。至于扩容倍数——知道它是小于 256 翻倍、之后约涨四分之一、最后内存对齐就够了别去背具体数字。下一篇预告Go map 为什么不能并发读写——那个fatal error背后的扩容与渐进搬迁机制。