Go语言切片剖析


切片(slice)[1]类似数组,都是一段相同类型元素在内存中的连续集合。和数组不同的是,切片的长度是可以动态改变的,而数组一旦确定了长度之后就无法再改变了。

切片的创建和使用

创建

要创建一个切片,我们可以使用以下几种方式

  • 使用关键字make()创建切片
  • 使用切片表达式[2]从切片或数组构造切片
  • 使用字面值显式初始化切片
a := make([]int,3,5// 使用make关键字
b := a[1:3]          // 使用切片表达式
c := []int{1,2,3}    // 使用字面值显式初始化

使用

切片的使用方式类似数组,都是通过下标访问对应的元素:

// 使用字面值初始化切片
foo := []int{5,6,7,8,9}
f1 := foo[0// f1 = 5
f2 := foo[1// f2 = 6
f3 := foo[4// f3 = 9 #注意这里的值#

// 使用切片表达式从切片foo上创建新切片bar
bar := foo[2:4]
b1 := bar[0// b1 = 7
b2 := bar[1// b2 = 8
// b3 := bar[2] 出错,panic: runtime error: index out of range [2] with length 2

// 对切片进行操作
bar = append(bar,10// 向slice末尾内追加元素
b3 := bar[2// b3 = 10 # 注意这里的值
f3 = foo[4]  // f3 = 10 #和上面的f3比较一下#
foo[4] = 9   // 更改foo[4]的值
b3 = bar[2]  // b3 = 9  #和上面的b3比较一下#

我们并没有对foo进行修改,我们只是在bar后面添加了一个元素10,为什么foo的内容也发生改变了呢?后面我们修改了foo[4],为什么bar刚刚追加的10变成了9?是不是它们用的是同一块地址空间?

在回答这些问题之前,让我们先看看切片的结构。

切片的运行时结构

切片在运行时的表示为reflect.SliceHeader,其结构如下

type SliceHeader struct {
   Data uintptr // 底层数组的指针
   Len  int     // 切片的长度
   Cap  int     // 切片的容量
}

我们可以发现,切片本身并没有“存储数据”的能力,它”肚子里“有一个指针,这个指针指向的才是真正存放数据的数组。当我们使用make([]int,3,5)来创建切片时,编译器会在底层创建一个长度为5的数组来作为这个切片存储数据的容器,并将切片的Data字段设为指向数组的指针。

我们可以使用如下方法来将一个切片转换为reflect.SliceHeader结构:

// 初始化一个切片
slice := make([]int35)
// 将切片的指针转化为unsafe.Pointer类型,再进一步将其转化为reflect.SliceHeader
sliceHeader := (*reflect.SliceHeader)(unsafe.Pointer(&slice))
// 打印切片的属性
fmt.Printf("Data:%pn",unsafe.Pointer(sliceHeader.Data)) // Data:0xc0000181e0
fmt.Printf("Len:%vn",sliceHeader.Len) // Len:3
fmt.Printf("Cap:%vn",sliceHeader.Cap) // Cap:5

通过这种方法,我们可以很容易的观察使用切片表达式创建切片和原数组的关系:

// 初始化一个数组
arr := [3]int{123}
// 使用切片表达式在数组arr上创建一个切片
slice := arr[0:2]
sliceHeader := (*reflect.SliceHeader)(unsafe.Pointer(&slice))
fmt.Printf("Data:%pn", unsafe.Pointer(sliceHeader.Data)) // Data:0xc0000b8000
fmt.Printf("Arr:%pn",&arr) // Arr:0xc0000b8000
fmt.Printf("Len:%vn", sliceHeader.Len) // Len:2
fmt.Printf("Cap:%vn", sliceHeader.Cap) // Cap:3

我们可以发现,切片的Data字段和数组的指针是相同的,说明切片和数组共用的是同一块内存空间。

切片的内存结构

说了这么多,切片究竟长什么样子呢?

我们拿下面这段代码来做个示范:

// 初始化一个长度为5的数组
arr := [5]int{56789}
// 使用切片表达式在arr上构建一个切片
slice := arr[2:4]

数组arr和切片slice的结构如下图所示:

Go语言切片剖析
我们结合代码来解读一下这张图,

  • arr是一个由5个元素组成的数组,它们在内存中是连续的。
  • slice是使用切片表达式arr[2:4]来构建的

所以slice的内容是数组arr[2]arr[4]中间的这部分(从2开始但并不包括4)。

也就是说,sliceData字段指针指向的是arr[2]的位置,当前slice里有两个元素(4 – 2 = 2),slice[0]的值是7,slice[1]的值是8。

fmt.Println(slice[0]) // 7
fmt.Println(slice[1]) // 8

Cap为什么是3呢?

因为slice的底层数组,也就是arr依然有额外的空间供slice使用,目前slice只包括了78两个元素,但如果需要添加新的元素进slice里,那么9这个元素的空间就可以供slice使用。

我们可以查看汇编验证一下是否正确

...
// 初始化部分
LEAQ type.[5]int(SB), AX
CALL runtime.newobject(SB) // 在堆上分配内存
MOVQ $5, (AX)   // arr[0] = 5
MOVQ $68(AX)  // arr[1] = 6
MOVQ $716(AX) // arr[2] = 7
MOVQ $824(AX) // arr[3] = 8
MOVQ $932(AX) // arr[4] = 9
// Slice相关逻辑
ADDQ $16, AX // Data = &arr[2],AX寄存器指向的是arr[0],将其加16字节,也就是向后移动两个元素
MOVL $2, BX  // Len = 2
MOVL $3, CX  // Cap = 3
...

总结一下,Data指针指向slice截取的第一个数据,Len代表slice当前有多少个元素,Cap表示slice最多可以容纳的元素数量,超过Cap之后就需要扩容了。

所以现在我们可以解决开始的问题了:如果使用切片表达式在别的切片或数组上构建切片,那么它们共享的是同一块内存空间,只不过Data,Cap,Len的数据不一样,如果修改其中某一个元素,其他切片或数组也会受到影响

切片的扩容

切片本身并不是一个动态数组,那么它是如何实现动态扩容的呢?

我们依然拿上面的例子讲解

arr := [5]int{56789}
slice := arr[2:4]
// 当前slice属性 Len:2,Cap:3
// 向slice内追加元素
slice = append(slice,5// 此时slice为[7,8,5]
// 当前slice属性 Len:3,Cap:3

在我们使用appendslice内追加1个元素后,底层的arr数组其实已经没有额外的容量能够让我们再一次追加数据了,此时的slice如图所示:

Go语言切片剖析

现在我们再次append一个元素看看会发生什么事情

// 接前面
// 在底层数组没有空间的情况下再次追加元素
slice = append(slice,6)

// arr 此时的值
fmt.Printf("%#vn",arr) // [5]int{5, 6, 7, 8, 5}
// slice 此时的值
fmt.Printf("%#vn",slice) // []int{7, 8, 5, 6}

// arr的地址
fmt.Printf("%pn",&arr) // 0x081e0
// slice Data的值以及Len和Cap
sliceHeader := (*reflect.SliceHeader)(unsafe.Pointer(&slice))
fmt.Printf("Data:%pn", unsafe.Pointer(sliceHeader.Data)) // 0x08210
fmt.Printf("Len:%vn", sliceHeader.Len) // Len:4
fmt.Printf("Cap:%vn", sliceHeader.Cap) // Cap:6

观察输出你会发现,sliceData字段已经和arr的值不一样了,说明此时slice的底层数组已经不是arr,我们使用以下命令禁用优化和内联进行编译,并查看对应的汇编代码,再次验证一下:

GOSSAFUNC=main.grow go build -gcflags "-N -l" slice.go

源代码Go语言切片剖析

最终生成的汇编代码Go语言切片剖析

仔细观察我们发现,源代码第7行所对应的汇编指令中,有一行CALL runtime.growslice,这个函数定义在runtime/slice.go文件中,函数签名如下

func growslice(et *_type, old slice, cap int) slice

该函数传入旧的slice期望的新容量,返回新的slice,让我们看看函数的具体实现:

func growslice(et *_type, old slice, cap int) slice {
    // 省略了部分代码
    newcap := old.cap
    // doublecap为旧切片容量的2倍
    doublecap := newcap + newcap
    // 如果期望容量 > doublecap,则直接将期望容量作为新的容量
    if cap > doublecap {
        newcap = cap
    } else {
        // 判断旧切片的容量,如果小于1024,则将旧切片的容量翻倍
        if old.cap < 1024 {
            newcap = doublecap
        } else {
            // 每次增长1/4,直到容量大于期望容量
            for 0 < newcap && newcap < cap {
                newcap += newcap / 4
            }
            // 如果旧切片容量小于等于0,则直接将期望容量作为新容量
            if newcap <= 0 {
                newcap = cap
            }
        }
    }
    // 省略了部分代码
}

在本例中,我们向growslice函数传入的cap4(因为旧的slice本身有3个元素,再次append1个元素,所以期望容量是4,为了印证这一点,我们可以观察对应的汇编代码,在Go 1.17之后的版本里,x86平台下函数调用使用寄存器来传递函数的参数,寄存器AX传递第一个参数,参数二的runtime.slice是个结构体,有三个字段,占用3个寄存器,所以BXCXDI寄存器是第二个参数,SI寄存器为cap,仔细看调用前的准备工作MOVL $4, SI,印证了我们之前说传入的cap为4的说法),cap传入4之后我们向下走,4 < 2 x 3,不满足 cap > doublecap,继续向下走,到old.cap < 1024时条件满足,所以新的容量就等于旧的容量翻倍,也就是2 x 3 = 6

上述部分得到的结果并不是真正的新切片容量,为了提高内存分配效率,减少内存碎片,会在这个newcap的基础上向上取整,取整的参考是一个数组,位置在runtime/sizeclasses.go中,数组内容如下:

var class_to_size = [_NumSizeClasses]uint16{081624324864, ..., 32768}

上面我们计算的newcap为6,每个int占用8字节,共计6 x 8 = 48字节,正好在这个数组中,不需要再向上取整,如果我们刚刚计算的newcap为5,5 x 8 = 40字节,需要向上对齐到48字节,于是最终的新切片大小就为6个元素而不是5个。

到这里我们已经计算出我们新切片的所有数据了,Len:4Cap:6。怎么样?是不是和上面代码的输出一样呢?现在我们再来看看新切片的内存结构图:

Go语言切片剖析

slice现在引用了一个新的数组,现在的slicearr已经没有关系了。

值得注意的是,切片扩容后灰色的部分暂时未使用,这部分是留给下次append时使用的,避免频繁扩容带来的复制和内存分配的开销。

切片的传递

我们之前提到过,Go中的参数传递都是值传递[3]。但如果你有过一些Go语言的经验,可能会遇到下面这种情况:

// foo 尝试把数组的第一个元素修改为9
func foo(arr [3]int) {
    arr[0] = 9
}

// bar 尝试把切片的第一个元素修改为9
func bar(slice []int) {
    slice[0] = 9
}

func main() {
    arr := [3]int{123}
    slice := []int{123}
    foo(arr)           // 尝试修改数组
    bar(slice)         // 尝试修改切片
    fmt.Println(arr)   // [1 2 3]
    fmt.Println(slice) // [9 2 3]
}

我们对arr的修改在main函数中是不可见的,这符合我们刚刚提到的:一切参数的传递都是值传递,我们在foo中修改的只是arr的一个副本,真正的arr并没有被修改,除非我们将foo函数的参数修改为指针类型。但slice的修改却影响到了main函数中的slice,这是为什么呢?难道切片是引用传递而非值传递吗?

其实不是的,我们上面说了,一切参数传递都是值传递slice当然也不例外 ,结合我们刚刚了解的slice的结构,我们在向bar中传递参数时,是slice的结构体拷贝了一份而并非拷贝了底层数组本身,因为slice的结构体内持有底层数组的指针,所以在bar内修改slice的数据会将底层数组的数据修改,从而影响到main函数中的slice

下面我用两幅图向你展示参数传递时复制的内容:

Go语言切片剖析

上面这幅是我们调用foo函数时的情景,我们复制了一个数组后传入了foo函数,foo函数内的arr和main函数内的arr除了里面的元素相等之外没有任何关系,它们不是同一块内存空间,所以我们在foo函数内修改数组arr并不会影响main函数的arr

接下来我们再看一下当我们调用bar时的情景:Go语言切片剖析

当我们调用bar函数时,复制的是切片的结构体,也就是DataLenCap这三个属性。此时bar函数内的slice和main函数内的slice持有的是同一个数组的指针,所以我们在bar函数内对slice所做的修改会影响到main函数中的slice

那是不是可以认为,我们在任意场景下都可以传递slice到别的函数?反正里面有指针,做的修改都能在函数外面“看到”呀?

那我们稍微修改一下上面代码中的bar函数:

// bar 尝试在slice后追加元素
func bar(slice []int) {
    slice = append(slice,4)
}

func main() {
    slice := []int{123}
    bar(slice)         // 追加元素
    fmt.Println(slice) // [1 2 3]
}

为什么我们在bar内追加了元素,但main内没发生变化?

Go语言切片剖析

我们知道,bar.slicemain.slice复制,起初bar.slicemain.sliceData相同的,它们指向同一块内存地址,但我们在bar函数内向bar.slice追加了元素,由于slice底层的数组只能容纳3个元素,所以append操作会触发扩容导致bar.slice指向新的数组,而main.slice指向的依然是旧的数组,所以我们在bar内的append操作不会影响main函数内的slice

思考

  • 如果上述例子中main.sliceLen为3,Cap为4,那么调用bar函数进行append操作,main.slice会受到什么影响吗?

切片的复制

切片使用内建函数copy进行复制,copy将元素从源切片(src)复制到目标切片(dst),返回复制的元素数。

// copy 的函数签名
func copy(dst, src []Type) int

// copy的使用
foo := []int{54321}
bar := []int{0000}

// 将bar复制到foo,从foo[2]开始
i := copy(foo[2:], bar)
fmt.Printf("%vn",foo) // [5 4 0 0 0]
fmt.Printf("%dn",i)   // 3

上面代码的行为如下图所示:Go语言切片剖析

我们copydst是从foo[2]开始的,所以copy只会将foo[2],foo[3],foo[4]使用bar进行替换,由于只复制了3个,所以copy返回3

copy会在编译时进行展开,底层调用runtime.memmove函数将整块内存进行拷贝。相较于平时我们使用for循环进行逐个复制,copy能够提供更好的性能。

参考资料

[1]

Go Specification Slice types: https://go.dev/ref/spec#Slice_types

[2]

Slice expressions: https://go.dev/ref/spec#Slice_expressions

[3]

pass by value: https://go.dev/doc/faq#pass_by_value


原文始发于微信公众号(梦真日记):Go语言切片剖析

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/167852.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!