前言
本文主要记录:
1.Slice切片的实现原理。
2.切片的指针是存储在堆中还是栈中的。
3.切片使用中的一些坑。
一、为什么要有切片?
由于go中的数组是值类型的,使用的时候是固定大小的 如arr := [3]int{1,2,3} 之后就无法再改变数组的长度了。
所以golang基于数组设计出了以动态数组为底层数据结构的切片Slice。动态数组就是一种可以动态往数组中添加、删除元素的数据结构,并且在容量不足的时候可以动态的进行扩容。
二、切片是怎么实现的呢?
1.Go的切片结构体 – SliceHeader
如下图所示,go使用SliceHeader这个struct来表示动态数组,它包含了一个Data 的uint类型的指针用于指向底层动态数组的地址、Len表示切片中保存的元素个数、Cap表示当前数组内总共有多少容量。
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
2.初始化切片的两种方式
切片初始化的方式分为 编译期初始化 和 运行时初始化两种方式:
编译期初始化包括了: 1)从数组中生成切片 2)使用字面量的方式生成切片
// 演示go语言切片相关操作
// slice初始化的方式一: []string{}
slice1 := []string{"java", "golang", "python", "php"}
fmt.Println(slice1) // [java golang python php]
// slice初始化的方式二: 使用make() 函数 很常用!
slice2 := make([]string, 5)
fmt.Println(len(slice2)) // 5
fmt.Println(slice2) //[ ] 默认是5个空字符串
// slice初始化的方式三: 通过数组来创建
data_arr := [5]int{2,4,6,8,10}
slice3 := data_arr[1:4]
fmt.Println(slice3) // [4 6 8]
编译期初始化切片的方式可以在编译期就把元素的类型、长度等需要做的typeCheck都提前做了,所以当我们声明了一个长度只有4的数组,而要通过数组的方式来获得一个长度为5的切片是可以在build编译期就把错误提示出来的。
*编译器初始化切片的原理底层实际上是这样的:
slice := []int{1,2,3}
//实际上它会经历一下的几个步骤:
// 计算出元素的个数,然后初始化一个数组出来
var vstat [3]int
vstat[0] = 1
vstat[1] = 2
vstat[2] = 3
// 创建一个数组的指针
var vauto *[3]int = new([3]int)
// 数组指针指向上面的数组
*vauto = vstat
// 用户声明的slice变量指向该数组,也就是回到第一步从数组的方式来初始化
slice := vauto[:]
*运行期使用make函数初始化有两种方式: make([]int, 5) 和 make([]int,5,6)
第一种指定了Len长度而没有指定Cap长度的切片生成出来的就是一个带有5个int默认值的元素并且Cap也是5的切片。
第二种生成了cap为6,那么底层就会开辟6个空间,但是只有5个元素。
3.切片是在栈上分配内存的还是在堆?
这个与:
// go build -gcflags '-m -l' array.go
// ./array.go:5:2: moved to heap: s
// ./array.go:5:11: make([]int, 3, 4) escapes to heap
func test() *[]int {
s := make([]int,3,4)
return &s
}
//再进行实验发现,以下场景是不会发生s的变量逃逸的
// ./array.go:5:11: make([]int, 3, 4) does not escape
func test() int {
s := make([]int,3,4)
return s[0]
}
// 但是如果test方法返回的是s这个切片,仍然会发生逃逸,可能这属于编译器无法确定是否被外部引用的场景
// ./array.go:5:11: make([]int, 3, 4) escapes to heap
func test() []int {
s := make([]int,3,4)
return s
}
发现在内存分配的过程中,s这个变量逃逸到了堆中去初始化,也就是说这个切片的指针是存储在堆上的,而对于切片本身是存储在堆上还是栈上,如果切片指针的变量都发生了逃逸,那么切片也会在堆上初始化,尽管它的容量很小本可以在栈上就完成初始化。
总结:
经过学习,这与代码片段的上下文环境有关系:
1)如果变量明确被函数外部所引用,那么肯定会在堆上分配内存
2)如果编译期编译器不能确定是否被外部引用,也会直接分配在堆上.
3)但是如果切片在方法中初始化之后,只用于取其中一部分的值返回,仍然不会发生逃逸。
4.切片的扩容:
slice的扩容
slice的扩容要注意两个点:
1.扩容的原理 2.扩容机制
1.扩容的原理:
实际上扩容是会重新申请存储空间,空间长度为扩容后的cap,然后把原来len长度的数据拷贝到新的切片中,原来的切片依然存在。
2.扩容机制:
当cap小于1024时,基本上当slice满足了cap后再append进新的数据都会扩容原来的cap的两倍
当cap大于1024后,slice的扩容方式为原来cap的1.25倍,也就是在原来的基础上开辟多1/4的新空间
// 声明一个数组
arr := [10]int{1,2,3,4,5,6,7,8,9,10}
// 从数组中产生了第一个切片
sub_slice1 := arr[2:4]
// 发现它的len是2 cap是8
// 这说明切面会从原数组中复用它的cap作为存储空间,不会再新申请空间,节省空间
fmt.Printf("len:%d, cap:%d \n", len(sub_slice1), cap(sub_slice1)) //len:2, cap:8
// 下面演示2倍的简单扩容
// 首先申请一个空的切片
empty_slice := make([]int,0)
fmt.Printf("len:%d, cap:%d \n", len(empty_slice), cap(empty_slice)) //len:0, cap:0
new_slice := append(empty_slice, 1)
fmt.Printf("len:%d, cap:%d \n", len(new_slice), cap(new_slice)) //len:1, cap:1
new_slice = append(new_slice, 2)
fmt.Printf("len:%d, cap:%d \n", len(new_slice), cap(new_slice)) //len:2, cap:2
new_slice = append(new_slice, 3)
new_slice = append(new_slice, 4)
fmt.Printf("len:%d, cap:%d \n", len(new_slice), cap(new_slice)) //len:4, cap:4
// 下一次扩容可以清晰看到2倍
new_slice = append(new_slice, 5)
fmt.Printf("len:%d, cap:%d \n", len(new_slice), cap(new_slice)) //len:5, cap:8
三、切片的使用有什么坑需要注意呢?
实际开发中,我遇到的使用切片的坑:
1.当从一个数组生成很多切片的时候,要注意每个切片的底层实际上都是同一个数组,更新操作会影响到别的切片看到的切片内容。
2.当我们希望在别的方法中对切片进行新增append操作时,必须返回一个切片然后用变量接收,才能完成切片的增删操作,否则会无效。
3.大切片问题,大切片在扩容或者copy的时候性能会很低,要注意大切片的append操作一旦动态新增内存时超出了限制,就会导致程序崩溃;而copy一个大切片也会很慢。
总结
切片是go开发中非常常用的一种动态数组的类型,除了简单的运用外,了解它的原理将有助于我们在开发中少踩坑更有效率的完成开发。
由于go语言的原生库没有链表也没有队列、栈等更高级的数据结构,我们可以自行基于切片(也就是动态数组)实现队列和栈这样的结构,在实现数据结构的增删改查的过程中对Slice可以有更深刻的认识。