深入理解slice应用与底层实现
切片是 Go 语言中的一种基本数据结构,用于管理数据集合。它的设计灵感来源于动态数组,能自动调整数据结构的大小,需要注意的是切片本身并不是动态数组或者数组指针。常见的操作包括重新切片(reslice)、追加元素(append)和复制(copy)。
主题大纲:

一、切片和数组
切片能自动调整数据结构的大小,那么切片和数组该如何选择呢?还是回到我们研究这两者的本质:切片和数组它们到底是引用传递还是值传递?
func main() { arrayA := [4]int{1, 2, 3, 4} var arrayB [4]int arrayB = arrayA // 执行时会复制出arrayA的一个副本,然后将这个副本赋值给arrayB fmt.Printf("arrayA : %p , %v\n", &arrayA, arrayA) fmt.Printf("arrayB : %p , %v\n", &arrayB, arrayB) testArray(arrayA) } func testArray(x [4]int) { fmt.Printf("func Array : %p , %v\n", &x, x) } 输出结果: arrayA : 0xc00009e000 , [1 2 3 4] arrayB : 0xc00009e020 , [1 2 3 4] func Array : 0xc00001a0e0 , [1 2 3 4]
可以看到,arrayA,arrayB和func Array中的数组元素都是相同的,但是它们的地址却都不相同。为什么呢?
结论:数组是值传递(值复制),进行数组赋值。在大数据量数组传参会发生数组复制,影响性能。
那么你可以想一想:如何在使用数组的时候能够在函数传递时,不复制整个数组呢?
聪明的你肯定想到了,既然数组的值传递会影响性能,那我不传递值,而是传递指针就能避免整个数组进行复制了。我们来看下面这段代码:
func main() { arrayA := [4]int{1, 2, 3, 4} testArrayPoint1(&arrayA) // 1.传数组指针 arrayB := arrayA[:] testArrayPoint2(arrayB) // 2.传切片 fmt.Printf("arrayA : %p , %v, arrayB: %p, %v\n", &arrayA, arrayA, &arrayB, arrayB) } func testArrayPoint1(x *[4]int) { fmt.Printf("func Array : %p , %v\n", x, *x) (*x)[1] += 100 } func testArrayPoint2(x []int) { fmt.Printf("func Array : %p , %v\n", x, &x) (x)[1] += 100 }
可以想想打印的地址是否一样 输出结果: func Array : 0xc00009e000 , [1 2 3 4] func Array : 0xc00009e000 , &[1 102 3 4] arrayA : 0xc00009e000 , [1 202 3 4], arrayB: 0xc000010018, [1 202 3 4]
可以看到,第一个跟第三个的地址是一样的,相信你一定有一个疑惑:为什么testArrayPoint2修改的是arrayB,但是却修改了arrayA ?结合下图来理解就很清晰了。

关键点来了:arrayB := arrayA[:]如何理解呢?其实就是在arrayA数组上初始化了一个切片,如下图:

所以在执行testArrayPoint2时,操作如下:

最后我们再来分析一下输出结果:
func Array : 0xc00009e000 , [1 2 3 4] func Array : 0xc00009e000 , &[1 102 3 4] arrayA : 0xc00009e000 , [1 202 3 4], arrayB: 0xc000010018, [1 202 3 4] &arrayA和&arrayB不是同一块内存,&arrayA指向的是数组的第一个元素的首地址,&arrayB指向的是切片结构的首地址。 但是&arrayB切片结构体的首地址里面的值又是指向的是&arrayA。
所以在实际应用场景中,如果本身并不知道数据量有多大,都选择使用切片。
需要明确的是:使用切片代替数组时,切片底层数组可能会在堆上分配内存。
func array() [1024]int { var x [1024]int for i := 0; i < len(x); i++ { x[i] = i } return x } func slice() []int { x := make([]int, 1024) for i := 0; i < len(x); i++ { x[i] = i } return x } func BenchmarkArray(b *testing.B) { for i := 0; i < b.N; i++ { array() } } func BenchmarkSlice(b *testing.B) { for i := 0; i < b.N; i++ { slice() } }
测试命令:go test -bench . -benchmem -gcflags "-N -l" 测试结果: goos: linux goarch: amd64 pkg: goprogramer/improve/slice/example1 cpu: Intel(R) Core(TM) i5-9300H CPU @ 2.40GHz BenchmarkArray-2 474058 2398 ns/op 0 B/op 0 allocs/op BenchmarkSlice-2 301341 4168 ns/op 8192 B/op 1 allocs/op PASS ok goprogramer/improve/slice/example1 3.370s 在测试 Array 的时候,用的是2核,循环次数是474058,平均每次执行时间是2398 ns,每次执行堆上分配内存总量是0,分配次数也是0 。 而切片的结果就“差”一点,同样也是用的是2核,循环次数是301341,平均每次执行时间是4168 ns,每执行一次,堆上分配内存总量是8192,分配次数是1 。
总结:
- 数组适合小型固定大小的数据结构,具有较低的内存开销。
- 切片更灵活,适合数据频繁变更的场景。
二、切片的数据结构
切片本身并不是动态数组或者数组指针。它内部实现的数据结构通过指针引用底层数组,设定相关属性将数据读写操作限定在指定的区域内。切片本身是一个只读对象,其工作机制类似数组指针的一种封装,是对数组一个连续片段的引用。
Slice的数据结构定义如下:
type slice struct {
array unsafe.Pointer
len int
cap int
}

如果要获取slice的地址,可以:
func main() { s := []int{1, 2, 3, 4} ptr := unsafe.Pointer(&s[0]) ptr1 := unsafe.Pointer(&s[1]) ptr2 := unsafe.Pointer(&s[2]) ptr3 := unsafe.Pointer(&s[3]) fmt.Println(ptr, ptr1, ptr2, ptr3) }
接下来根据slice的底层数据结构,我们将根据几个容易被忽略的问题来更好的掌握切片:
1、空切片等于nil吗?
func main() { var s1 []uint32 s2 := make([]uint32, 0) fmt.Println(s1 == nil) fmt.Println(s2 == nil) fmt.Println("nil slice:", len(s1), cap(s1)) fmt.Println("cap slice:", len(s2), cap(s2)) }
思考一下,这段代码的输出是什么?
分析:首先s1和s2的长度和容量都为 0,这很好理解。比较切片与nil是否相等,实际上要检查slice结构中的array字段是否是空指针。显然s1 == nil返回true,s2 == nil返回false。尽管s2长度为 0,但是make()为它分配了空间。所以,一般定义长度为 0 的切片使用var的形式。
2、传值还是传引用?
func main() { s1 := []uint32{1, 2, 3,4} s2 := append(s1, 5) append(s2, 6, 7, 8) fmt.Println(s1) fmt.Println(s2) }
思考一下,这段代码的输出是什么?
分析:首先s1的长度和容量都是3,当我们使用append将s1进行传递时,实际上传递的是slice结构体,这个传递的结构体是值传递,所以会重新复制一份新的slice结构体进行append操作,当append完之后进行返回时,由于s1_copy赋值给了s2,所以s1是没任何改变,s2将得到一个append之后的结构体。append(s2, 6, 7, 8)也是同样的道理,失去了新的slice的引用,将会丢失数据6,7,8。
如下图所示:

结论:需要明确切片的两个结构,切片结构体和切片结构体array指针指向的数组。函数之间传递相同的切片,从切片结构体的角度来看,传递的是切片结构体的一个拷贝,但是由于切片结构体和切片结构体的拷贝的array指针指向的都是同一份连续的数组空间,所以给外部造成了一种传指针的假象。
3、切片的扩容策略?
func main() { var s1 []uint32 s1 = append(s1, 1, 2, 3) s2 := append(s1, 4) fmt.Println(&s1[0] == &s2[0]) s3 := []uint32{1, 2, 3} s4 := append(s3, 4) fmt.Println(&s3[0] == &s4[0]) }
思考一下,这段代码的输出是什么?
分析:slice的扩容策略
- 当前容量小于 1024,则将容量扩大为原来的 2 倍;
- 当前容量大于等于 1024,则将容量逐次增加原来的 0.25 倍,直到满足所需容量。
结合问题2进行分析。
4、切片底层数据共享
func main() { array := [10]uint32{1, 2, 3, 4, 5} s1 := array[:5] s2 := s1[5:10] fmt.Println(s2) s1 = append(s1, 6) fmt.Println(s1) fmt.Println(s2) s3 := s1[5:12] // nil pointer fmt.Println(s3) }
思考一下,这段代码输出结果是什么?
分析:当对s1 = append(s1, 6)时,结果会对s2也进行操作了,如图所示

可以看到由于切片底层数据共享可能造成修改一个切片会导致其他切片也跟着修改。这有时会造成难以调试的 BUG。为了一定程度上缓解这个问题,Go 1.2 版本中提供了一个扩展切片操作符:[low:high:max],用来限制新切片的容量。使用这种方式产生的切片容量为max-low。
func main() { array := [10]uint32{1, 2, 3, 4, 5} s1 := array[:5:5] s2 := array[5:10:10] fmt.Println(s2) s1 = append(s1, 6) fmt.Println(s1) fmt.Println(s2) }

5、切片扩容
前面我们讲到了,切片扩容粗略的可以认为是当切片容量小于1024时,以2倍扩容,如果大于1024则以0.25倍扩容,那么接下来先来看下面这段代码输出什么?
func main() { slice := []int{1, 2, 3, 4} newSlice := append(slice, 5) fmt.Printf("Before slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice)) fmt.Printf("Before newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice)) newSlice[1] += 10 fmt.Printf("After slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice)) fmt.Printf("After newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice)) }
思考一下如下图所示

分析:slice的长度和容量都是4,当我们执行newSlice := append(slice, 5)时,会先拷贝一份slice的结构体,然后进行检测slice备份是否有足够的容量,容量不够会触发扩容机制,重新申请了一片连续的内存,把slice的数据进行拷贝,最后追加5,返回slice副本赋值给newSlice,所以会出现两片互不干涉的连续的数组内存空间。
结论:对切片进行append操作时,如果容量不够,会触发切片扩容机制,将会根据扩容策略重新开辟一个新的连续的内存空间,将原数组数据进行拷贝,然后追加数据,最后将该数组的首地址空间赋值新的结构体切片,新的切片结构体将会赋给旧的结构体切片,完成扩容。
那么扩容之后的数组一定是新的吗?这个也不一定:
func main() { array := [4]int{1, 2, 3, 4} slice := array[0:2] newSlice := append(slice, 5) fmt.Printf("Before slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice)) fmt.Printf("Before newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice)) newSlice[1] += 10 fmt.Printf("After slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice)) fmt.Printf("After newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice)) fmt.Printf("After array = %v\n", array) }

分析:在这种情况下,扩容以后并没有新建一个新的数组,扩容前后的数组都是同一个,这也就导致了新的切片修改了一个值,也影响到了老的切片了。并且 append() 操作也改变了原来数组里面的值。一个 append() 操作影响了这么多地方,如果原数组上有多个切片,那么这些切片都会被影响!无意间就产生了莫名的 bug!
结论:当切片容量足够时,进行append操作后,会在原数组上进行操作,所以这种情况下,扩容以后的数组还是指向原来的数组。
经验:通过var声明切片或者make()初始化切片,如果通过字面量创建切片,要避免出现slice := array[1:2:3],非常容易出现BUG。
通过字面量创建切片,cap的值一定要保持清醒,避免共享原数组导致的bug。
6、切片拷贝
思考一下,下面这段代码的输出是什么?
func main() { slice := []int{10, 20, 30, 40} for index, value := range slice { fmt.Printf("value = %d , value-addr = %x , slice-addr = %x\n", value, &value, &slice[index]) value = value + 10 } fmt.Println(slice) }


浙公网安备 33010602011771号