Go Slice 深入浅出

kenticny

今天我们来聊一聊 Go 语言中最常用的类型之一,切片类型 Slice。对于切片类型,有很多人会把切片和数组混淆在一起,甚至有人认为切片就是数组类型,毕竟在 Go 的开发中,数组相比于切片类型,出场率可太低了。虽然切片和数组是两个不同的类型,但是它们之间又有着千丝万缕的关系。如果对于它们的底层实现不了解,就会在使用时踩一些莫名其妙的坑。今天我们就从一个常见的“坑”来了解切片的原理。文本将主要分析 Go 语言切片的实现原理和使用时的注意事项,不会对切片的基本使用做过多描述,所以如果您没有 Go 语言基础,可以先了解下切片的基础用法。

首先我们先看一段代码:

1
2
3
4
5
6
7
8
func getFileIndex(filename string) ([]byte, error) {
// ... 省略业务代码...
fileBuf, err := ioutil.ReadFile(filename)
if err != nil {
return nil, err
}
return fileBuf[0:16], nil
}

这段代码首先读取了文件,然后将文件 Buffer 的前16位字符返回,大家可以看出这段代码有什么问题么?下面我们就针对 Go 语言中 Slice 的一些细节做下分析。

切片实现原理

在 Go 语言中切片和数组可以说是密不可分的,甚至有很多初学者对于切片和数组的区别都没有弄清楚。其实在 Go 语言中,切片是在数组的基础上构建的,所以我们要了解切片,必须得先了解数组。

和其他语言类似,Go 语言的数组也是一种固定长度,固定元素类型的线性结构;由于数组需要支持随机访问,所以需要在内存中的空间是连续的。

例如,一个长度为5的整型数组,我们使用 [5]int 来表示,其在内存中的形态如下:

数组内存结构

由于数组的长度是固定的,而在实际的使用场景中,我们很多时候都无法确定我们需要的数组长度,数组类型显得有点不灵活了;所以切片类型就应运而生了。

切片类型不需要声明长度,而且前面有说到切片是基于数组构建的,所以切片也具有了数组的所有特点,这就使得切片的使用非常的广泛。

例如,我们声明一个切片,包含5个元素:

1
v := []int{1, 2, 3, 4, 5}

这个切片在内存中的形态如下:

切片内存结构

通过上图我们可以看出切片中切片仅存储了长度和容量,以及指向底层引用数组的指针。

另外我们还了解,切片是可以通过下标切割来创建新的切片的,比如 s := v[1:3] , 那么这个操作创建的新切片在内存中是怎样的呢? 见下图:

切片切割

通过上图可以看出,通过切割创建的切片和原切片是共享底层数组的。也就是说,在这个例子中,当切片 v 或者切片 s 其中有一个的元素发生变化时,两个切片都会变化,因为这两个切片指向的是同一个底层数组。

这里要注意一个点,我们说的是“切片中的元素”发生变化,而不是切片本身发生变化,如果切片本身发生变化,那就意味着切片指向的底层数组也发生变化了,所以和原切片之间就没有关系了。

前面我们提到切片的一个特点,就是不需要指定长度,但同时切片又具有数组随机访问的特性,那么当切片中元素数量达到最大数量时,切片会怎么处理呢?

在 Go 语言中,使切片扩容最常见的方法常见是使用 append 函数,作用为向切片末端追加数据,当元素数量达到切片最大时,则会触发切片扩容的操作,这里我们来看下切片扩容时的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func growslice(et *_type, old slice, cap int) slice {
// ... 省略部分代码...

newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.cap < 1024 {
newcap = doublecap
} else {
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
if newcap <= 0 {
newcap = cap
}
}
}

// ... 省略部分代码 ...
}

这里仅列出了扩容容量计算的部分逻辑,全部的代码大家可以自行到 Go 源码库去看。简单说,默认情况下,切片扩容会按照当前容量的2倍进行扩容,但是当切片容量超过1024时,会按当前容量的25%进行扩容。

在计算好容量后,会按照扩容后的容量创建新的切片,然后将原切片的数据复制到新的切片中,这也和其他语言所谓的动态数组实现方式是类似的。

使用和踩坑

回到我们最开始提到的问题,我们再把代码贴一遍:

1
2
3
4
5
6
7
8
func getFileIndex(filename string) ([]byte, error) {
// ... 省略业务代码...
fileBuf, err := ioutil.ReadFile(filename)
if err != nil {
return nil, err
}
return fileBuf[0:16], nil
}

现在大家可以这段代码里面的问题么?

在这段代码里面,每次都读出了全部的文件,然后通过切片切割的方式返回的前16个byte,回想我们之前介绍的切片在内存中的形态,切片是指向底层数组的,也就是说这里的虽然我们只返回了16个byte,但是这16个byte却是底层数组的引用。所以在这16个字节释放之前,底层数组所占用的空间是不会被GC回收的。简单说我们为了16个byte而导致整个文件无法被释放,导致内存泄露的问题。

参照我们真实场景的生产环境,大概会有百万级别的文件,文件平均大小在5M左右,所以大家可以设想下,如果所有文件都通过这种方式读出16个byte,那么真实占用的内存…… 想想都可怕。所以大家平时开发时要警惕这个问题的发生。

那么我们要如何解决这种问题呢?其实很简单,我们只需要将这16个字节重新复制给一个新的切片就可以了:

1
2
3
4
5
6
7
8
func getFileIndex(filename string) ([]byte, error) {
// ... 省略业务代码...
fileBuf, err := ioutil.ReadFile(filename)
// ... 省略
s := make([]byte, 16)
copy(s, fileBuf[0:16])
return s, nil
}

至此本文对于 Go 语言切片原理和问题的分析就全部结束了,至于切片的其他特性的实现原理,大家也可以自行阅读源码。切片作为在 Go 语言开发中最常用的类型之一,了解了它的工作原理以后,对于它的使用会更加得心应手。

  • 本文标题:Go Slice 深入浅出
  • 本文作者:kenticny
  • 创建时间:2020-04-06 00:00:39
  • 本文链接:https://luyun.io/2020/04/06/go-slice-overview/
  • 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
 评论
此页目录
Go Slice 深入浅出