你真的了解Golang中的slice吗?

190 阅读11分钟

前言

切片 slice 是 golang 中一个非常经典的数据结构,其定位可以类比于其他编程语言中的数组。使用 go的朋友对于切片这个数据结构肯定不会感到陌生,一些基本的概念和用法应该是可以做到了然于心的。

本文我将采用Q&A的形式,先抛出一些代码题,大家可以停下来思考并给出自己的答案,然后对比答案解析,查漏补缺。

问题1

func Test_slice(t *testing.T){  
    s := make([]int,10)    
    s = append(s,10)  
    t.Logf("s: %v, len of s: %d, cap of s: %d",s,len(s),cap(s))  
}

输出:[0 0 0 0 0 0 0 0 0 0 10], 11, 20

解释:切片老容量<256, 且需要的新容量<老容量的2倍,直接取老容量的2倍作为新容量。

问题2

func Test_slice(t *testing.T){  
    s := make([]int,0,10)    
    s = append(s,10)  
    t.Logf("s: %v, len of s: %d, cap of s: %d",s,len(s),cap(s))  
}

输出:[10], 1, 10

解释:未发生扩容

问题3

func Test_slice(t *testing.T){  
    s := make([]int,10,11)    
    s = append(s,10)  
    t.Logf("s: %v, len of s: %d, cap of s: %d",s,len(s),cap(s))  
}

输出:[0 0 0 0 0 0 0 0 0 0 10], 11, 11

解释:同样未发生扩容

问题4

func Test_slice(t *testing.T){  
    s := make([]int,10,12)    
    s1 := s[8:]  
    t.Logf("s1: %v, len of s1: %d, cap of s1: %d",s1,len(s1),cap(s1))  
}

输出:[0 0], 2, 4

解释:切片容量也会随着截取窗口的起始偏移量,计算缓存空间,发生变化,即12-8=4

问题5

func Test_slice(t *testing.T){  
    s := make([]int,10,12)    
    s1 := s[8:9]  
    t.Logf("s1: %v, len of s1: %d, cap of s1: %d",s1,len(s1),cap(s1))  
}

输出:[0], 1, 4

解释:截取切片得到的新slice容量只看截取的起点,后续的容量是作为缓存空间使用的,因此仍然为12-8=4

问题6

func Test_slice(t *testing.T){  
    s := make([]int,10,12)    
    s1 := s[8:]  
    s1[0] = -1  
    t.Logf("s: %v",s)  
}

输出:[0 0 0 0 0 0 0 -1 0]

解释:s1 是在s基础上截取得到的,对slice header做了一次值拷贝,但是里头的字段array是一个指针,是一次引用传递,所以指向底层的数组还是同一个,其中s[x] 等价于s1[x+8],因此修改s1[0]会直接影响到s[8]

问题7

func Test_slice(t *testing.T){  
    s := make([]int,10,12)    
    v := s[10]  
    // 问,此时数组访问是否会越界  
}

输出:会panic

解释:初始化时设定了切片长度为10,容量为 12。 容量是物理意义上的,但长度是逻辑意义上的,判断是否越界以逻辑意义为准,因此 index = 10 已经越界。

问题8

func Test_slice(t *testing.T){  
    s := make([]int,10,12)    
    s1 := s[8:]  
    s1 = append(s1,[]int{10,11,12}...)  
    v := s[10]  
    // ...  
    // 问,此时数组访问是否会越界  
}

输出:会panic

解释:在 s 的基础上截取产生了 s1,此时 s1 和 s 会拥有两个独立的 slice header,但array指向的底层数组还是同一个;接下来执行 append 操作时,由于 s1 预留的空间不足,之前截取后新长度为2,容量为4,只有2个空闲缓冲区,再append3个新元素,s1 会发生扩容;s1 扩容后,会被迁移到新的空间地址,此时 s1 已经和 s 做到真正意义上的完全独立,意味着修改 s1 不再会影响到 s;s 继续维持原本的长度值 10 和容量值 12,因此访问 s[10] 会panic

问题9

func Test_slice(t *testing.T){  
    s := make([]int,10,12)    
    s1 := s[8:]  
    s1 = append(s1,2)  
    v := s[10]  
    // ...  
    // 问,此时数组访问是否会越界  
}

输出:会panic

解释:虽然s1的容量足够新增2个元素,但是s的长度仍为10,判断是否越界只看逻辑上的len,而不是cap,因此访问 index=10 时会越界

