likes
comments
collection
share

详解Go slice底层原理

作者站长头像
站长
· 阅读数 15

前言

切片是go中非常重要的一种基础结构,一个切片由3部分组成:指针长度容量。指针指向底层数组,长度代表slice当前的长度,容量代表底层数组的长度

type slice struct { 

    array unsafe.Pointer // 指向底层数组 

    len int // 长度 

    cap int // 容量 

}

本文将介绍切片的一些最佳实践,以及切片的常用操作append,截取,复制在底层是如何实现的

初始化

切片的初始化方式有好几种,分别为:

  1. 空切片
var s []int

此时切片s的cap = 0,len = 0,data = nil,虽然data等于nil,但不影响往s append元素,因此和

s := []int{}初始化方式在使用上没有区别

  1. 字面量初始化
s := []int{1,2,3}

该切片cap = len = 3,data为指向底层数组的指针,该数组容量为3,装有3个元素

  1. make
s := make([]int,3,5)

切片s的len=3,cap=5,data指向一个底层数组,该数组容量为5,其中每个值都是默认值

建议使用make初始化方式,因为只有make能指定容量,当预先知道数据规模时,可以预分配合适的内存空间,避免后续使用时频繁扩容

需要注意用make初始化时,除了copy切片时(下文介绍),len需要为0,表示当前slice有0个元素,append时从0位置开始追加。若len不为0,就会从len位置开始追加,len前面的位置就用不到了

append追加

slice作为动态数组,长度是可以变化的,实现方式就是使用append

如果追加完后len小于等于cap,就会在当前使用的底层数组后面追加元素

如果追加完后len大于cap了,就会进行扩容。注意这里是大于slice的cap,而不是底层数组的容量,虽然这两者大部分情况是相等的,也就是说可能出现一种情况是,切片中元素数量还没到底层数组的容量,就会进行扩容。这种情况在切片截取时会发生,我们后面再讨论

func main() {

   s := make([]int, 0, 3)

   fmt.Println(s, len(s), cap(s))



   s = append(s, 1)

   fmt.Println(s, len(s), cap(s))

}



// 执行结果

[] 0 3

[1] 1 3

需要注意append之后,一定要用原来的变量s去接收

因为原来的变量s的len = 0,没有变化,对原来的变量s进行遍历,虽然底层数组已经有1个元素,但slice能访问到哪些元素,是根据len决定的,所以看起来还是空切片

append返回的slice变量中len = 1,cap = 3,此时用s接收该返回值,s的len变为1,就能访问到刚append的元素

继续对切片执行append,直到len超过cap,就会触发扩容,新申请一块底层数据,将原数组的数据拷贝过去

func main() {

   s := make([]int, 0, 3)

   fmt.Println(s, len(s), cap(s))



   s = append(s, 1)

   fmt.Println(s, len(s), cap(s))



   s = append(s, 2, 3, 4)

   fmt.Println(s, len(s), cap(s))

}



// 执行结果

[] 0 3

[1] 1 3

[1 2 3 4] 4 6

详解Go slice底层原理

关于新数组的容量,在runtime/slice.go.growslice中有说明:

newcap := old.cap

doublecap := newcap + newcap

if cap > doublecap {

   newcap = cap

} else {

   if old.cap < 1024 {

      newcap = doublecap

   } else {

 for 0 < newcap && newcap < cap {

         newcap += newcap / 4

      }

 if newcap <= 0 {

         newcap = cap

      }

   }

}
  • 若2倍的老容量oldcap还小于需要的容量,就直接用需要的容量

    • 当一次性append元素过多时,可能出现这种情况
  • 当oldcap小于1024时,使用2 * oldcap作为新数组的容量

  • 否则新容量 = oldcap * 1.25

多分配一些空间,避免每次追加都要重新申请内存,但长度超过1024时只多分配 25%的空间,避免浪费

除此之外,还会进行内存对齐操作,因为go内存分配是按照size class大小进行分配,需要根据需求内存大小,在runtime/sizeclasses.class_to_size数组中,找到最小的满足需求大小的一块内存,给slice使用

