Go slice深度剖析

热身环节

先看一段代码,了解一下对slice的理解程度。

var s = make([]int, 3, 3)
s = append(s, 1, 2, 3)
fmt.Println(&s[0] == &append(s, 4)[0])

var s1 = make([]int, 3, 4)
s1 = append(s1, 1, 2, 3)
fmt.Println(&s1[0] == &append(s1, 4)[0])

输出结果为 falsetrue, 可能很多小伙伴一下就看出来与slicecap的容量是有关系, 但在实际的开发中接手的可能是其他同学的代码,甚至slice 是作为参数传递进来的,稍有疏忽就有可能导致bug的出现。

slice是什么,作用是什么?

个人理解:slice 的底层数据是数组,slice 是对数组的封装,它描述了一个数组的片段。和数组相同,切片也可以通过下标来访问单个元素。

这种解释并没给大家添加新的学习理解成本,解释为在数组基础上做了进一步关于数组的常用方法封装,因此我们只要掌握其中的API方法就可以使用,文章这里通过深入剖析原理,也是希望帮助读者能使用的更加熟练。

Go中数组是定长的,长度定义好之后,不能再更改。 在Go 中, 数组其长度是类型的一部分,限制了它的表达能力,比如 [3]int[4]int 就是不同的类型, 数组就是一片连续的内存。

切片则非常灵活,它可以动态地扩容。切片的类型和长度无关,slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。

slice的提出,极大减轻开发的编程负担, 再也不用像C\c++ 中那样自己申请空间,手动的判断是否溢出,最后还要手动的释放掉。

数据结构如下: runtime/slice.go

type slice struct {
    array unsafe.Pointer // 底层数组指针
    len   int // 长度 
    cap   int // 容量
}
  • array: 是一个非安全类型的指针,指向底层数组,是一个连续的内存块。
  • len: 指slice的实际长度, 即slice中含有的元素个数。
  • cap: 指slice的底层数组长度,即slice的容量,slice可以容纳多少元素。

注意:底层数组是可以被多个 slice 同时指向的,因此对一个 slice 的元素进行操作是有可能影响到其他 slice

slice 的定义方式有下面3种方法

  • var identifier []type
  • make([]T, length, capacity)
  • 从数组或者切片获得, arr[low:high:max],这种情况下和arr共享数组,len=high-low, cap=max-low, 并且遵循"左闭右开"的原则。

make()new() 函数多一些操作,new() 函数只会进行内存分配并做默认的赋0初始化,而make()可以先为底层数组分配好内存,然后从这个底层数组中再额外生成一个slice并初始化。

另外,make只能构建slicemapchannel这 3 种结构的数据对象,因为它们都指向底层数据结构,都需要先为底层数据结构分配好内存并初始化。

关于 silce 自身占用的大小

对于每一个slice结构都由3部分组成:容量(capacity)、长度(length)和指向底层数组某元素的指针,它们各占8字节, 也就是64bit。

