Home Slice
Post
Cancel

Slice

slice 底层结构

切片在运行时的表现是 SliceHeader 结构体,定义如下:

1
2
3
4
5
6
type SliceHeader struct {
  Data uintptr
  Len int
  Cap int
}

  • Data:指向具体的底层数组。
  • Len:代表切片的长度。
  • Cap:代表切片的容量。

要点是:切片真正存储数据的地方,是一个数组。切片的 Data 属性中存储的是指向所引用的数组指针地址

可以将切片理解成一片连续的内存空间加上长度与容量的标识.

初始化切片

1
2
3
    arr := arr[1:3]             // 下标
    arr := []int{1, 2, 3}       // 字面量
    arr := make([]int, 2, 3)    // 关键字
  1. 使用下标初始化切片 不会拷贝原数组或者原切片中的数据,它只会创建一个指向原数组的切片结构体,所以修改新切片的数据也会修改原切片。
  2. 字面量初始化切片 是在编译时完成的。
    1. 根据切片中的元素数量对底层数组的大小进行推断并创建一个数组
    2. 将这些字面量元素存储到初始化的数组中
    3. 创建一个同样指向 [3]int 类型的数组指针
    4. 将静态存储区的数组 vstat 赋值给 vauto 指针所在的地址
    5. 通过 [:] 操作获取一个底层使用 vauto 的切片
  3. 关键字 创建切片时,很多工作都需要运行时的参与。不仅会检查 len 是否传入,还会保证传入的容量 cap 一定大于或者等于 len。

当切片发生逃逸或者非常大时,运行时需要 runtime.makeslice 在堆上初始化切片,如果当前的切片不会发生逃逸并且切片非常小的时候,会直接使用下标得到得到数组对应的切片。

整形切片

1
var ints []int

slice 的元素要存在连续的内存中,也就是连续数组。 data 是底层数组的起始地址,这里只分配了切片结构没有分配底层数组,此时 data = nilLen 和 Cap 都为零。

1
2
3
var arr []int = make([]int, 2, 5)
arr = append(arr, 1)
arr[0] = 1

通过 make 的方式定义变量,不仅会分配结构还会开辟一段内存作为它的底层数组。此时分配的值都为 0。通过 append 之后此时索引 2 的位置被修改为 1,通过索引下标 0 修改后第一个元素为 1, 其他位置还是默认值 0。

字符串类型切片

1
2
3
4
5
6
arr := new([]string)

*arr = append(*arr, "kevin")
// append(arr, "kevin") 会报错
// invalid argument: arr (variable of type *[]string) is not a slicecompiler
// 因为 new 返回的是指针起始地址

上面 new 一个 slice 对象同样会分配切片的三部分,它不负责底层数组的分配,new 的返回值是 slice 的指针地址,如果这时候 (*arr)[0] = "kevin" 通过下标修改切片内容是不允许的,此时可以通过 append 进行分配底层数组。

和字符串相关的底层数组

底层数组是相同类型的元素一个挨一个的存储,不同的slice 可以关联到同一个数组。slice 的 data 起始指针并不一定指向数组的开头,如下例:

1
2
3
4
arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

var s1 = arr[1:4]
var s2 = arr[7:]

s1 的元素是 arr 索引 1到4 的元素,左闭又开,长度是 3,但是容量 Cap 是从索引 1 开始到末尾 为 9,s2 的元素是从 索引 7 到末尾,总共三个元素容量 Cap 也是 3。slice 访问和修改的都是底层数组的元素。

1
s1[3] = 5

上面 s1 就会越界产生 panic,只能扩大读写区间范围。此时如果给 s2 添加元素 s2 = append(s2, 10) 会开辟新数组,原来的元素要拷过来同时添加新元素,元素个数改为 4,容量扩到 6。

扩容规则

如果 append 返回的新切片不需要赋值回原有的变量,就会 makeslice 创建一个新的 slice;
如果使用 slice = append(slice, 1, 2, 3) 语句,那么 append 后的切片会覆盖原切片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// append(slice, 1, 2, 3)
ptr, len, cap := slice
newlen := len + 3
if newlen > cap {
    ...
}
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
return makeslice(ptr, newlen, cap)

// slice = append(slice, 1, 2, 3)
a := &slice
... // 同上
if uint(newlen) > uint(cap) {
   *a.cap = newcap
   *a.ptr = newptr
}
newlen = len + 3
*a.len = newlen
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3

预估容量

1
2
arr := []int{1, 2}
arr = append(arr, 3, 4, 5)

上面扩容后容量到 5,因为整形元素占有 8 字节,根据内存规格匹配到 48 。下面分析为什么?

在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:

  1. 如果期望容量大于当前容量的两倍就会使用期望容量(也就是当前容量翻倍)
  2. 如果当前切片的长度小于 1024 就会将容量翻倍
  3. 如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量

