1.序言
写golang的同学,对切片(slice)一定不会陌生。可以说,切片是golang开发中使用最多的数据结构之一,需要的时候make一下,简直不要太简单。
然而要用好它,不熟悉原理怎么行。今天这篇文章,就来看看golang中是怎么处理切片相关操作的。
go version go1.19.4 linux/amd64
2.问题引入
在深入分析切片原理之前,先来看看下述代码片段会输出什么结果,相信通过这几段代码,能够帮助我们更加深入地了解切片的使用技巧和原理:
2.1 代码1
通过几种不同的方式声明切片,区别是什么
func main() {
var s1 []int
s2 := make([]int, 0, 10)
s3 := make([]int, 10)
s4 := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
fmt.Println(s1, s2, s3, s4)
}
2.2 代码2
执行以下操作:
- 初始化长度为0,容量为10的切片s
- 对s执行一次append操作
- 初始化长度为10,容量为10的切片s1
- 对s1执行一次append操作
执行完后s/s1的长度和容量分别是多少
func main() {
s := make([]int, 0, 10)
s = append(s, 1)
fmt.Printf("len: %d, cap: %d\n", len(s), cap(s))
s1 := make([]int, 10, 10)
s1 = append(s1, 1)
fmt.Printf("len: %d, cap: %d\n", len(s1), cap(s1))
}
2.3 代码3
执行以下操作:
- 初始化长度为8,容量为10的切片s
- 将切片s中索引为6以后的元素截断赋值给s1
- 将切片s中索引[6,7]之间的元素截断赋值给s2
- 将切片s中索引为6以前的元素截断赋值给s3
执行完后s1/s2/s3的长度和容量分别是多少
func main() {
s := make([]int, 8, 10)
s1 := s[6:]
s2 := s[6:7]
s3 := s[:6]
fmt.Printf("len: %d, cap: %d\n", len(s1), cap(s1))
fmt.Printf("len: %d, cap: %d\n", len(s2), cap(s2))
fmt.Printf("len: %d, cap: %d\n", len(s3), cap(s3))
}
2.4 代码4
执行以下操作:
- 初始化长度为8,容量为10的切片s
- 将切片s中索引为4以后的元素截断赋值给s1
- 修改s1[0]的值
- 将切片s中索引为6以后的元素截断赋值给s2
- 将s2传给函数change,在change中修改索引0处的值
执行完后s是否会受影响
func main() {
s := make([]int, 8, 10)
s1 := s[4:]
s1[0] = 100
fmt.Println(s)
s2 := s[6:]
change(s2)
fmt.Println(s)
}
func change(s2 []int) {
s2[0] = 200
}
2.5 代码5
执行以下操作:
- 初始化长度为8,容量为10的切片s
- 将切片s中索引为6以后的元素截断赋值给s1
- 向s1中append5个元素
- 将切片s中索引为6以后的元素截断赋值给s2
- 将s2传给函数change,在change中向s2中append5个元素
执行完后s是否会受影响
func main() {
s := make([]int, 8, 10)
s1 := s[6:]
s1 = append(s1, []int{1, 2, 3, 4, 5}...)
fmt.Printf("len: %d, cap: %d\n", len(s), cap(s))
s2 := s[6:]
change(s2)
fmt.Printf("len: %d, cap: %d\n", len(s), cap(s))
}
func change(s2 []int) {
s2 = append(s2, []int{1, 2, 3, 4, 5}...)
}
2.6 代码6
执行以下操作:
- 初始化长度为512,容量为512的切片s
- 向其中append一个元素
执行完后s的长度和容量分别是多少
func main() {
s := make([]int, 512)
s = append(s, 1)
fmt.Printf("len: %d, cap: %d\n", len(s), cap(s))
}
2.7 代码7
执行以下操作:
- 初始化切片s,元素为{1,2,3,4,5}
- 删除元素3
执行完后s的长度和容量分别是多少
func main() {
s := []int{1, 2, 3, 4, 5}
s = append(s[:2], s[3:]...)
fmt.Printf("len: %d, cap: %d\n", len(s), cap(s))
}
3.数据结构
runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}
切片本质上是个结构体,包含三个字段:
- array: 指向底层数据结构的指针,由于在声明切片时要指定类型,因此可认为这里是一个指向数组起始位置的指针
- len: 切片的长度,即切片当前已容纳的元素个数,只能通过索引访问[0,len - 1]之间的元素,否则会越界
- cap: 切片的容量,即切片一共能容纳的元素个数
我们在进行切片赋值、传参、截断时,其实是复制了一个slice结构体,只不过底层的数组是同一个。这就导致无论是在复制的切片中修改值,还是在修改形参切片的值,都会印象到原来的切片。
4.创建切片
代码1中提及了几种声明和初始化方式,下面结合slice结构体的定义及对应代码的部分汇编代码片段来看看。
需要注意的是,为了展示起来方便,这里在分析第一种声明方法时注释掉了其他方法。
go tool compile -S -N -l main.go > main.s-S-N-l
var s1 []int
// 将X15寄存器的值移动到+56位置,其实就是切片s1的起始位置,大小为16个字节,所以下一条指令从+72开始
// type slice struct {
// array unsafe.Pointer
// len int
// cap int
// }
// 结合slice的结构可知,这里其实写了array和len部分。
0x0018 00024 (main.go:6) MOVUPS X15, main.s1+56(SP)
// 这里将cap设置为0
0x001e 00030 (main.go:6) MOVQ $0, main.s1+72(SP)
X15寄存器在前面没有进行设置,默认情况下值为0,这里应该是将底层数组的地址和len设置为0。
从这里可知,这种方式声明的切片并没有真实的为底层数组分配空间,这也就是所说的nil切片。
var s = []int{}
// runtime.zerobase 是某一段固定内存
0x0018 00024 (main.go:14) LEAQ runtime.zerobase(SB), DX
0x001f 00031 (main.go:14) MOVQ DX, main..autotmp_2+40(SP)
0x0024 00036 (main.go:14) TESTB AL, (DX)
0x0026 00038 (main.go:14) JMP 40
// 将该固定内存地址作为底层数组的地址
0x0028 00040 (main.go:14) MOVQ DX, main.s5+64(SP)
0x002d 00045 (main.go:14) MOVUPS X15, main.s5+72(SP)
0x0033 00051 (main.go:15) MOVUPS X15, main..autotmp_1+48(SP)
因此可以得出结论:
- nil切片仅进行声明,没有初始化,因此底层数组地址为0
- 空切片完整地进行了初始化,容量为0,底层数组指向一段特定的内存区域
s2 := make([]int, 0, 10)
这里要比上面的方式清晰的多,切片中每个字段都进行了单独设置。
// 调用runtime.makeslice为底层数组分配内存
0x0026 00038 (main.go:8) CALL runtime.makeslice(SB)
// 初始化底层数组,这里保存了地址,大小为8字节
0x002b 00043 (main.go:8) MOVQ AX, main.s2+56(SP)
// len设置为0,大小为8字节
0x0030 00048 (main.go:8) MOVQ $0, main.s2+64(SP)
// cap设置为10,大小为8字节
0x0039 00057 (main.go:8) MOVQ $10, main.s2+72(SP)
s3 := make([]int, 10)
0x0027 00039 (main.go:10) CALL runtime.makeslice(SB)
0x002c 00044 (main.go:10) MOVQ AX, main.s3+56(SP)
0x0031 00049 (main.go:10) MOVQ $10, main.s3+64(SP)
0x003a 00058 (main.go:10) MOVQ $10, main.s3+72(SP)
s4 := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
在编译时就已经初始化好了一段连续的内存,并且将值保存到了对应位置。在声明切片时直接将这段内存起始地址保存到切片的array字段中。
0x0025 00037 (main.go:12) MOVQ AX, main..autotmp_2+40(SP)
0x002a 00042 (main.go:12) MOVQ $1, (AX)
0x0031 00049 (main.go:12) MOVQ main..autotmp_2+40(SP), CX
0x0036 00054 (main.go:12) TESTB AL, (CX)
0x0038 00056 (main.go:12) MOVQ $2, 8(CX)
0x0040 00064 (main.go:12) MOVQ main..autotmp_2+40(SP), CX
0x0045 00069 (main.go:12) TESTB AL, (CX)
0x0047 00071 (main.go:12) MOVQ $3, 16(CX)
0x004f 00079 (main.go:12) MOVQ main..autotmp_2+40(SP), CX
0x0054 00084 (main.go:12) TESTB AL, (CX)
0x0056 00086 (main.go:12) MOVQ $4, 24(CX)
0x005e 00094 (main.go:12) MOVQ main..autotmp_2+40(SP), CX
0x0063 00099 (main.go:12) TESTB AL, (CX)
0x0065 00101 (main.go:12) MOVQ $5, 32(CX)
0x006d 00109 (main.go:12) MOVQ main..autotmp_2+40(SP), CX
0x0072 00114 (main.go:12) TESTB AL, (CX)
0x0074 00116 (main.go:12) MOVQ $6, 40(CX)
0x007c 00124 (main.go:12) MOVQ main..autotmp_2+40(SP), CX
0x0081 00129 (main.go:12) TESTB AL, (CX)
0x0083 00131 (main.go:12) MOVQ $7, 48(CX)
0x008b 00139 (main.go:12) MOVQ main..autotmp_2+40(SP), CX
0x0090 00144 (main.go:12) TESTB AL, (CX)
0x0092 00146 (main.go:12) MOVQ $8, 56(CX)
0x009a 00154 (main.go:12) MOVQ main..autotmp_2+40(SP), CX
0x009f 00159 (main.go:12) TESTB AL, (CX)
0x00a1 00161 (main.go:12) MOVQ $9, 64(CX)
0x00a9 00169 (main.go:12) MOVQ main..autotmp_2+40(SP), CX
0x00ae 00174 (main.go:12) TESTB AL, (CX)
0x00b0 00176 (main.go:12) JMP 178
0x00b2 00178 (main.go:12) MOVQ CX, main.s4+64(SP)
0x00b7 00183 (main.go:12) MOVQ $9, main.s4+72(SP)
0x00c0 00192 (main.go:12) MOVQ $9, main.s4+80(SP)
上述分析中涉及到两个golang标准库的代码:
runtime.zerobaseruntime.makeslice
4.1 runtime.zerobase
可以认为是一段运行时预分配好内存空间,不仅这里用到了,在空结构体中也有用到。具体参考: https://blog.csdn.net/slphahaha/article/details/121134367
4.2 runtime.makeslice
这一段逻辑并不复杂,主要流程为:
cap * sizemallocgc
有关内存分配的细节这里不予讨论,以后会专门进行分析。
func makeslice(et *_type, len, cap int) unsafe.Pointer {
mem, overflow := math.MulUintptr(et.size, uintptr(cap))
if overflow || mem > maxAlloc || len < 0 || len > cap {
mem, overflow := math.MulUintptr(et.size, uintptr(len))
if overflow || mem > maxAlloc || len < 0 {
panicmakeslicelen()
}
panicmakeslicecap()
}
return mallocgc(mem, et, true)
}
5.切片截取
通常用形如s[a:b]的格式进行切片截取,其中a,b为截取的索引,不包含索引b位置的元素。a和b是可以缺省的,缺省情况下a默认为0,b默认为len - 1。
下面来看看这段代码对应的汇编实现,源代码如下:
s := make([]int, 8, 10)
s1 := s[6:]
对应的汇编是:
// 为切片s底层数组分配内存
0x0037 00055 (main.go:6) CALL runtime.makeslice(SB)
// 创建切片s,AX寄存器保存了底层数组的地址
0x003c 00060 (main.go:6) MOVQ AX, main.s+128(SP)
0x0044 00068 (main.go:6) MOVQ $8, main.s+136(SP)
0x0050 00080 (main.go:6) MOVQ $10, main.s+144(SP)
0x005c 00092 (main.go:7) JMP 94
0x005e 00094 (main.go:7) PCDATA $1, $-1
0x005e 00094 (main.go:7) NOP
0x0060 00096 (main.go:7) JMP 98
// 这里将AX中的地址加上偏移量48(刚好就是6个元素的大小)后,作为切片s2的底层数组地址
0x0062 00098 (main.go:7) LEAQ 48(AX), DX
0x0066 00102 (main.go:7) MOVQ DX, main.s1+104(SP)
0x006b 00107 (main.go:7) MOVQ $2, main.s1+112(SP)
0x0074 00116 (main.go:7) MOVQ $4, main.s1+120(SP)
不难看出,这种方式会创建一个新的slice结构,只不过底层数组还是共用的。所以截取后,修改s1的元素会影响到原始切片s。
6.切片赋值
和切片截断其实是一样的,都会分配一个新的slice结构,重新计算并设置len和cap,共用底层数组。
0x0037 00055 (main.go:6) CALL runtime.makeslice(SB)
0x003c 00060 (main.go:6) MOVQ AX, main.s+72(SP)
0x0041 00065 (main.go:6) MOVQ $8, main.s+80(SP)
0x004a 00074 (main.go:6) MOVQ $10, main.s+88(SP)
0x0053 00083 (main.go:10) MOVQ AX, main.s4+48(SP)
0x0058 00088 (main.go:10) MOVQ $8, main.s4+56(SP)
0x0061 00097 (main.go:10) MOVQ $10, main.s4+64(SP)
7.切片追加
golang提供append函数进行切片元素追加,其实包含两种情况:
- 情况一: 切片剩余容量大于追加元素个数,不会触发扩容
- 情况二: 切片剩余容量小于追加元素个数,会触发扩容
这里先讨论第一种,源代码如下:
s1 := make([]int, 0, 5)
s1 = append(s1, 1, 2)
该代码创建一个len = 0, cap等于5的切片并向其中追加两个元素。显然符合情况一。对应的汇编代码如下:
// 创建切片s1
0x0026 00038 (main.go:10) CALL runtime.makeslice(SB)
0x002b 00043 (main.go:10) MOVQ AX, main.s1+56(SP)
0x0030 00048 (main.go:10) MOVQ $0, main.s1+64(SP)
0x0039 00057 (main.go:10) MOVQ $5, main.s1+72(SP)
0x0042 00066 (main.go:11) JMP 68
// 直接将值保存到底层数组对应的位置
0x0044 00068 (main.go:11) MOVQ $1, (AX)
0x004b 00075 (main.go:11) MOVQ $2, 8(AX)
0x0053 00083 (main.go:11) MOVQ AX, main.s1+56(SP)
// 修改len
0x0058 00088 (main.go:11) MOVQ $2, main.s1+64(SP)
0x0061 00097 (main.go:11) MOVQ $5, main.s1+72(SP)
从上面可知,情况一这种场景,会直接将值写到切片底层数组对应的位置,并不会开辟新的内存空间。
8.扩容
如果追加的元素个数大于切片剩余容量,会发生什么呢?
这里创建一个len = 4, cap等于5的切片并向其中追加两个元素,源代码如下:
s1 := make([]int, 4, 5)
s1 = append(s1, 1, 2)
对应的汇编代码是:
// 创建原始切片s1
0x0037 00055 (main.go:10) CALL runtime.makeslice(SB)
0x003c 00060 (main.go:10) MOVQ AX, main.s1+96(SP)
0x0041 00065 (main.go:10) MOVQ $4, main.s1+104(SP)
0x004a 00074 (main.go:10) MOVQ $5, main.s1+112(SP)
0x0053 00083 (main.go:11) JMP 85
0x0055 00085 (main.go:11) MOVQ AX, BX
0x0058 00088 (main.go:11) MOVL $4, CX
0x005d 00093 (main.go:11) MOVL $5, DI
0x0062 00098 (main.go:11) MOVL $6, SI
// 切片扩容
0x0067 00103 (main.go:11) LEAQ type.int(SB), AX
0x006e 00110 (main.go:11) CALL runtime.growslice(SB)
0x0073 00115 (main.go:11) LEAQ 2(BX), DX
0x0077 00119 (main.go:11) JMP 121
// 将元素保存到扩容后的底层数组对应位置
0x0079 00121 (main.go:11) MOVQ $1, 32(AX)
0x0081 00129 (main.go:11) MOVQ $2, 40(AX)
0x0089 00137 (main.go:11) MOVQ AX, main.s1+96(SP)
0x008e 00142 (main.go:11) MOVQ DX, main.s1+104(SP)
0x0093 00147 (main.go:11) MOVQ CX, main.s1+112(SP)
runtime.growslice
下面来详细分析下扩容原理是什么:
func growslice(et *_type, old slice, cap int) slice {
...
// 如果新容量比原容量还小,直接panic
if cap < old.cap {
panic(errorString("growslice: cap out of range"))
}
// 如果指定的容量为0,则直接用zerobase的地址作为底层数组的地址。其实就对应于使用这种: var s = []int{}
if et.size == 0 {
return slice{unsafe.Pointer(&zerobase), old.len, cap}
}
// 计算原容量的2倍
newcap := old.cap
doublecap := newcap + newcap
// 如果新容量比原容量的两倍还大,则直接取新容量
if cap > doublecap {
newcap = cap
} else {
const threshold = 256
// 原容量小于256,则取原容量的两倍
if old.cap < threshold {
newcap = doublecap
} else {
// 对原容量 * 1.25 并加上192
// 循环执行上述操作,直到扩容后的容量已经大于等于预期的新容量为止
for 0 < newcap && newcap < cap {
newcap += (newcap + 3*threshold) / 4
}
// 溢出了,直接取指定的容量
if newcap <= 0 {
newcap = cap
}
}
}
var overflow bool
var lenmem, newlenmem, capmem uintptr
// 基于上面确定的容量,计算分配底层数组所需的内存大小capmem
switch {
// 数组元素大小为1,capmem取 newcap * 1,并基于size class向上取整
case et.size == 1:
lenmem = uintptr(old.len)
newlenmem = uintptr(cap)
capmem = roundupsize(uintptr(newcap))
overflow = uintptr(newcap) > maxAlloc
newcap = int(capmem)
// 数组元素是指针类型,capmem取: 指针大小 * newcap,并基于size class向上取整
case et.size == goarch.PtrSize:
lenmem = uintptr(old.len) * goarch.PtrSize
newlenmem = uintptr(cap) * goarch.PtrSize
capmem = roundupsize(uintptr(newcap) * goarch.PtrSize)
overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize
newcap = int(capmem / goarch.PtrSize)
// 数组元素大小是2的幂,通过位运算计算capmem,并基于size class向上取整
case isPowerOfTwo(et.size):
var shift uintptr
if goarch.PtrSize == 8 {
// Mask shift for better code generation.
shift = uintptr(sys.Ctz64(uint64(et.size))) & 63
} else {
shift = uintptr(sys.Ctz32(uint32(et.size))) & 31
}
lenmem = uintptr(old.len) << shift
newlenmem = uintptr(cap) << shift
capmem = roundupsize(uintptr(newcap) << shift)
overflow = uintptr(newcap) > (maxAlloc >> shift)
newcap = int(capmem >> shift)
default:
// 其他类型,capmem取: 元素大小 * newcap,并基于size class向上取整
lenmem = uintptr(old.len) * et.size
newlenmem = uintptr(cap) * et.size
capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
capmem = roundupsize(capmem)
newcap = int(capmem / et.size)
}
if overflow || capmem > maxAlloc {
panic(errorString("growslice: cap out of range"))
}
// 根据上面计算的capmem分配内存空间
var p unsafe.Pointer
if et.ptrdata == 0 {
p = mallocgc(capmem, nil, false)
// The append() that calls growslice is going to overwrite from old.len to cap (which will be the new length).
// Only clear the part that will not be overwritten.
memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
} else {
// Note: can't use rawmem (which avoids zeroing of memory), because then GC can scan uninitialized memory.
p = mallocgc(capmem, et, true)
if lenmem > 0 && writeBarrier.enabled {
// Only shade the pointers in old.array since we know the destination slice p
// only contains nil pointers because it has been cleared during alloc.
bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem-et.size+et.ptrdata)
}
}
// 将原切片中的元素复制到新开辟的内存中
memmove(p, old.array, lenmem)
// 创建一个新的切片
return slice{p, old.len, newcap}
}
memmove
9.拷贝
其实标题6中也是一个拷贝方式,这种方式会生成一个新的slice结构,底层数组和原始切片共用(未扩容前)。
copy
func main() {
s := []int{1, 2, 3}
s1 := make([]int, 3, 3)
copy(s1, s)
fmt.Println(s1)
}
生成的汇编如下:
0x0062 00098 (main.go:6) MOVQ DX, main.s+128(SP)
0x006a 00106 (main.go:6) MOVQ $3, main.s+136(SP)
0x0076 00118 (main.go:6) MOVQ $3, main.s+144(SP)
0x0082 00130 (main.go:7) LEAQ type.int(SB), AX
0x0089 00137 (main.go:7) MOVL $3, BX
0x008e 00142 (main.go:7) MOVQ BX, CX
0x0091 00145 (main.go:7) PCDATA $1, $1
0x0091 00145 (main.go:7) CALL runtime.makeslice(SB)
0x0096 00150 (main.go:7) MOVQ AX, main.s1+104(SP)
0x009b 00155 (main.go:7) MOVQ $3, main.s1+112(SP)
0x00a4 00164 (main.go:7) MOVQ $3, main.s1+120(SP)
...
0x0159 00345 (main.go:8) CALL runtime.memmove(SB)
runtime.memmove
这里其实隐藏了一个坑,如果声明s1时指定len为0,那么就达不到copy的效果,因为没有对len进行过修改,len还是0。
10.总结
通过上述对源代码和汇编代码的分析,对切片的理解更深刻了。
对于文章开头提出的几个问题,相信应该有了答案。本文中涉及到的源代码和汇编代码,我都放到了这个仓库,有兴趣的可以自己跑一跑。
其实文中还遗留了一个问题,也就是size class是怎么确定的,这部分我将会在分析golang内存管理相关原理时进行详细的说明。