问题10

func Test_slice(t *testing.T){  
    s := make([]int,10,12)    
    s1 := s[8:]  
    changeSlice(s1)  
    t.Logf("s: %v",s)  
}  
  
  
func changeSlice(s1 []int){  
  s1[0] = -1  
}

输出:[0 0 0 0 0 0 0 0 -1 0]

解释:切片在传递时属于引用传递,且 s1[0] 和 s[8] 指向同一个元素. 因此在局部方法中,修改了 s1[0] 会直接影响到 s[8] 的内容

问题11

func Test_slice(t *testing.T){  
    s := make([]int,10,12)    
    s1 := s[8:]  
    changeSlice(s1)  
    t.Logf("s: %v, len of s: %d, cap of s: %d",s, len(s), cap(s))  
    t.Logf("s1: %v, len of s1: %d, cap of s1: %d",s1, len(s1), cap(s1))  
}  
  
  
func changeSlice(s1 []int){  
  s1 = append(s1, 10)  
}

输出:

  • s [0,0,0,0,0,0,0,0,0,0], 10, 12
  • s1 [0,0], 2, 4

解释:虽然切片的底层数组是引用传递,但是在方法调用时,header是值传递,传递的会是一个新的 slice header。 因此在局部方法 changeSlice 中,虽然对 s1 进行了 append 操作,但这只会在局部方法中这个独立的 slice header 中生效,不会影响到原方法 Test_slice 当中的 s 和 s1 的长度和容量。

问题12

func Test_slice(t *testing.T){  
    s := []int{0,1,2,3,4}  
    s = append(s[:2],s[3:]...)  
    t.Logf("s: %v, len: %d, cap: %d", s, len(s), cap(s))  
    v := s[4]   
    // 是否会数组访问越界  
}

输出:[0 1 3 4], 4, 5 会发生越界

解释:这个append操作对原来的s做了覆盖,s[:2] = {0,1} s[3:] = {3,4},二者拼接后相当于把中间 index=2 的元素删了,执行后新的切片s实际长度为4,容量保存和原来不变仍为5,因此访问 s[4]会发生数组越界的错误。

问题13

func Test_slice(t *testing.T){  
    s := make([]int,512)    
    s = append(s,1)  
    t.Logf("len of s: %d, cap of s: %d",len(s),cap(s))  
}

输出:513, 848

解释:

  • 由于切片 s 原有容量为 512,已经超过了阈值 256,因此对其进行扩容操作会采用的计算公式为 512 + (512 + 3*256)/4 = 832
  • 其次,在真正申请内存空间时,我们会根据切片元素大小乘以容量计算出所需的总空间大小,得出所需的空间为 8byte * 832 = 6656 byte
  • 再进一步,结合分配内存的 mallocgc 流程,为了更好地进行内存空间对其,golang 允许产生一些有限的内部碎片,对拟申请空间的 object 进行大小补齐,最终 6656 byte 会被补齐到 6784 byte 的这一档次
  • 最终,在 mallocgc 流程中,我们为扩容后的新切片分配到了 6784 byte 的空间,于是扩容后实际的新容量为 cap = 6784/8 = 848
知识扩展

关于内存分配时,对象分档以及与 mspan 映射细节可以参考 golang 标准库 runtime/sizeclasses.go 文件

// class  bytes/obj  bytes/span  objects  tail waste  max waste  min align  
//     1          8        8192     1024           0     87.50%          8  
//     2         16        8192      512           0     43.75%         16  
//     3         24        8192      341           8     29.24%            
// ...  
//    48       6528       32768        5         128      6.23%        128  
//    49       6784       40960        6         256      4.36%        128

问题14

    a := [...]int{0, 1, 2, 3}  
    x := a[:1]  
    y := a[2:]  
    x = append(x, y...)  
    x = append(x, y...)  
    fmt.Println(a, x)

输出:

  • a [0 2 3 3]
  • x [0 2 3 3 3]

解释:我们来逐行解析这段代码