内存分配

内存空间=切片中元素大小×目标容量

语言的内存管理模块会提前向操作系统申请一批内存,分成常用规格管理起来。当申请内存时会匹配合适的规格进行分配。

1
2
3
4
5
6
7
8
9
10
var class_to_size = [_NumSizeClasses]uint16{
    0,
    8,
    16,
    32,
    48,
    64,
    80,
    ...,
}

例子如下:

1
2
a := []string{"my", "name", "is"}
a = append(a, "kevin")
  1. 字符串在 64 位机器上每个元素占 16 字节,扩容前容量是 3,添加一个最少扩容到4,原容量翻倍等于 6 大于 4,小于1024,直接翻倍预估容量为 6。
  2. 预估容量元素大小(616=96byte)
  3. 匹配到内存规格 96 所以最终扩容后容量为 6

切片 copy

无论是编译期间拷贝还是运行时拷贝,两种拷贝方式都会通过 runtime.memmove 将整块内存的内容拷贝到目标的内存区域中。

Classic problem

1
s2 := s1[2:6:7]

长度 s2 从 s1 的索引2(闭区间)到索引6(开区间,元素真正取到索引5),容量到索引7(开区间,真正到索引6),为5。

Go 提供的语法糖 ...,可以将 slice 传进可变函数,不会创建新的切片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func main() {
  slice := make([]int, 5, 5)
  slice[0] = 1
  slice[1] = 2

  // 此处因为 append 操作底层数组发生了扩容,原 slice 的底层数组不会改变
  change(slice...)
  fmt.Println(slice) // [1, 2, 0, 0, 0]

  // 此处 [0:2] 截取获得一个新切片,长度为 2, 容量为 5
  // 在 append 操作中原切片底层数据容量足够,不会发生扩容,影响到原切片的长度
  change(slice[0:2]...)
  fmt.Println(slice) // [1, 2, 3, 0, 0]
}

func change(s ...int) {
  s = append(s, 3)
  fmt.Println(s)
}

Len and cap

Go 语言中的所有东西都是以值传递的。也就是说,一个函数总是得到一个被传递的东西的副本,就像有一个赋值语句将值赋给参数一样。

如果传过去的值是指向内存空间的地址,是可以对这块内存空间做修改的

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
  sl := make([]int, 0, 10)
  var appenFunc = func(s []int) {
    s = append(s, 10, 20, 30)
    fmt.Println(s)
  }
  fmt.Println(sl)
  appenFunc(sl)
  fmt.Println(sl)
  fmt.Println(sl[:10])

  fmt.Println(sl[:])
}

实质上在调用 appenFunc(sl) 函数时,实际上修改了底层所指向的数组,自然也就会发生变化,也就不难理解为什么 10, 20, 30 元素会出现了

常用的访问切片我们会用:

1
s[low : high]

注意这里是:lowhigh 。没有用 len cap 这种定性的词语,也就代表着这里的取值是可变的。

当是切片(slice)时,表达式 s[low : high] 中的 high最大的取值范围对应着切片的容量cap,不是单纯的长度len。因此调用 fmt.Println(sl[:10]) 时可以输出容量范围内的值,不会出现越界。 相对的 fmt.Println(sl[:]) 因为该切片 len 值为 0,没有指定最大索引值,high 则取 len 值,导致输出结果为空。

slice 的泄漏

1
2
3
4
5
6
7
8
9
10
11
var a []int

func f(b []int) []int {
	a = b[:2]
	return a
}

func main() {
    ...
}
// 切片 a 和 b 都共享着同一个底层数组(共享内存块),sliceB 包含全部所引用的字符。sliceA 只包含了 [:2],也就是 0 和 1 两个索引位的字符。

泄露的点 就在于虽然切片 b 已经在函数内结束了他的使命了,不再使用了。 但切片 a 还在使用,切片 a 和 切片 b 引用的是同一块底层数组(共享内存块)

虽然切片 a 只有底层数组中 0 和 1 两个索引位正在被使用,其余未使用的底层数组空间毫无作用。但由于正在被引用,他们也不会被 GC,因此造成了泄露。

解决办法

利用切片的特性。当切片的容量空间不足时,会重新申请一个新的底层数组来存储,让两者彻底分手

1
2
3
4
5
6
7
8
9
10
11
12
var a []int
var c []int    // 第三者

func f(b []int) []int {
 a = b[:2]
  
  // 新的切片 append 导致切片扩容
 c = append(c, b[:2]...)
 fmt.Printf("a: %p\nc: %p\nb: %p\n", &a[0], &c[0], &b[0])
  
 return a
}
This post is licensed under CC BY 4.0 by the author.

Go 常见面试题

Map