Go切片数组深度解析
Go 中的分片数组,实际上有点类似于Java中的ArrayList,是一个可以扩展的数组,但是Go中的切片由比较灵活,它和数组很像,也是基于数组,所以在了解Go切片前我们先了解下数组。
- Go 数组原理
- Go 切片的特性
- Go 切片的扩容
Go 数组原理
数组简单描述就由相同类型元素组成的数据结构, 在创建初期就确定了长度,是不可变的。
type Array struct {
Elem *Type // element type
Bound int64 // number of elements; <0 if unknown yet
}
func NewArray(elem *Type, bound int64) *Type {
if bound < 0 {
Fatalf("NewArray: invalid bound %v", bound)
}
t := New(TARRAY)
t.Extra = &Array{Elem: elem, Bound: bound}
t.SetNotInHeap(elem.NotInHeap())
return t
}
但是Go的数组类型又和C与Java的数组类型不一样,NewArray
用于创建一个数组,从源码中可以看出最后返回的是 &Array{}的指针,并不是第一个元素的指针,在Go中数组属于值类型,在进行传递时,采取的是值传递,通过拷贝整个数组。Go语言的数组是一种有序的struct。
- 数组的初始化
Go 语言的数组有两种不同的创建方式,一种是显示的初始化,一种是隐式的初始化。
arr1 := [3]int{1, 2, 3}
arr2 := [...]int{1, 2, 3}
注意一定是使用[...]T
进行创建,使用三个点的隐式创建,编译器会对数组的大小进行推导,只是Go提供的一种语法糖。
其次,Go中数组的类型,是由数值类型和长度两个一起确定的。[2]int 和 [3]int 不是同一个类型,不能进行传参和比较,把数组理解为类型和长度两个属性的结构体,其实就一目了然了。
- 数组的存储
Go中的数组属于值类型,通常应该存储于栈中,局部变量依然会根据逃逸分析确定存储栈还是堆中。
编译器对数组函数中做两种不同的优化:
- 当元素数量小于或者等于 4 个时,会直接将数组中的元素放置在栈上;
- 当元素数量大于 4 个时,会将数组中的元素放置到静态区并在运行时取出;
var arr [5]int
statictmp_0[0] = 1
statictmp_0[1] = 2
statictmp_0[2] = 3
statictmp_0[3] = 4
statictmp_0[4] = 5
arr = statictmp_0
在静态区完成赋值后复制到栈中。
总结起来,在不考虑逃逸分析的情况下,如果数组中元素的个数小于或者等于 4 个,那么所有的变量会直接在栈上初始化,如果数组元素大于 4 个,变量就会在静态存储区初始化然后拷贝到栈上。
切片的特性
由于数组是值类型,那么赋值和函数传参操作都会复制整个数组数据。
func main() {
arrayA := [2]int{100, 200}
var arrayB [2]int
arrayB = arrayA
fmt.Printf("arrayA : %p , %v\n", &arrayA, arrayA)
fmt.Printf("arrayB : %p , %v\n", &arrayB, arrayB)
testArray(arrayA)
}
func testArray(x [2]int) {
fmt.Printf("func Array : %p , %v\n", &x, x)
}
arrayA : 0xc0000160c0 , [100 200]
arrayB : 0xc0000160d0 , [100 200]
func Array : 0xc000016120 , [100 200]
不管是赋值或函数传参,地址都不一致,发生了拷贝。如果数组的数据较大,则会消耗掉大量内存。那么为了减少拷贝我们可以主动的传递指针呀。
func main() {
arrayA := [2]int{100, 200}
fmt.Printf("func Array : %p , %v\n", &arrayA, arrayA)
testArray(&arrayA)
arrayA = [2]int{0, 200}
}
func testArray(x *[2]int) {
fmt.Printf("func Array : %p , %v\n", x, *x)
}
func Array : 0xc0000160c0 , [100 200]
func Array : 0xc0000160c0 , [100 200]
地址是一样的,不过传指针会有一个弊端,从打印结果可以看到,指针地址都是同一个,万一原数组的指针指向更改了,那么函数里面的指针指向都会跟着更改。
func main() {
arrayA := [2]int{100, 200}
array := arrayA[:]
fmt.Printf("func Array : %p , %v\n", &arrayA, arrayA)
testArray(&array)
arrayA = [2]int{0, 200}
}
func testArray(x *[]int) {
fmt.Printf("func Array : %p , %v\n", x, *x)
}
func Array : 0xc0000160c0 , [100 200]
func Array : 0xc0000044c0 , [100 200]
同样的我们将数组转换为切片,通过传递切片,地址是不一样的,数组值相同。
切片是引用传递,所以它们不需要使用额外的内存并且比使用数组更有效率。
所以,切片属于引用类型。
- 切片的初始化
- 通过下标的方式获得数组或者切片的一部分;
slices := arr[:]
slices2 := arr[1:3]
通过这种方式可以将数组转换为切片。
- 使用字面量初始化新的切片;
slice := []int{1, 2, 3}
中间不加三个点就是切片,使用这种方式创建切片,实际上是先创建数组,然后再通过第一种方式创建。
- 使用关键字 make 创建切片:
slice := make([]int, 10)
使用make创建切片,就不光编译期了,make创建切片会涉及到运行期。1. 切片的大小和容量是否足够小;
切片是否发生了逃逸,最终在堆上初始化。如果切片小的话会先在栈或静态区进行创建。
- 切片的底层结构
struct Slice
{ // must not move anything
byte* array; // actual data
uintgo len; // number of elements
uintgo cap; // allocated number of elements
};
type slice struct {
array unsafe.Pointer
len int
cap int
}
切片有一个数组的指针,len是指切片的长度, cap指的是切片的容量。
arrayA := [5]int{100, 200,4,5,6}
array := arrayA[1:3]
array = append(array,1,3,5)
fmt.Printf("%v, %d, %d, %v\n",array,len(array),cap(array),&array)
[200 4 1 3 5], 5, 8, &[200 4 1 3 5]
cap是在初始化切片是生成的容量。
发现切片的结构体是数组的地址指针array unsafe.Pointer,而Go中数组的地址代表数组结构体的地址。
slice 中得到一块内存地址,&array[0]或者unsafe.Pointer(&array[0])。
也可以通过地址构造切片
var ptr unsafe.Pointer
var s1 = struct {
addr uintptr
len int
cap int
}{ptr, length, length}
s := *(*[]byte)(unsafe.Pointer(&s1))
- nil 切片 和 空切片
nil切片:指的unsafe.Pointer 为nil
空切片:
silce := make( []int , 0 )
slice := []int{ }
创建的指针不为空,len和cap为空
切片扩容
当一个切片的容量满了,就需要扩容了。怎么扩,策略是什么?
func growslice(et *_type, old slice, cap int) slice {
if raceenabled {
callerpc := getcallerpc(unsafe.Pointer(&et))
racereadrangepc(old.array, uintptr(old.len*int(et.size)), callerpc, funcPC(growslice))
}
if msanenabled {
msanread(old.array, uintptr(old.len*int(et.size)))
}
if et.size == 0 {
// 如果新要扩容的容量比原来的容量还要小,这代表要缩容了,那么可以直接报panic了。
if cap < old.cap {
panic(errorString("growslice: cap out of range"))
}
// 如果当前切片的大小为0,还调用了扩容方法,那么就新生成一个新的容量的切片返回。
return slice{unsafe.Pointer(&zerobase), old.len, cap}
}
// 这里就是扩容的策略
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.len < 1024 {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}
// 计算新的切片的容量,长度。
var lenmem, newlenmem, capmem uintptr
const ptrSize = unsafe.Sizeof((*byte)(nil))
switch et.size {
case 1:
lenmem = uintptr(old.len)
newlenmem = uintptr(cap)
capmem = roundupsize(uintptr(newcap))
newcap = int(capmem)
case ptrSize:
lenmem = uintptr(old.len) * ptrSize
newlenmem = uintptr(cap) * ptrSize
capmem = roundupsize(uintptr(newcap) * ptrSize)
newcap = int(capmem / ptrSize)
default:
lenmem = uintptr(old.len) * et.size
newlenmem = uintptr(cap) * et.size
capmem = roundupsize(uintptr(newcap) * et.size)
newcap = int(capmem / et.size)
}
// 判断非法的值,保证容量是在增加,并且容量不超过最大容量
if cap < old.cap || uintptr(newcap) > maxSliceCap(et.size) {
panic(errorString("growslice: cap out of range"))
}
var p unsafe.Pointer
if et.kind&kindNoPointers != 0 {
// 在老的切片后面继续扩充容量
p = mallocgc(capmem, nil, false)
// 将 lenmem 这个多个 bytes 从 old.array地址 拷贝到 p 的地址处
memmove(p, old.array, lenmem)
// 先将 P 地址加上新的容量得到新切片容量的地址,然后将新切片容量地址后面的 capmem-newlenmem 个 bytes 这块内存初始化。为之后继续 append() 操作腾出空间。
memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
} else {
// 重新申请新的数组给新切片
// 重新申请 capmen 这个大的内存地址,并且初始化为0值
p = mallocgc(capmem, et, true)
if !writeBarrier.enabled {
// 如果还不能打开写锁,那么只能把 lenmem 大小的 bytes 字节从 old.array 拷贝到 p 的地址处
memmove(p, old.array, lenmem)
} else {
// 循环拷贝老的切片的值
for i := uintptr(0); i < lenmem; i += et.size {
typedmemmove(et, add(p, i), add(old.array, i))
}
}
}
// 返回最终新切片,容量更新为最新扩容之后的容量
return slice{p, old.len, newcap}
}
- 首先判断,如果新申请容量(cap)大于2倍的旧容量(old.cap),最终容量(newcap)就是新申请的容量(cap)
- 否则判断,如果旧切片的长度小于1024,则最终容量(newcap)就是旧容量(old.cap)的两倍,即(newcap=doublecap)
- 否则判断,如果旧切片长度大于等于1024,则最终容量(newcap)从旧容量(old.cap)开始循环增加原来的 1/4,即(newcap=old.cap,for {newcap += newcap/4})直到最终容量(newcap)大于等于新申请的容量(cap),即(newcap >= cap)
- 如果最终容量(cap)计算值溢出,则最终容量(cap)就是新申请容量(cap)
如果原来数组切片的容量已经达到了最大值,再想扩容, Go 默认会先开一片内存区域,把原来的值拷贝过来,然后再执行 append() 操作。这种情况对现数组的地址和原数组地址不相同。
- 切片数组的拷贝
func main() {
array := []int{10, 20, 30, 40}
slice := make([]int, 6)
n := copy(slice, array)
fmt.Println(n,slice)
}
func main() {
slice := []int{10, 20, 30, 40}
for index, value := range slice {
fmt.Printf("value = %d , value-addr = %x , slice-addr = %x\n", value, &value, &slice[index])
}
}
value = 10 , value-addr = c4200aedf8 , slice-addr = c4200b0320
value = 20 , value-addr = c4200aedf8 , slice-addr = c4200b0328
value = 30 , value-addr = c4200aedf8 , slice-addr = c4200b0330
value = 40 , value-addr = c4200aedf8 , slice-addr = c4200b0338
从上面结果我们可以看到,如果用 range 的方式去遍历一个切片,拿到的 Value 其实是切片里面的值拷贝,即浅拷贝。所以每次打印 Value 的地址都不变。
由于 Value 是值拷贝的,并非引用传递,所以直接改 Value 是达不到更改原切片值的目的的,需要通过 &slice[index] 获取真实的地址。