「Go语言面试题」24 - 深入理解Go Slice:扩容机制、共享底层数组与传参陷阱
引言
「Slice 和数组有什么区别?」「append 之后原来的 slice 会不会变?」「为什么我把 slice 传进函数,append 之后外面的长度没变?」—— 这三个问题几乎出现在每一场 Golang 面试中。Slice 是 Go 日常使用频率最高的复合类型,也是面试中最容易暴露候选人"只会用、不懂原理"的知识点。
本文从底层数据结构出发,讲清 append 扩容规则(含 Go 1.18 的重要变化),并通过三个可运行的代码示例,把 slice 传参陷阱彻底讲明白。
一、Slice 的底层结构
Slice 并不是"动态数组"本身,它只是一个描述符,底层是 runtime 包中的 slice 结构体:
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 长度:当前实际元素个数
cap int // 容量:从指针位置到底层数组末尾的可用空间
}
这意味着一个 slice 变量只有 3 个字段,赋值、传参时拷贝的都是这 3 个字段,底层数组本身不会被复制——这是理解后面所有陷阱的基础。数组则是值类型,整体拷贝,这一点与上一期 Map 的引用语义类似,可对照阅读:《深入理解Go Map底层实现:哈希冲突、扩容迁移与并发安全》。
验证一下 len、cap 和共享底层数组的行为:
package main
import "fmt"
func main() {
s1 := []int{1, 2, 3, 4, 5} // len=5, cap=5
s2 := s1[1:3] // 底层数组相同,len=2, cap=4
s2[0] = 99 // 修改的是共享的底层数组
fmt.Println(s1) // [1 99 3 4 5] —— s1 也被"改"了!
fmt.Println(len(s2), cap(s2)) // 2 4
}
二、append 扩容机制:Go 1.18 前后的关键变化
当 len == cap 时 append 会触发扩容,运行时调用 runtime.growslice。规则(已通过 Go 源码核实):
Go 1.17 及之前:
- 若所需容量
cap > 2 * oldcap,直接扩到所需容量; - 若
oldcap < 1024,翻倍(2 倍); - 若
oldcap >= 1024,循环每次增加 1/4(1.25 倍)。
Go 1.18 起(阈值从 1024 降为 256,增长曲线更平滑):
- 若所需容量
cap > 2 * oldcap,直接扩到所需容量; - 若
oldcap < 256,翻倍(2 倍); - 否则循环执行
newcap += (newcap + 3*256) / 4,直到满足所需容量。
对应源码(src/runtime/slice.go)核心逻辑:
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
const threshold = 256
if old.cap < threshold {
newcap = doublecap
} else {
for 0 < newcap && newcap < cap {
// 从小切片的 2x 增长平滑过渡到大切片的 1.25x 增长
newcap += (newcap + 3*threshold) / 4
}
if newcap <= 0 { // 溢出兜底
newcap = cap
}
}
}
实际扩容后的 cap 还会经过 roundupsize 按内存分配规格(size classes)向上对齐,所以你在程序里打印出来的 cap 往往不是公式算出的精确值。用下面这段代码实测(建议使用 Go 1.18+ 运行):
package main
import "fmt"
func main() {
s := make([]int, 0)
for _, n := range []int{1, 3, 129, 257, 513} {
s = s[:0]
for i := 0; i < n; i++ {
s = append(s, i)
}
fmt.Printf("len=%4d -> cap=%4d\n", len(s), cap(s))
}
}
三、高频陷阱一:append 后"原来的 slice 没变"
这是面试必考题。append 返回的是新的 slice 描述符,如果底层数组还有空间,新旧 slice 共享同一个数组;一旦触发扩容,新 slice 就指向了全新的数组:
package main
import "fmt"
func appendOne(s []int) {
s = append(s, 99) // 传进来的是 slice 的副本
fmt.Println("函数内:", s, len(s))
}
func main() {
s := []int{1, 2, 3} // len=3, cap=3
appendOne(s) // 函数内触发扩容,指向新数组
fmt.Println("函数外:", s, len(s)) // 函数外: [1 2 3] 3 —— 没有任何变化!
}
要点:slice 作为参数仍是值传递,函数内修改的是 3 个字段的副本。想让调用方感知到 append 的结果,需要返回新 slice 或传入 *[]int。这与第 18 期 《值传递 vs 指针传递》 的结论完全一致:Go 里一切传参都是值拷贝,slice 只是拷贝成本低(只拷描述符),并非引用语义。
四、高频陷阱二:共享底层数组导致的数据串改
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4}
a := base[:2] // [1 2] cap=4
b := base[2:] // [3 4] cap=2
a = append(a, 9) // cap 未满,原地写入 index=2
fmt.Println(b) // [9 4] —— b[0] 被悄悄覆盖了!
}
排查这类 bug 时,go build -gcflags="-m" 的逃逸分析输出往往能提供线索,第 12 期 《什么是Go中的逃逸分析》 对此有专门讲解。
五、高频陷阱三:并发读写
和 Map 一样,slice 不是并发安全的:多个 goroutine 同时 append 会互相覆盖数据甚至引发 data race。并发追加的正确姿势是加锁,或者让每个 goroutine 写入自己独立的切片再合并:
package main
import "sync"
func main() {
var mu sync.Mutex
var result []int
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
mu.Lock()
result = append(result, n)
mu.Unlock()
}(i)
}
wg.Wait()
}
锁的选择可以参考第 10 期 《互斥锁(Mutex)和读写锁(RWMutex)的区别》。
速查清单
| 问题 | 一句话答案 |
|---|---|
| Slice 底层是什么? | 三元组(指针、len、cap),底层共享数组 |
| Slice 是引用类型吗? | 描述符按值传递,底层数组共享,不是纯引用语义 |
| Go 1.18 扩容阈值? | 256(此前为 1024),超过后按 (newcap+3*256)/4 平滑增长 |
| append 后原 slice 长度会变吗? | 不会,必须接收返回值:s = append(s, x) |
| 函数内 append 想影响外部? | 返回新 slice,或传 *[]int |
| Slice 并发安全吗? | 不安全,需要 Mutex 保护或分片写入再合并 |
结语
Slice 的考点可以浓缩成一句话:“描述符按值拷贝,底层数组按容量共享,扩容即换数组”。记住这句话,再加上 Go 1.18 的扩容阈值变化,这类面试题就再难得住你了。
如果觉得文章对你有帮助,请点赞、收藏、关注三连支持!
本文是「Go语言面试题」系列第 24 篇,全系列持续更新中,已发布 24 篇。