关于1个机器字长,64位机器上一个机器字长为64bit,共8字节大小,32位架构则是32bit,占用4字节),所以任何一个slice都是24字节(3个机器字长。

注意区分 array 和 slice 比较

  • 虽然nil sliceEmpty slice的长度和容量都为0,输出时的结果都是[],且都不存储任何数据,但它们是不同的。nil slice不会指向底层数组,而空slice会指向底层数组,只不过这个底层数组暂时是空数组。

  • 在定义上区别主要是定义时数组长度是否有值。 例如:=[...]string {} 定义一个自动推导的string数组。 []int{} 声明出的就是一个int切片,并初始化为空切片,不能访问 Number[0]

方法

下面我们主要介绍一下关于slice中常用方法。

append 方法

切片带有自动扩容机制,一般是在使用了append函数向切片追加了元素之后,切片的容量不足,引起了扩容。

扩容机制的规律是什么呢?

Go18前后有所区别,在底层调用的方法 growSlice函数,这里不做详细的介绍, 仅仅是做一下粗略的描述(后边我们看源码时在单独讲解):

  • 当切片的容量小于一个阈值threshold时,每次扩容将容量翻倍;
  • 当切片的容量大于等于threshold时,每次扩容将容量增加原来容量的 1.25 倍。
  • 在计算出新的容量后,函数根据新的容量计算出需要分配的内存大小capmem,并通过roundupsize函数进行内存对齐。(这里也是为啥有些同学算不出来上面的倍数的原因)
  • 最后,函数将capmem转换为新的容量newcap,并返回新的切片。

注意:超出原 slice.cap 限制,就会重新分配底层数组(新建一个新的),即便原数组并未填满。,如果没有重新分配底层数组,那么一直是在同一个数组下进行操作。

copy 函数

可以将一个slice拷贝到另一个slice中。 函数 copy 在两个 slice 间复制数据,复制长度以 len 小的为准。 两个 slice 可指向同一底层数组,允许元素区间重叠。

复制长度以 len 小的为准的含义 举例来说: copy(s1, s2)

  • 如果 s1 的长度大于 s2, 则会将 s2 放在 s1 的最前面进行覆盖。
  • 如果 s1 的长度小于 s2, 则会将 s1 使用 s2 全部覆盖,多的s2部分不进行复制。

因此:应及时将所需数据 copy 到较小的 slice,以便释放超大号底层数组内存。

slice遍历迭代

slice的遍历,常用的遍历方法和数组相似

  • for... range
  • forlen(slice)结合

具体的如下所示:

for i := 0; i < len(slice); i++ {
    fmt.Println(slice[i])
}
for _,v := range slice{
    fmt.Println(v)
}

对切片/数组截取使用

可以使用如下格式进行切片的使用和截取

语法示例
make[type, len, cap]sliceA := make([]int, 5, 10) //length = 5; capacity = 10
slice[start : end]sliceB := sliceA[0:5] //length = 5; capacity = 10
slice[start : ]sliceC := sliceA[0:] //length = 5; capacity = 10
slice[: end ]sliceD := sliceA[:5] //length = 5; capacity = 10
slice[start : end : cap]sliceE := sliceA[0:5:5] //length = 5; capacity = 5

其他

  • stringslice

string 底层是一个byte数组,因此string也可以进行切片处理。 但是string是不可变的,所以不能通过slice[0]='s' 的方式进行修改。【这种情况下编辑器是编译不通过的】

如果要修改需要将string转为byte数组改变值以后再改成string, 如下例子:

str := "abcdefghijk"	
slice := []byte(str)  //将str转为byte切片
slice[0]='z'//将第1个元素a改为z
str =string(slice)//转回string
fmt.Println(str) //zbcdefghijk
  • 快速复制一个新的slice写法
var s = []int{1,3}
fmt.Println(append([]int(nil), s...)) // type... 是一种固定的写法,表示将s内的数据全部解构出来
  • 序列化与反序列化中的坑

如下代码,在和前端同学写接口时经常遇到:

var s []int
b, _ := json.Marshal(s)
fmt.Println(string(b)) //null 


s := []int{} //对底层的数组进行初始化
b, _ := json.Marshal(s)
fmt.Println(string(b)) //[]

切片作为函数参数会被改变吗

根据slice定义,它其实是一个结构体,当slice 作为函数参数时,就是一个普通的结构体。

  • 若直接传 slice 在调用者看来,实参 slice 并不会被函数中的操作改变;
  • 若传的是 slice 的指针,在调用者看来,是会被改变原 slice 的。

其实不管传的是 slice 还是 slice 指针,如果改变了 slice 底层数组的数据,会反应到实参slice 的底层数据。

根本原因是:底层数据在 slice 结构体里是一个指针,尽管 slice 结构体自身不会被改变,也就是说底层数据地址不会被改变。但是通过指向底层数据的指针,可以改变切片的底层数据。通过 slicearray 字段就可以拿到数组的地址。另外在Go语言里,函数参数传递,只有值传递,没有引用传递。

slice作为参数的时候,一定要注意底层的数组修改是否会带来其他的影响。

性能陷阱:

  • 在大批量添加数据时,建议一次性分配足够大的空间,以减少内存分配和数据复制开销。
  • 初始化足够长的 len 属性,改用索引号进行操作。索引号的效率高。
  • 切片拷贝时需要判断实际拷贝的元素个数。
  • 及时释放不再使用的 slice 对象,避免持有过期数组,造成 GC 无法回收。
  • 谨慎使用多个切片操作同一个数组, 以防读写冲突。 读写冲突在多协程情况下一定要慎重考虑。
  • 当同一个底层数组有很多slice时,一切将变得混乱不堪,因为我们不可能记住谁在共享它,通过修改某个slice元素时,将也会影响那些可能我们不想影响的slice。【这个地方容易出问题】。
  • 切片引用切片场景:如果一个切片有大量的元素,而它只有少部分元素被引用,其他元素存在于内存中,但是没有被使用,则会造成内存泄露。

其实有很多同学还会选择多个去公用一个数组,原因基本都是避免了底层数组的频繁创建来提升性能。【富贵险中求,其实个人觉得,如果了解底层原理,写出一些骚操作问题是不大的,在安全和性能之间平衡好】

几个例子

最后再看看几个输出,来加深我们的理解。

例子1: 可以加强下 底层数组是可以被多个 slice 同时指向的,因此对一个 slice 的元素进行操作是有可能影响到其他 slice

package main

import "fmt"

func main() {
    slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    s1 := slice[2:5]
    s2 := s1[2:6:7]

    s2 = append(s2, 100)
    s2 = append(s2, 200)

    s1[2] = 20

    fmt.Println(s1)
    fmt.Println(s2)
    fmt.Println(slice)
}

结果如下,这里主要是

s1: [2 3 20]
s2: [4 5 6 7 100 200]
slice: [0 1 2 3 20 5 6 7 100 9]

例子2: 将slice 作为参数传递 和 作为指针传递的理解加深。

package main

import "fmt"

func myAppend(s []int) []int {
    // 这里 s 虽然改变了,但并不会影响外层函数的 s
    s = append(s, 100)
    return s
}

func myAppendPtr(s *[]int) {
    // 会改变外层 s 本身
    *s = append(*s, 100)
    return
}

func main() {
    s := []int{1, 1, 1}
    newS := myAppend(s)

    fmt.Println(s)
    fmt.Println(newS)

    s = newS

    myAppendPtr(&s)
    fmt.Println(s)
}

输出结果为:

[1 1 1]
[1 1 1 100]
[1 1 1 100 100]

例子3: 手动代码实现slice的增加和删除操作

//增加
func Add(s []int, index int, value int) []int {
    //如果插入的长度超过了切片的长度,则直接在末尾插入元素
    if index > len(s) {
        return append(s, value)
    }
    //如果插入的位置在切片中间,则需要讲该位置后面的元素全部向后移
    s = append(s[:index+1], s[index:len(s)-1]...)
    s[index] = value
    return s
}

//删除
func Delete(s []int, index int, value int) []int {
    //如果删除的位置超出了切片的长度,则直接返回原切片。
    if index >= len(s) {
        return s
    }
    // 如果删除的位置在切片中间,则需要将该位置后面的元素全部向前移动一位
    copy(s[index:], s[index+1:])
    return s[:len(s)-1]
}
关于我
loading