前言
slice是go中很基础的一个数据结构,本文将从源码和汇编的角度,分析slice的参数传递,append,扩容,截取,拷贝,遍历。这些操作的实现原理
基于源码go1.24.1
数据结构,初始化
slice底层结构为:
go
// src/reflect/value.go
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
-
Data
:指向底层数组的起始地址 -
Len
:当前 slice 的长度- 代表当前slice已经装了多少个元素 ,可以访问
[0:len)
范围内的元素。不能访问index>=len
范围内的元素,否则会panic - 也代表下次append时,往哪个index追加
- 代表当前slice已经装了多少个元素 ,可以访问
-
Cap
:当前 slice 的容量。当len等于cap时,再append就需要扩容了
初始化
slice有3种初始化方法:
指定len和cap初始化:其len是0,cap是8

适用场景:先分配一块空闲,后续用append添加元素
go
func main() {
s := make([]int, 0, 8)
fmt.Println(len(s)) // 0
fmt.Println(cap(s)) // 8
}
指定cap初始化:其len和cap都是8
使用场景:先分配一块空闲,后续根据索引修改元素,而非用append
go
func main() {
s := make([]int, 8)
fmt.Println(len(s)) // 8
fmt.Println(cap(s)) // 8
}
初始化时同时赋值,其len和cap都是3
适用场景:已知初始元素的值,希望直接初始化
go
func main() {
s := []int{1, 2, 3}
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 3
}
作为参数传递
Go 中通过参数传递 slice,核心机制是:将slice header拷贝了一份传到被调用方法中
下面将调用者称为caller,被调用者称为callee
核心机制为:
- callee修改底层数组,会反映到caller :例如callee内通过
s[i] = x
修改元素,是通过data
指针访问底层数组 - 不管callee怎么操作,caller 的 len 和 cap 不变 :因为callee对
s
的len
或cap
的修改(如s = s[:2]
)只影响拷贝的 header,不会影响原 slice- 除非callee最后把s返回给caller
下面看slice header是怎么被传递的
go
func main() {
s := []int{1, 2, 3}
changeSlice(s)
}
func changeSlice(s []int) {
s[0] = 1
s[1] = 1
s[2] = 1
}
main方法的汇编代码:
assembly
LEAQ main..autotmp_2+24(SP), AX
MOVQ $1, (AX)
MOVQ $2, 8(AX)
MOVQ $3, 16(AX)
MOVQ AX, main.s+48(SP)
MOVL $3, BX
MOVQ BX, CX
CALL main.changeSlice(SB)
- 底层数组在栈上分配内存,取SP+24的地址作为slice底层数组的首地址
- 将slice的数据1,2,3放到栈上对应位置
- 将3赋值给BX和CX
- 将AX,BX,CX作为参数,调changeSlice方法
- AX:存储slice底层数组的地址
- BX:slice的len
- CX:slice的cap
因此在方法间传递切片时,会对 sliceheader实例本身进行一次值拷贝,然后将 sliceheader的副本传到局部方法中
append追加
go
func changeSlice(s []int) {
s = append(s, 4)
}
流程:
- 如果slice后面还有空间,在就末尾追加一个元素
- 否则扩容,再在后面追加一个元素
我们看看changeSlice的汇编代码:
首先,s底层数组地址,len,cap,被caller放在寄存器AX,BX,CX中
assembly
00029 INCQ BX
00032 CMPQ CX, BX
00035 JCC 39
00037 JMP 41
00039 JMP 60
INCQ BX
:尝试将len
加 1(因为要append
一个元素)。CMPQ CX, BX
:比较new_len
(BX)和cap
(CX)JCC 39
:如果CX >= BX,也就是cap>=new_len
,表示不用扩容,就跳转到39,进而跳转到60,执行追加操作JMP 41
:否则跳转到41,执行扩容操作
下面是扩容操作:
assembly
00041 MOVL $1, DI
00046 LEAQ type:int(SB), SI
00053 CALL runtime.growslice(SB)
00058 JMP 60
MOVL $1, DI
:表示要追加 1 个元素。LEAQ type:int(SB), SI
:将int
类型信息传给SI
寄存器(用于growslice
知道元素大小和类型)CALL runtime.growslice(SB)
:调用运行时函数进行切片扩容。
growslice
的返回值:
AX
: 新的数据指针(可能已迁移)BX
: 新的长度(原len + 1
)CX
: 新的容量
最后是往尾部append一个元素:
assembly
00060 LEAQ (AX)(BX*8), DX
00064 LEAQ -8(DX), DX
00068 MOVQ $4, (DX)
LEAQ (AX)(BX*8), DX
:计算&data[len]
的地址,并放到DXAX
是底层数组指针(data
)BX
是新的len
(注意:此时BX
是len+1
,但我们要写的是len
位置)BX*8
:因为int
在 64 位系统上是 8 字节(AX)(BX*8)
表示data + len*8
,即&data[len]
LEAQ -8(DX), DX
:减去 8 字节,得到&data[len-1]
,也就是最后一个有效位置(即刚追加的位置- 这是因为
BX
是len+1
,所以data + (len+1)*8 - 8 = data + len*8
,即&data[len]
正确位置
- 这是因为
MOVQ $4, (DX)
:将值4
写入该地址
扩容
slice可以自动进行扩容,当调用append
方法时,如果老的len == cap
,即容纳不下新的元素 的时,编译器会调runtime.growslice
方法,帮我们为切片的底层数组分配更大的内存空间
方法签名为:
go
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice
参数 | 说明 |
---|---|
oldPtr | 原切片底层数组的指针 |
newLen | 扩容后的新长度(即 oldLen + num) |
oldCap | 原切片的容量 |
num | 要追加的元素个数(这里是 1) |
et | 元素类型信息(包含大小、是否含指针等) |
具体流程如下:
拿到原始长度:
go
oldLen := newLen - num
计算新的容量
go
newcap := nextslicecap(newLen, oldCap)
nextslicecap
其中计算新容量nextslicecap方法如下:
newLen
:扩容后的目标长度oldCap
:当前切片的容量- 返回值:新容量
go
func nextslicecap(newLen, oldCap int) int {
newcap := oldCap
doublecap := newcap + newcap
// 如果目标长度 newLen 已经大于当前容量的两倍,那就直接返回 newLen
if newLen > doublecap {
return newLen
}
const threshold = 256
// 如果当前容量小于 256,则直接返回 oldCap * 2
if oldCap < threshold {
return doublecap
}
//
for {
// 等价于:newcap += newcap/4 + 192
newwcap += (newcap + 3*threshold) >> 2
if uint(newcap) >= uint(newLen) {
break
}
}
return newcap
}
整体流程为:
- 如果目标长度
newLen
已经大于当前容量的两倍,那就直接返回newLen
例如:
go
s := make([]int, 1, 10)
s = append(s, make([]int, 1000)...)
此时 newLen = 1001
,doublecap = 20
,显然 1001 > 20
,所以直接返回 1001
- 当前容量小于 256,则直接返回
oldCap * 2
- 小切片频繁
append
时,翻倍增长可以显著减少内存分配次数
- 小切片频繁
- 当前容量大于256,逐步从翻倍增长过渡到1.25倍增长
- 新容量公式为:
newcap += newcap/4 + 192
- 当
newcap
很小时(接近 256),192
占比大 → 增长更快(接近 2x) - 当
newcap
很大时,192
可忽略 → 增长趋近于1.25x
- 新容量公式为:
oldCap | newcap 增长量 | 增长率 |
---|---|---|
256 | 256/4 + 192 = 256 | 2 |
1000 | 1000/4 + 192 ≈ 442 | 1.44 |
10000 | 10000/4 + 192 ≈ 2692 | 1.27 |
100000 | 100000/4 + 192 ≈ 25192 | 1.25 |
以上是go1.18之后的扩容策略。1.18之前策略是:
-
如果老容量小于
1024
,按照2
倍扩容 -
如果老容量大于等于
1024
,按照1.25
倍扩容
1.18之前的扩容策略有什么问题?
- 不够平滑:容量小于1024时2倍扩容,大于1024突然降到1.25倍扩容
- 容量增长不单调 :正常应该是较大的初始容量扩容后有较大的最终容量
例如以下代码:
go
x1 := make([]int, 897)
x2 := make([]int, 1024)
y := make([]int, 100)
println(cap(append(x1, y...))) // 2048
println(cap(append(x2, y...))) // 1280
x1的容量比x2小,都增加100个元素后x1的容量反而比x2大,容量增长变得不单调了 于是,go官方在这次commit github.com/golang/go/c...后 ,将扩容策略改成现在这样
分配内存
计算完新的容量后,下一步是为新的底层数组分配内存
根据新的容量,计算要分配多少内存:
go
switch {
case et.Size_ == 1:
// ...
case et.Size_ == goarch.PtrSize:
// ...
case isPowerOfTwo(et.Size_):
// ...
default:
lenmem = uintptr(oldLen) * et.Size_
newlenmem = uintptr(newLen) * et.Size_
capmem, overflow = math.MulUintptr(et.Size_, uintptr(newcap))
capmem = roundupsize(capmem, noscan)
newcap = int(capmem / et.Size_)
capmem = uintptr(newcap) * et.Size_
}
lenmem
:旧数据占用的字节数(oldLen * elemSize
)newlenmem
:新长度所需字节数capmem
:新容量所需字节数(向上对齐)newcap
:最终确定的新容量(可能因对齐微调)
roundupsize:按照内存分配块的大小向上修正 内存分配各个size如下:{0, 8, 16, 24, 32, 48, 64, 80, 96, 112...}
例如,如果slice为int类型,有5个元素,应该占40个字节。但由于分配到size=48的内存块,会向上修正到48字节,也就是cap变为6
调mallocgc分配内存
go
p = mallocgc(capmem, nil, false)
将原数组 oldPtr
的 lenmem
字节复制到新内存 p
go
memmove(p, oldPtr, lenmem)
返回扩容后slice
go
return slice{p, newLen, newcap}
截取
slice支持以下截取操作:
go
func main() {
s := []int{1, 2, 3, 4, 5, 6}
fmt.Println(&s[0]) // 0xc00000e300
fmt.Println(len(s)) // 6
fmt.Println(cap(s)) // 6
s1 := s[1:3]
fmt.Println(s1) // [2 3]
fmt.Println(&s1[0]) // 0xc00000e308
fmt.Println(len(s1)) // 2
fmt.Println(cap(s1)) // 5
}
s1从s中,根据开头和结尾进行截取出[1,3)
范围的数据
截取操作的一般化描述为:newSlice := original[low:high]
s1的
- newLen=2,因为截取出2个元素
- 计算公式:
newLen = high - low
- 如果截取操作不指定结束位置,例如
s[1:]
,表示从原始slice第二个位置截取到最后,新slice的len就是原始slice的len-1
- 计算公式:
- newCap=5,为此次截取的开头算到原slice的cap结尾
- 计算公式为:
newCap = originalCap(原slice的cap) - low
- 计算公式为:
- 底层数组开头指向的地址,为原slice底层数组开头指向的地址+8

我们看看底层汇编
初始化slice s:
scss
00041 LEAQ main..autotmp_3+24(SP), DX
00046 MOVQ DX, main..autotmp_2+120(SP) // 保存数组地址到120(SP)
00053 MOVQ $1, (DX)
00062 MOVQ $2, 8(DX)
00072 MOVQ $3, 16(DX)
00082 MOVQ $4, 24(DX)
00097 MOVQ $5, 32(DX)
00112 MOVQ $6, 40(DX)
初始化slice s1:
- 将s的地址+8,作为s1的底层数组的地址
- 将2最为s1的len
- 将3作为s2的cap
assembly
00120 MOVQ main..autotmp_2+120(SP), AX // 将数组地址赋值给AX
00157 ADDQ $8, AX
00184 MOVL $2, BX
00189 MOVL $5, CX
可以看出:截取操作代价很低,就是new一个slice结构,底层数组复用原来的
此时如果对往s1后面append,因为len还没超过cap,就会在data指向的数组后面追加,且该数组和s为同一个,就会修改原数组下标为3位置的值:
go
func main() {
s := []int{1, 2, 3, 4, 5, 6}
s1 := s[1:3]
s1 = append(s1, 8)
fmt.Println(s) // [1 2 3 8 5 6]
fmt.Println(s1) // [2 3 8]
}
拷贝
slice的拷贝分为浅拷贝和深拷贝
浅拷贝
通过对slice变量赋值,可以创建出了一个新的slice header实例,但是其中的指针array、容量cap和长度len 仍和老的slice header实例相同.
go
func main() {
s := []int{0, 1, 2, 3, 4}
s1 := s
fmt.Println(&s[0]) // 0xc00000e300
fmt.Println(&s1[0]) // 0xc00000e300
fmt.Println(len(s), cap(s)) // 5 5
fmt.Println(len(s1), cap(s1)) // 5 5
}
深拷贝
深拷贝指的是会创建出一个和 slice 容量大小相等的独立的内存区域,并将原 slice 中的元素一一拷贝到新空间中
什么情况需要深拷贝?
- 需要独立修改,不希望影响原始数据
- 为了并发安全,多goroutine操作时,避免竞态条件
go内建函数copy
能完成切片的深拷贝,将原切片source
的内容拷贝的目标切片target
go
func main() {
source := []int{1, 2, 3, 4, 5, 6}
fmt.Println(source, len(source), cap(source)) // [1 2 3 4 5 6] 6 6
target := make([]int, 6, 6)
copy(target, source)
fmt.Println(target, len(target), cap(target)) // [1 2 3 4 5 6] 6 6
fmt.Println(&source[0]) // 0xc00000e300
fmt.Println(&target[0]) // 0xc00000e330
}
需要注意:要拷贝多少个元素,是根据min(len(source),len(target))
决定的。因此需要将目标切片的len设置为和原切片一样,才能保证将原切片原封不动地拷贝到目标切片
举个例子,假设target的len=3,那就只会拷贝source中前3个元素到target
go
func main() {
source := []int{1, 2, 3, 4, 5, 6}
fmt.Println(source, len(source), cap(source)) // [1 2 3 4 5 6] 6 6
target := make([]int, 3, 6)
copy(target, source)
fmt.Println(target, len(target), cap(target)) // [1 2 3] 3 6
}
遍历
最后介绍slice的for range遍历操作:
go
func main() {
source := []int{1, 2, 3, 4, 5, 6}
for i, v := range source {
handle(i, v)
}
}

for range循环,对应的汇编代码为:
初始source:
asm
// 数组分配在栈上SP+48的位置,将数组首地址放到CX
00044 LEAQ main..autotmp_5+48(SP), CX
00049 MOVQ CX, main..autotmp_4+120(SP)
00056 MOVQ $1, (CX)
00065 MOVQ $2, 8(CX)
00075 MOVQ $3, 16(CX)
00085 MOVQ $4, 24(CX)
00100 MOVQ $5, 32(CX)
00115 MOVQ $6, 40(CX)
00123 MOVQ main..autotmp_4+120(SP), CX
// 将数组首地址放到SP+128
00155 MOVQ CX, main..autotmp_3+128(SP)
初始化循环遍历:
assembly
// 循环变量i
00187 MOVQ $0, main..autotmp_6+40(SP)
// 循环长度len
00196 MOVQ $6, main..autotmp_7+32(SP)
00205 JMP 207
00207 MOVQ main..autotmp_6+40(SP), CX
// 比较len和i
00212 CMPQ main..autotmp_7+32(SP), CX
// 如果len > i,跳转到221
00217 JGT 221
// 否则跳转到270,结束循环
00219 JMP 270
循环体:
asm
// 将i赋值给局部变量
00226 MOVQ AX, main.i+24(SP)
00231 MOVQ main..autotmp_6+40(SP), CX
00236 SHLQ $3, CX
00240 ADDQ main..autotmp_3+128(SP), CX
// 读取下标i的值s[i]
00248 MOVQ (CX), BX
// 将s[i]的值赋值给局部变量v
00251 MOVQ BX, main.v+16(SP)
// 调handle方法,参数在SP+16,SP+24的位置
00256 CALL main.handle(SB)
00261 JMP 263
// i自增
00263 INCQ main..autotmp_6+40(SP)
// 跳到207继续循环
00268 JMP 207
整体上就是常规的for循环。需要注意一个核心机制,for循环体中:
range
会对每个元素进行值拷贝 ,然后赋值给v
- 每次循环,v都会被重新赋值为
s[i]
的副本
对应到本文中的例子,for range中的每个v都是拷贝出来的单独变量,在SP+16中。而source底层数组在SP+48
这也能很好解释为啥下单将每个元素 * 2不生效:因为将SP+16位置的值X2,并不会影响原始数组里的值
go
func main() {
s := []int{1, 2, 3, 4, 5}
for _, v := range s {
v *= 2
}
fmt.Println(s) // [1 2 3 4 5]
}