2.4 切片的结构与内存管理

切片是我们日常使用比较多的一个结构,深入的了解它的结构对于我们提高程序性能也有比较大的帮助。

本节我们将针对切片底层结构、扩容机制、底层数组进行讲解。

本节代码存放目录为 lesson4

切片底层结构

我们在使用的时候发现切片与数组很相似,这是由于本身切片的底层其实就是由数组构成的。主要结构如下:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}
  • array:指向底层数组的指针。

  • len:长度,切片实际存储元素的数量。

  • cap:容量,切片最大可以存储元素的数量。

从上面的结构可以看出,切片的底层其实也是数组。

当新创建一个切片时,会同步创建一个底层数组,之后在array中存储底层数组的指针,那么在访问的时候,其实也是访问的最底层的这个数组。

那么切片结构本身在内存底层是怎么表示的呢?由于切片结构本身在底层是一个结构体,所以其实它的内存表示就是按照结构体的方式来进行的。

扩容机制

上文中我们提到了len长度与cap容量,长度是比较好理解的,这也是数组中的概念。

那么容量又是什么呢?首先我们回顾一个概念:数组长度是固定的,不可改变;切片的长度是可变的。

那么既然数组不可变化,切片的底层也是数组,又是怎么实现长度可变的呢?这就涉及到了容量的概念。

Go语言中,其实是 通过不断的创建底层新数组实现长度可变的。


我们举个例子,比如目前切片的长度是5,底层数组的长度也是5,接下来我需要通过append新添加一个元素,由于底层数组长度不可变,那么要怎么办呢?

Go语言中,这时候就会新创建一个数组,将之前的5个元素拿到新数组,同时将新添加的这个元素也放到新数组,之后更新切片结构的array指针指向新的数组。

这样操作是很便捷的,但是每次都创建新的数组,如果数据比较多的时候,这个开销也是不小的。

所以Go语言提出了容量的概念,也就是说:创建新数组的时候,多创建几个空位,那么之后再添加就不用重复的创建新数组了

我们可以通过下面的代码查看:

a := []int{1, 2, 3, 4, 5}
fmt.Printf("切片a长度: %d, 容量: %d\n", len(a), cap(a))
a = append(a, 6)
fmt.Printf("切片a长度: %d, 容量: %d\n", len(a), cap(a))

结果输出如下所示:

切片a长度: 5, 容量: 5
切片a长度: 6, 容量: 10

从上面的示例我们可以看到,当我们创建切片时,由于是知道元素数量的,所以第一次创建的时候容量就是5

新添加元素的时候,由于底层数组的长度只有5,是不够存储的,所以可以看到容量变成了10

我们可以验证一下,底层数组长度是否是10,代码如下:

hdr := (*reflect.SliceHeader)(unsafe.Pointer(&a))
arrayPtr := hdr.Data
array := (*[10]int)(unsafe.Pointer(arrayPtr))
fmt.Println("底层数组: ", array)

结果输出如下所示:

底层数组:  &[1 2 3 4 5 6 0 0 0 0]

从上面的输出我们可以看出,扩容后底层数组的长度就是10,后面的位置则是默认值,也就是还没有使用的。


除了上面说的,还有另一种特殊的情况。如下代码:

b := [5]int{1, 2, 3, 4, 5}
bs := b[1:4]
fmt.Printf("切片bs index 0: %d, 切片bs长度: %d, 容量: %d\n", bs[0], len(bs), cap(bs))

结果输出如下所示:

切片bs index 0: 2, 切片bs长度: 3, 容量: 4

在上面的代码中,我们从数组b中截取了部分形成了切片bs,最终输出的长度是3,容量是4

执行上面的代码,最终bs的元素是:2,3,4。那么如果根据上面说的,这时候创建了新切片,底层也会创建新的数组,那容量就应该是3,为什么会是4呢?

这是因为在这种情况的时候,切片其实并没有创建新的数组,而是指向数组b的索引1内存地址的,这时候从索引1以后一共有1,2,3,4四个位置,所以容量是4而不是3

这也就是容量的核心概念:切片起始位置到底层数组结束位置的长度。

扩容规则

上面我们讲到了扩容的机制,那么扩容的规则又是怎么样的呢?我们通过下面的代码看一下:

c := []int{1, 2, 3, 4, 5}
fmt.Printf("切片c长度: %d, 容量: %d\n", len(c), cap(c))
c = append(c, 6)
fmt.Printf("切片c长度: %d, 容量: %d\n", len(c), cap(c))
c = append(c, 7)
fmt.Printf("切片c长度: %d, 容量: %d\n", len(c), cap(c))
c = append(c, 8, 9, 10, 11)
fmt.Printf("切片c长度: %d, 容量: %d\n", len(c), cap(c))

结果输出如下所示:

切片c长度: 5, 容量: 5
切片c长度: 6, 容量: 10
切片c长度: 7, 容量: 10
切片c长度: 11, 容量: 20

从上面的结果我们可以看出,在容量不够的时候,都是将容量扩大到两倍

那么是否一直都是这样呢?如果一直这样的话容量就会变得特别大。我们接着看下面的代码:

for i := 0; i < 2000; i++ {
    s = append(s, i)
    capNew := cap(s)
    if capNew != capOld {
        fmt.Printf("扩容: 旧容量=%d, 新容量=%d\n", capOld, capNew)
        capOld = capNew
    }
}

结果输出如下所示:

go1.19
扩容: 旧容量=5, 新容量=10
扩容: 旧容量=10, 新容量=20
扩容: 旧容量=20, 新容量=40
扩容: 旧容量=40, 新容量=80
扩容: 旧容量=80, 新容量=160
扩容: 旧容量=160, 新容量=336
扩容: 旧容量=336, 新容量=672
扩容: 旧容量=672, 新容量=1184
扩容: 旧容量=1184, 新容量=1696
扩容: 旧容量=1696, 新容量=2384

go1.15
扩容: 旧容量=5, 新容量=10
扩容: 旧容量=10, 新容量=20
扩容: 旧容量=20, 新容量=40
扩容: 旧容量=40, 新容量=80
扩容: 旧容量=80, 新容量=160
扩容: 旧容量=160, 新容量=336
扩容: 旧容量=336, 新容量=672
扩容: 旧容量=672, 新容量=1360
扩容: 旧容量=1360, 新容量=1792
扩容: 旧容量=1792, 新容量=2304

从上面的输出我们可以发现,当容量增长到672以后,就没有按照2倍的规则进行了,同时使用不同的Go版本扩容规则也是不一样的。

如果再换不同的Go语言版本可能输出还会不一样,但是他们是有相同点的:在容量小于1024时,会按照2倍扩展;大于1024时,会根据内存占用、内存对齐等方式平滑扩容

也就是说,当前容量大于1024后,就不会按照2倍那样的去扩容了,而是会平滑的扩容,避免扩容过多。

基于扩容机制及规则,那么我们在使用切片的时候,就应该考虑到长度的计算,避免扩容太频繁造成内存的浪费。

小结

本节我们讲解了切片的底层结构、扩容机制及扩容规则,如果感兴趣的话,我们还可以再去看一下它的底层内存表示。

关于本节总结如下:

  • 切片的底层结构是数组

  • 切片通过创建新数组的方式实现长度可变

  • 切片扩容就是创建新数组

results matching ""

    No results matching ""