菜鸟笔记
提升您的技术认知

原来go语言slice切片底层原理这么简单

前言

本篇博客不会贴出go的源码,只会告诉你slice用法,因为我们学习一项技术主要学的是编程的思想,眼花缭乱的源码千篇一律,深入人心的思想万里挑一,博客种通过图文的方式介绍底层机制,为的是能让开发人员写出属于自己的技术,而不是生搬硬套去复制别人的代码,相信等你理解了底层原理之后,你完全可以自己写一个动态数组出来,这就是我写博客的初心!

slice是什么

在go语言中,如果想要使用一个连续的内存空间,你可以使用数组,但是数组是固定长度的,无法做到动态扩容。因此切片slice就出现了,你可以先给切片设置一个初始容量,然后往里面添加元素,当切片容量不足时会自动扩容,来装载加入的元素;

用法

    // 初始化长度为5,容量为10的切片
	strings := make([]string, 5, 10)
	// 创建string类型的切片,长度为5,容量为5
	strings1 := make([]string, 5)
	
	// 添加元素
	strings = append(strings , "元素")
    // 修改元素
    strings[1] = "修改的元素"

删除元素

切片本身并没有提供删除的方法,但是我们可以自己实现一个

func main(){
  
    strings := make([]string, 0, 2)
	strings = append(strings, "one")
	strings = append(strings, "two")
	strings = append(strings, "three")
	strings = append(strings, "four")
	newStrings := deleteSlice(strings, "three")
	fmt.Println(newStrings)
}
/**
 * 删除指定切片的元素 
 * strings 原切片
 * 需要删除的元素
 */
func deleteSlice(strings []string, delStr string)  []string{
  
	newStrings := make([]string, 0, len(strings))
	// 删除元素 three--排除需要删除的元素,将其他元素赋值给新的切片
	for _, v := range strings {
  
		if v != delStr {
  
			newStrings = append(newStrings, v)
		}
	}
	fmt.Printf("删除后的元素%v \n", newStrings)
	fmt.Println(len(newStrings))
	return newStrings
}

切片底层用什么结构?

在开始的时候就有介绍过,切片属于动态数组,所以他的底层就是一个数组,在内存中是一段连续的内存空间

长度 和 容量的区别

切片的长度指的是已初始化元素长度大小,什么意思呢?比如下面这个语句

strings := make([]string, 5, 10)

这个语句表示切片的长度未5,容量为10,

  • 切片的容量指的就是底层数组的长度,容量为10,就表示底层数组的长度就是10
  • 切片的长度指的是已初始化的元素个数,从下图可以看到,前5个元素已经分配了内存空间,并赋予初始值 ""(空字符串),所以它的长度是5

可通过以下方式得出切片长度和容量

    strings := make([]string, 5, 10)
	fmt.Printf("长度:%d, 容量:%d",len(strings),cap(strings))

打印结果如下:

长度:5, 容量:10

扩容机制

以上了解了那么多,那么切片的在内部是如何进行扩容的呢?接下来就来揭晓它内部的秘密,首先我们声明一个切片,长度为1,容量为2,然后往这个容量中添加4次元素,看看有什么样的变化

strings := make([]string, 1, 2)
for i := 0; i < 4; i++ {
  
	strings = append(strings,"1")
	fmt.Printf("第%d次添加元素,长度:%d,容量:%d \n",i + 1,len(strings),cap(strings))
}
fmt.Println(strings)

控制台打印结果如下

第1次添加元素,长度:2,容量:2
第2次添加元素,长度:3,容量:4
第3次添加元素,长度:4,容量:4
第4次添加元素,长度:5,容量:8
[ 0 1 2 3 ]

扩容分析

通过以上案例,我们可以分解出每次操作后底层的数组都做了哪些事

0、创建切片

首先,当我们创建好切片后,长度为1,容量为2,长度为1就表示第0个元素已经初始化好了,已经赋值为空字符串了,此时结构如下图

1、第一次添加元素"0"

因为第0个下标的的位置已经被占用了,所以添加的元素就会往后面排,因此会先给下标1的元素先初始化,然后将"0"的值为放到1的位置上,此时大家会发现,容量已经满了,但是这时候还没触发扩容;

2、第二次添加元素"1"

在添加元素前,会先判断数组的容量是否足够,因为这时候容量已经满了,所以一定会触发扩容,扩容原理如下

  1. 创建一个新的数组,容量为原数组的2倍
  2. 将原数组的内容复制到新数组
  3. 将新添加的元素加入到新数组的后面
  4. 原数组因为没在使用,稍后gc会将其回收

扩容后的结构如下图 (白色部分表示未分配内存空间)

3、第三次添加元素"2"

这一步是正常的追加元素,追加到后面,和第一次添加元素时一样,容量又满了,但是还未触发扩容

4、第四次添加元素"3"

同样地,在添加元素前,会先判断数组的容量是否足够,因为这时候容量已经满了,所以会再次触发扩容

每次扩容都扩一倍吗?

不是的,,如果每次都扩一倍的话,将会占用大量的内存空间,而很有可能这些空间我们都用不到;所以go语言为了防止数组冗余,做了一些处理

  • 当数组容量小于1024时,每次扩容一倍
  • 当数组容量大于等于1024时,每次扩容0.25倍,也就是扩容四分之一的容量

多说无益,我们来测试下

strings := make([]string, 1, 2)
	for i := 0; i < 1024; i++ {
  
		strings = append(strings,fmt.Sprint(i))
	}
	fmt.Printf("经过1024次添加元素,长度:%d,容量:%d \n",len(strings),cap(strings))

打印结果如下,按照计算公式: 1024 * 1.25 = 1280,扩容后就是1280的容量

经过1024次添加元素,长度:1025,容量:1280

切片会缩容吗?

切片不会缩容,扩容后的数组空间,哪怕你不用,也是安安静静地占用着内存空间的,不会进行缩容;