一文详解Go语言中切片的底层原理
作者:7small7
大家好,我是二条,在上一篇我们学习了轻松理解Go中的内存逃逸问题,今天接着我们学习Go中切片的相关知识。本文不会单独去讲解切片的基础语法,只会对切片的底层和在开发中需要注意的事项作分析。
在Go语言中,切片作为一种引用类型数据,相对数组而言是一种动态长度的数据类型,使用的场景也是非常多。但在使用切片的过程中,也有许多需要注意的事项。例如切片函数传值、切片动态扩容、切片对底层数组的引用问题等等。今天分享的主题,就是围绕切片进行。
切片的函数传值
切片作为一种引用数据类型,在作为函数传值时,如果函数内部对切片做了修改,会影响到原切片上。
package main import "fmt" func main() { sl1 := make([]int, 10) for i := 0; i < 10; i++ { } fmt.Println("切片sl1的值是", sl1) change(sl1) fmt.Println("切片sl2的值是", sl1) } func change(sl []int) { sl[0] = 100 fmt.Println("形参sl切片的值是", sl) }
打印上述代码:
切片sl1的值是 [1 2 3 4 5 6 7 8 9 10]
形参sl切片的值是 [100 2 3 4 5 6 7 8 9 10]
切片sl2的值是 [100 2 3 4 5 6 7 8 9 10]
通过上面的结果,不难看出来,在函数change()中修改了切片,原切片的小标0的值也发生了改变。这是因为切片是一种引用类型数据,在传递到函数change()时,使用的都是相同的底层数组(切片底层本质仍是一个数组
)。因此,底层数组的值改变了,就会影响到其他指向该数组的切片上。
针对上述的问题,有什么解决方案,使得传递切片,不会影响原切片的值呢?可以采用切片复制的方式,重新创建一个新的切片当做函数的参数进行传递。
package main import "fmt" func main() { sl1 := make([]int, 10) for i := 0; i < 10; i++ { sl1[i] = i + 1 } fmt.Println("切片sl1的值是", sl1) // 创建一个新的切片,当做参数传递。 sl2 := make([]int, 10) copy(sl2, sl1) change(sl2) fmt.Println("切片sl2的值是", sl1) } func change(sl []int) { sl[0] = 100 fmt.Println("形参sl切片的值是", sl) }
打印上述代码:
切片sl1的值是 [1 2 3 4 5 6 7 8 9 10]
形参sl切片的值是 [100 2 3 4 5 6 7 8 9 10]
切片sl2的值是 [1 2 3 4 5 6 7 8 9 10]
通过上述运行结果,在change函数中,对切片下标为0做了值修改,对切片sl1的值没有影响。
切片动态扩容机制
在Go中,切片是一种动态长度
的引用数据类型
。当切片的容量不足以容纳新增加的元素时,底层会实现自动扩容用来存储新添加的元素。先查看下面的一段实例代码,证明切片存在动态扩容。
package main import "fmt" func main() { var sl1 []int fmt.Println("切片sl1的长度是", len(sl1), ",容量是", cap(sl1)) for i := 0; i < 10; i++ { sl1 = append(sl1, i) fmt.Println("切片sl1的长度是", len(sl1), ",容量是", cap(sl1)) } }
打印上述代码:
切片sl1的长度是 0 ,容量是 0
切片sl1的长度是 1 ,容量是 1
切片sl1的长度是 2 ,容量是 2
切片sl1的长度是 3 ,容量是 4
切片sl1的长度是 4 ,容量是 4
切片sl1的长度是 5 ,容量是 8
切片sl1的长度是 6 ,容量是 8
切片sl1的长度是 7 ,容量是 8
切片sl1的长度是 8 ,容量是 8
切片sl1的长度是 9 ,容量是 16
切片sl1的长度是 10 ,容量是 16
可以看出,切片的长度是随着for操作,依次递增。但切片的容量就不是依次递增,从明面上看,有点像以2的倍数在增加。具体增加的规律是怎么样的呢?
要弄明白Go中的切片是如何实现扩容的,这就需要关注一下Go的版本。
在Go的1.18版本以前,是按照如下的规则来进行扩容:
1、如果原有切片的长度小于 1024,那么新的切片容量会直接扩展为原来的 2 倍。
2、如果原有切片的长度大于等于 1024,那么新的切片容量会扩展为原来的 1.25 倍,这一过程可能需要执行多次才能达到期望的容量。
3、如果切片属于第一种情况(长度小于 1024)并且需要扩容的容量小于 1024 字节,那么新的切片容量会直接增加到原来的长度加上需要扩容的容量(新容量=原容量+扩容容量)。
从Go的1.18版本开始,是按照如下的规则进行扩容:
1、当原slice容量(oldcap)小于256的时候,新slice(newcap)容量为原来的2倍。
2、原slice容量超过256,新slice容量newcap = oldcap + (oldcap+3*256) / 4
。
使用上面的代码,将循环的值调到非常大,例如10w,甚至更大,你会发现切片的容量和长度始终是比较趋近,而不是差距很大。
例如我将循环设置到100w,这里就只打印最后几行结果,不进行全部打印。
package main import "fmt" func main() { var sl1 []int fmt.Println("切片sl1的长度是", len(sl1), ",容量是", cap(sl1)) for i := 0; i < 1000000; i++ { sl1 = append(sl1, i) fmt.Println("切片sl1的长度是", len(sl1), ",容量是", cap(sl1)) } }
打印上述代码结果为:
.................
切片sl1的长度是 999990 ,容量是 1055744
切片sl1的长度是 999991 ,容量是 1055744
切片sl1的长度是 999992 ,容量是 1055744
切片sl1的长度是 999993 ,容量是 1055744
切片sl1的长度是 999994 ,容量是 1055744
切片sl1的长度是 999995 ,容量是 1055744
切片sl1的长度是 999996 ,容量是 1055744
切片sl1的长度是 999997 ,容量是 1055744
切片sl1的长度是 999998 ,容量是 1055744
切片sl1的长度是 999999 ,容量是 1055744
切片sl1的长度是 1000000 ,容量是 1055744
上面讲到的不同版本之间的规律,这个规律是怎么来的,我们可以直接源代码。
首先看1.18版本开始的底层代码,你需要找到Go的源码文件,路径为runtime/slice.go
,该文件中有一个名为growslice()
函数。这个函数的代码很长,我们重点关注下述代码,其他的代码除了做一些逻辑处理,还处理了内存对齐问题,关于内存对齐就不在本篇提及。
// type切片期望的类型,old旧切片,cap新切片期望最小的容量 func growslice(et *_type, old slice, cap int) slice { newcap := old.cap// 老切片容量 doublecap := newcap + newcap// 老切片容量的两倍 if cap > doublecap {// 期望最小的容量 > 老切片的两倍(新切片的容量 = 2 * 老切片的容量) newcap = cap } else { const threshold = 256 if old.cap < threshold { newcap = doublecap } else { for 0 < newcap && newcap < cap { // 在2倍增长以及1.25倍之间寻找一种相对平衡的规则 newcap += (newcap + 3*threshold) / 4 } if newcap <= 0 { newcap = cap } } } }
接着来看1.18版本之前的源代码,可以直接通过GitHub上进行查看。1.16GitHub源码地址。
// type切片期望的类型,old旧切片,cap新切片期望最小的容量 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 {// 小于1024,直接扩容为2倍 newcap = doublecap } else { // 原 slice 容量超过 1024,新 slice 容量变成原来的1.25倍 for 0 < newcap && newcap < cap { newcap += newcap / 4 } if newcap <= 0 { newcap = cap } } } }
通过上述的代码,已经总结出切片扩容的规律。如果你在实际的案例中,并非按照总结的规律进行扩容,这是因为切片扩容之后还考虑了内存对齐问题,也就是上述growslic()
函数剩余部分。
切片操作对数组的影响
在Go中,切片和数组有一些共性,也有一些不同之处。
相同之处:
1、切片和数组在定义时,需要指定内部的元素类型,一旦定义元素类型,就只能存储该类型的元素。
2、切片虽然是单独的一种类型,底层仍然是一个数组,在Go源码中,有这样一段定义,通过阅读这段代码,可以总结出切片底层是一个struct
数据结构。
type slice struct { array unsafe.Pointer # 指向底层数组的指针 len int # 切片的长度,也就是说当前切片中的元素个数 cap int # 切片的容量,也就是说切片最大能够存储多少个元素 }
不同之处:
1、切片和数组最大的不同之处,在于切片的长度和容量是动态的,可以根据实际情况实现动态扩容,而数组是固定长度,一经定义长度,存储的元素就不能超过定义时的长度。
下面有这样一种场景,需要特别注意。
从一个切片中生产新的切片,使用截取实现。
func clipSliceBySlice() { s := make([]int, 1000000) start := time.Now() _ = s[0:500000] elapsed := time.Since(start) fmt.Printf("Time taken to generate slice from slice: %s\n", elapsed) }
从一个切片中生成新的切片,使用copy()
函数实现。
func clipSliceByCopy() { s := make([]int, 1000000) start := time.Now() s2 := make([]int, 500000) copy(s2, s[0:500000]) elapsed := time.Since(start) fmt.Printf("Time taken to copy slice using copy() function: %s\n", elapsed) }
这两段代码,都是从一个切片中生成一个新的切片,但谁的性能效果更好呢?
1、第一种方式,生成新切片,底层仍然与原切片共用一个底层数组。在生成切片时,效率会更高一些。但存在一个问题,如果原切片和新切片对自身的元素做了修改,底层数组也会随着改变,这样会导致另外一个切片也跟着受影响。这种方式虽然效率更高,但是共用同一个底层数组,会存在数据安全问题。
2、第二种方式,生成新切片,使用的是copy()
函数实现,会发生一个内存拷贝。这样新切片就是存储在新的内存中,其底层的数组和原切片底层的数组,不在是共享。不管是老切片还是新切片内部元素发生变化,都只会影响到自身。这种方式虽然消耗的内存更大,但数据更加安全。
使用归纳
在实际的开发过程中,我们一般使用切片的场景要比数组多,这是为什么呢?
1、动态扩展:切片可以动态扩展或缩减,而数组的长度是固定的。使用切片可以更方便地处理不确定长度的数据集。
2、内存效率:切片的底层实现是数组,但是通过切片可以对底层的数组进行引用,避免了复制底层数据的开销。因此,使用切片可以更高效地处理大量数据。
3、零值初始化:切片有一个默认值为0的长度和容量,这使得初始化切片更加方便。
4、内置函数:切片有许多内置函数,如append()、copy()等,这些函数可以更方便地操作切片。
本文总结
根据上面的几个小问题进行演示,我们在日常开发中,使用切片重点可以关注在动态扩容
和引用传值
上面,这也是经常出现问题的点。下面细分几点进行归纳:
1、由于切片是引用类型,因此容易出现多个变量引用同一个底层数组,导致内存泄露和意外修改数据的情况。
2、当切片长度超过底层数组容量时,可以导致切片重新分配内存,这可能会带来性能问题。
3、在使用切片时没有正确计算长度和容量,也可能导致意料之外的结果。
4、切片常常被用作函数参数,由于其引用类型的特性,可能会导致函数内对切片数据的修改影响到外部变量。
5、如果切片的底层数组被修改,可能会对所有引用该底层数组的切片数据造成影响。
到此这篇关于一文详解Go语言中切片的底层原理的文章就介绍到这了,更多相关Go切片内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!