该数组定义如下:

var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}

例如我需要能装下5个int数据,也就是40字节的空间,就会找到大小为class_to_sizep[4] = 48字节的空间进行分配

这个道理是,这段剩余的内存就算不给该切片,也不会给别人使用了,因为就给该切片,减少内部碎片

截取

切片支持以下截取操作:



func main() {

   s := []int{1, 2, 3, 4, 5, 6}



   s1 := s[1:3]

   fmt.Println(s1, len(s1), cap(s1))

}



// 结果

[2 3] 2 5

s1从s中,根据开头和结尾进行截取出[1,3)范围的数,截取出2个元素,因此len就是2,cap的值为此次截取的开头算到原切片的cap结尾,也就是5,data还是为为s的data

可以看出,截取操作代价很低,就是new一个slice结构,底层数组复用原来的,通过slice结构的len和cap两个字段,决定当前slice能访问哪些元素,数据量到多少时需要扩容

此时如果对往s1后面append,因为len还没超过cap,就会在data指向的数组后面追加,且该数组和s为同一个,就会修改原数组下标为3位置的值:

func main() {

   s := []int{1, 2, 3, 4, 5, 6}

   

   s1 := s[1:3]

   fmt.Println(s1, len(s1), cap(s1))



   s1 = append(s1, 8)

   fmt.Println(s1, len(s1), cap(s1))

   fmt.Println(s, len(s), cap(s))

}



//

[2 3] 2 5

[2 3 8] 3 5

[1 2 3 8 5 6] 6 6

可以看到,s和s1的数据都被改变

详解Go slice底层原理

切片也支持指定最大cap的截取操作:

func main() {

   s := []int{1, 2, 3, 4, 5, 6}

   fmt.Println(s, len(s), cap(s))



   s2 := s[1:3:3]

   fmt.Println(s2, len(s2), cap(s2))

}



// 结果

[1 2 3 4 5 6] 6 6

[2 3] 2 2

s2从s中也开头和结尾进行截取出[1,3)范围的数,但加了最大值限制3,s2的cap就从此次截取的开头1算到最大值限制3,也就是2

详解Go slice底层原理

此时如果对s2执行append,就会命中上文的情况:明明切片中元素数量还没到底层数组的容量,但len达到了cap的值,就会进行扩容:

func main() {

   s := []int{1, 2, 3, 4, 5, 6}

   fmt.Println(s, len(s), cap(s))



   s2 := s[1:3:3]

   fmt.Println(s2, len(s2), cap(s2))



   s2 = append(s2, 7)

   fmt.Println(s2, len(s2), cap(s2))

}



// 

[1 2 3 4 5 6] 6 6

[2 3] 2 2

[2 3 7] 3 4

可以看到,s[3]位置的值还是4,没有被s2影响,说明此时s和s2底层使用的是两个数组

详解Go slice底层原理

copy复制

go内建函数copy能完成切片的拷贝:



func main() {

   s := []int{1, 2, 3, 4, 5, 6}

   fmt.Println(s, len(s), cap(s))



   sc := make([]int, 6, 6)



   copy(sc, s)

   fmt.Println(sc, len(sc), cap(sc))

}



// 结果

[1 2 3 4 5 6] 6 6

[1 2 3 4 5 6] 6 6

copy函数将原切片s的内容拷贝的目标切片sc。要拷贝多少个元素,是根据min(len(s),len(sc))决定的,因此需要将目标切片的len设置为和原切片一样,才能保证将原切片原封不动地拷贝到目标切片

for遍历

可以通过for循环来遍历切片,其中i代表下标,v代表切片中的每个元素

s := []int{1,2,3,4,5}

for i,v := range s {

   // do something

}

这里有个坑,若想修改切片中的元素,若果修改for的循环遍历v,例如下面想将每个元素 * 2,会发现不生效:

s := []int{1,2,3,4,5}

for _,v := range s {

   v *= 2

}

要想修改切片中的元素,需要拿到下标直接操作切片本身:

s := []int{1,2,3,4,5}

for i,v := range s {

   s[i] *= 2

}