本系列为探究golang中,基本类型的一些底层实现。一者是为了在面试中,能对答出来,增加面试通过的几率;一者,是为了彻底了解语言的底层实现,在今后使用的过程中,才能知道自己需要注意什么,在哪里怎么优化。
本章节,我们将讨论Go的切片的用法
首先,我们看看slice的底层数据结构:
数据结构
type SliceHeader struct {
Data uintptr //指向数组的指针
Len int //当前切片的长度
Cap int //当前切片的容量
}
Data
作为一个指针指向的数组是一片连续的内存空间。
这片内存空间可以用于存储切片中保存的全部元素,数组中的元素只是逻辑上的概念,底层存储其实都是连续的,所以我们可以将切片理解成一片连续的内存空间加上长度与容量的标识。
创建和初始化
runtime.makeslice
函数在堆上初始化,如果当前的切片不会发生逃逸并且切片非常小的时候,make([]int, 3, 4)
会被直接转换成如下所示的代码:
var arr [4]int
n := arr[:3]
上述代码会初始化数组并且直接通过下标 [:3]
来得到数组的切片,这两部分操作都会在编译阶段完成,编译器会在栈上或者静态存储区创建数组,[:3]
会被转换成上一节提到的 OpSliceMake
操作。
分析了主要由编译器处理的分支之后,我们回到用于创建切片的运行时函数 runtime.makeslice
,这个函数的实现非常简单:
func makeslice(et *_type, len, cap int) unsafe.Pointer {
mem, overflow := math.MulUintptr(et.size, uintptr(cap))
if overflow || mem > maxAlloc || len < 0 || len > cap {
mem, overflow := math.MulUintptr(et.size, uintptr(len))
if overflow || mem > maxAlloc || len < 0 {
panicmakeslicelen()
}
panicmakeslicecap()
}
return mallocgc(mem, et, true)
}
它的主要工作就是计算当前切片占用的内存空间并在堆上申请一片连续的内存,它使用如下的方式计算占用的内存:
内存空间 = 切片中元素大小 x 切片容量
虽然大多的错误都可以在编译期间被检查出来,但是在创建切片的过程中如果发生了以下错误就会直接导致程序触发运行时错误并崩溃:
- 内存空间的大小发生了溢出;
- 申请的内存大于最大可分配的内存;
- 传入的长度小于 0 或者长度大于容量;
mallocgc
就是用于申请内存的函数,这个函数的实现还是比较复杂,如果遇到了比较小的对象会直接初始化在 Go 语言调度器里面的 P 结构中,而大于 32 KB 的一些对象会在堆上初始化。
append追加元素
从一道面试题出发,如下:
package main
import "fmt"
情况1:
func main() {
slice := make([]int,2)
_ = append(slice,1,2,3)
fmt.Println(slice)
}
输出:[0 0]
-------------------------------------------
情况2:
func main() {
slice := make([]int,2)
slice = append(slice,1,2,3)
fmt.Println(slice)
}
输出:[0 0 1 2 3]
-------------------------------------------
func main() {
slice := make([]int, 2)
nums := append(slice, 1, 2, 3)
fmt.Println(slice, nums)
}
输出:[0 0] [0 0 1 2 3]
在 Go 语言中我们会使用 append
关键字向切片追加元素,那么,怎么会出现上面的情况呢?他的底层是怎么运行的?
追加元素会根据返回值是否会覆盖原变量,分别进入两种流程:
情况1:
如果
append
返回的『新切片』不需要赋值回原有的变量,就会进入如下的处理流程:
// append(slice, 1, 2, 3)
ptr, len, cap := slice //先对切片结构体进行解构获取它的数组指针、大小和容量
newlen := len + 3
if newlen > cap { //如果在追加元素后切片的大小大于容量
ptr, len, cap = growslice(slice, newlen) //调用 runtime.growslice 对切片进行扩容
newlen = len + 3
}
//将新的元素依次加入切片
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
return makeslice(ptr, newlen, cap)
情况2:
如果 append
后的切片会覆盖原切片,即 slice = append(slice, 1, 2, 3)
, cmd/compile/internal/gc.state.append
就会使用另一种方式改写关键字:
// slice = append(slice, 1, 2, 3)
a := &slice
ptr, len, cap := slice
newlen := len + 3
if uint(newlen) > uint(cap) {
newptr, len, newcap = growslice(slice, newlen)
vardef(a)
*a.cap = newcap
*a.ptr = newptr
}
newlen = len + 3
*a.len = newlen
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
是否覆盖原变量的逻辑其实差不多,最大的区别在于最后的结果是不是赋值回原有的变量。
如果我们选择覆盖原有的变量,也不需要担心切片的拷贝,因为 Go 语言的编译器已经对这种情况作了优化。
扩容
当切片的容量不足时就会调用 runtime.growslice
函数为切片扩容,扩容就是为切片分配一块新的内存空间并将原切片的元素全部拷贝过去。
在分配内存空间之前需要先确定新的切片容量,Go 语言根据切片的当前容量选择不同的策略进行扩容:
- 如果期望容量大于当前容量的两倍就会使用期望容量;
- 如果当前切片的长度小于 1024 就会将容量翻倍;
- 如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;
确定了切片的容量之后,就可以计算切片中新数组占用的内存了,计算的方法就是将目标容量和元素大小相乘,计算新容量时可能会发生溢出或者请求的内存超过上限,在这时就会直接 panic
。
面试问题:什么时候会发生拷贝?
即,在扩容的时候,会发生拷贝,分配新的内存空间,将原来的切片元素全部拷贝过去
runtime.growslice
函数最终会返回一个新的 slice
结构,其中包含了新的数组指针、大小和容量,这个返回的三元组最终会改变原有的切片,帮助 append
完成元素追加的功能。
我们看:
package main
import "fmt"
func main() {
slice := make([]int, 2)
nums := append(slice, 1, 2, 3)
fmt.Println(slice, nums)
slice[0] = 9
fmt.Println(slice, nums) //即,说明改变原来的切片,新的切片是不会改变的
}
输出:
[0 0] [0 0 1 2 3]
[9 0] [0 0 1 2 3]
上面的输出结果表明,改变原来的切片,新的切片是不会改变的。也就是说,在情况1
下,append返回的,是一个新的内存地址
Slice的动态变化
修改 slice 带来的影响
改变一个切片底层共享数组部分,将会影响到另一个切片。
func main() {
//创建一个切片slice
//长度为5,容量为5
slice := []int{10, 20, 30, 40, 50}
//创建一个新的切片
newSlice := slice[1:3] //长度为2,容量为4
fmt.Println(len(newSlice), cap(newSlice)) //2 4
//改变newSlice的下标1的值
//原始数据的下标2的值也会对于改变
newSlice[1] = 35
fmt.Println(slice,newSlice) //[10 20 35 40 50] [20 35]
}
上面的例子,35 分配给新切片之后,原始切片的元素也会发生改变。
使用 append() 时,切片的容量变化
func main() {
//创建一个切片slice
//长度为4,容量为4
slice := []int{10, 20, 30, 40}
fmt.Println(len(slice),cap(slice)) // Print 4 4
fmt.Println(&slice[0]) //0xc000050140
//append一个新值
//将值50分配给新元素。
newSlice := append(slice, 50)
fmt.Println(len(newSlice),cap(newSlice)) //Print 5 8
fmt.Println(&newSlice[0]) //0xc00006a080
}
分析:&slice[0]=0xc000050140,而&newSlice[0]=0xc00006a080,说明他们指向的不是同一个地址
也就是说,改变newSlice的值,原始切片slice的值是不会受影响的
执行 append() 操作后,原数组元素将会拷贝到新的切片,并且该数组的容量是其原始大小的两倍。
append() 操作增加底层数组容量会自动调整的,例如,当原 slice 容量小于 1024 的时候,新 slice 容量变成原来的 2 倍;原 slice 容量超过 1024,新 slice 容量变成原来的 1.25 倍。
扩容,即,为切片分配一块新的内存空间并将原切片的元素全部拷贝过去
在扩容后,改变新切片将不会影响到旧切片,因为两个切片的底层数组是不同的。
使用append()后,切片是否相同
不触发扩容的时候:
func main() {
//创建一个切片slice
//长度为4,容量为8
slice := make([]int, 4, 6)
for i := 0; i < len(slice); i++ {
slice[i] = i + 1
}
fmt.Println(&slice[0]) //输出0xc00000c300
//append一个新值
//将值100分配给新元素。
newSlice := append(slice, 5)
fmt.Println(&newSlice[0]) //输出0xc00000c300
newSlice[0] = 100
fmt.Println(slice, newSlice) //输出[100 2 3 4] [100 2 3 4 5]
}
这里的结果有点意思,我们发现:
slice
和newSlice
是不一样的,即,newSlice
是一个新的切片,但是他的内存地址和slice的内存地址(未改变部分)是一样的。这个,在下面的实验使用中,更加突出
触发扩容时:
func main() {
//创建一个切片slice
//长度为4,容量为8
slice := make([]int, 4, 6)
for i := 0; i < len(slice); i++ {
slice[i] = i + 1
}
fmt.Println(&slice[0]) //输出0xc00000c300
//append多个新值
//将值5, 6, 7, 分配给新元素,触发扩容
newSlice := append(slice, 5, 6, 7, 8)
fmt.Println(&newSlice[0]) //输出0xc00003c060
newSlice[0] = 100
fmt.Println(slice, newSlice) //输出[1 2 3 4] [100 2 3 4 5 6 7 8]
}
在扩容后的append,返回的是一个新切片,这个新切片指向的,是一个新开辟的一片连续的内存空间。
在函数中使用append
func main() {
//创建一个切片slice
//长度为4,容量为8
slice := make([]int, 4, 6)
for i := 0; i < len(slice); i++ {
slice[i] = i + 1
}
changeSlice(slice, 100)
fmt.Println(slice, len(slice), cap(slice)) //[1 2 3 4] 4 6
}
func changeSlice(slice []int, v int) {
slice = append(slice, v)
fmt.Println(slice, len(slice), cap(slice)) //[1 2 3 4 100] 5 6
}
从这里,我们发现,append()
返回的,是一个新的切片。
但是,这个新的切片,会根据是否扩容,而采取不同的措施:
- 不扩容时,会和原数组共用内存地址空间;
- 扩容时,会开辟一片新的内存地址空间,并且复制原数组到这个新的内存地址空间上去。
这一切,都和growslice
函数有关。
至于为什么经过函数append后,输出仍然是[1 2 3 4]
,这个可以理解成:
newSlice := []int{1, 2, 3, 4, 100}
slice := newSlice[:4]
也就是说,slice长度并没有改变,本来就无法访问比他更长的那段地址空间。这就好像下面的例子:
func main() {
slice := []int{1, 2, 3, 4}
newSlice := slice
newSlice = append(newSlice, 100)
fmt.Println(slice, newSlice)
}
输出:
[1 2 3 4] [1 2 3 4 100]
原来的数组也不会发生改变,因为append()返回的,是一个新的切片。
append()扩容的影响
func main() {
s := []int{5}
s = append(s, 7) //[5 7] 长度:2 容量:2
s = append(s, 9) //[5 7 9] 长度:3 容量:4
x := append(s, 11) //[5 7 9 11] 长度:4 容量:4
y := append(s, 12)
fmt.Println(s, x, y)
}
输出:
[5 7 9] [5 7 9 12] [5 7 9 12]
这个例子再次说明一个问题:append()返回的是一个新的切片,当不发生扩容时,会共用重复的地址空间。
比如,x
应该等于[5 7 9 11]
,但是,y
对s
进行append
后,不会触发扩容,所以,会有:
y := &s
ptr, len, cap := s
newlen := len + 1
*y.len = newlen
*(ptr+len) = 12
也就是说,会把相应的指针这向的地址的值改变了,也就相应的改变切片x
的值。