代码片段运行结果
a := [...]int{0, 1, 2, 3}初始化了一个数组,长度是4,容量为4,值是[ 0 1 2 3]
x := a[:1]x是一个切片,底层指向数组a的首元素,值是[0],长度1,容量4
y := a[2:]y是一个切片,底层数组指向数组a的后2个元素构成的子数组,值是[2 3],长度2,容量2
x = append(x, y...)x的剩余容量还有3个空位,足以存储y里的2个元素,所以x不会扩容,x的值变成[0 2 3],长度3,容量4
但是,由于x, a, y都指向同一块内存空间,所以x的修改影响了a和y
a的值变为[0 2 3 3],长度4,容量4
y的值变为[3 3],长度2,容量2
x = append(x, y...)x的剩余容量只有1个,不足以存储y里的2个元素,所以要扩容,申请一个新的底层数组,得到一个新切片,值是[0 2 3 3 3],长度5,容量8。 append的返回值赋值给x,所以切片x会指向扩容后的新数组地址

slice原理剖析

源码数据结构

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

type Pointer *ArbitraryType

切片的类型定义如上,我们称之为 slice header,对应于每个 slice 实例,其核心字段包括:

  • array:指针,指向一个数组,切片的数据实际都存储在这个数组里
  • len:切片长度,指的是逻辑意义上 slice 中实际存放了多少个元素
  • cap:切片的容量,表示切片当前最多可以存储多少个元素,如果超过了现有容量会自动扩容

每个 slice header 中存放的是内存空间的地址(array 字段),后续在传递切片的时候,相当于是对 slice header 进行了一次值拷贝,但内部存放的地址是相同的,因此对于 slice 本身属于引用传递操作。

slice定义在src/runtime/slice.go第15行,源码地址:github.com/golang/go/b…

Pointer定义在src/unsafe/unsafe.go第184行,源码地址:github.com/golang/go/b…

关于 :分割操作符

  1. :可以对数组或者切片slice做数据截取,:得到的结果是一个新slice
  2. slice结构体里的array指针指向原数组或者原slice的底层数组,新切片的长度是:右边的数值减去左边的数值得到的差值,新切片的容量是原切片的容量减去:左边的数值。
  3. :的左边如果没有写数字,左边的默认值是0,右边如果没有写数字,右边的默认值是被分割的数组或被分割的切片的长度,注意,是长度不是容量
  4. :分割操作符右边的数值有上限,上限有2种情况
    • 如果分割的是数组,那上限是是被分割的数组的长度
    • 如果分割的是切片,那上限是被分割的切片的容量。注意,这个和下标操作不一样,如果使用下标索引访问切片,下标索引的最大值是(切片的长度-1),而不是切片的容量。
a := make([]int, 0, 4) // a的长度是0,容量是4
b := a[:] // 等价于 b := a[0:0], b的长度是0,容量是4
c := a[:1] // 等价于 c := a[0:1], c的长度是1,容量是4
d := a[1:] // 编译报错 panic: runtime error: slice bounds out of range
e := a[1:4] // e的长度3,容量3

总之,对数组或者切片做:分割操作产生的新切片还是指向原来的底层数组,并不会把原底层数组的元素拷贝一份到新的内存空间里。

正是因为他们指向同一块内存空间,所以对原数组或者原切片的修改会影响分割后的新切片的值,除非发生了扩容操作。

关于append机制

直接参考一下源码中注释

// The append built-in function appends elements to the end of a slice. If
// it has sufficient capacity, the destination is resliced to accommodate the
// new elements. If it does not, a new underlying array will be allocated.
// Append returns the updated slice. It is therefore necessary to store the
// result of append, often in the variable holding the slice itself:
//  slice = append(slice, elem1, elem2)
//  slice = append(slice, anotherSlice...)
// As a special case, it is legal to append a string to a byte slice, like this:
//  slice = append([]byte("hello "), "world"...)
func append(slice []Type, elems ...Type) []Type

append函数返回的是一个切片,append在原切片的末尾添加新元素,这个末尾是指的切片长度的末尾,不是切片容量的末尾。

如果原切片的容量足以包含新增加的元素,那append函数返回的切片结构里3个字段的值是:

  • array指针字段的值不变,和原切片的array指针的值相同,也就是append是在原切片的底层数组添加元素,返回的切片还是指向原切片的底层数组
  • len长度字段的值做相应增加,增加了N个元素,长度就增加N
  • cap容量不变

如果原切片的容量不足以包含新增加的元素,那就会扩容,申请新的数组空间。

注意,slice不是并发安全的,并发场景使用时需要加锁保护

参考

mp.weixin.qq.com/s/uNajVcWr4…