go version go1.17.1 darwin/amd64
我们都知道golang中的切片(slice)是基于数组实现,如下图。
接下来我们就通过源码来看看slice是怎么把数组玩出花的
底层数据结构
// go/src/runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}
即array 就是上图黄色底层数组的地址,len--切片长度,cap--切片容量。也就是说:一个用户自定义的切片变量的底层数据结构,就是由上面slice的结构
我们通过下面代码验证, slice到底是不是用上面结构体保存的
slice1 := make([]int, 1, 10)
ptr := unsafe.Pointer(&slice1)
opt := (*[3]int)(ptr) // 上一步和这一步,可以把任意类型的变量,转换成指针数组
fmt.Println("slice1: ", opt[0], opt[1], opt[2])
// 输出 slice1: 824635484112 1 10
可以看出opt[0], opt[1], opt[2]就分别对应array, len, cap。其中array就是底层数组的地址(具体的值在每个人的电脑上会不一样)。
我们在看看下面的代码
slice1 := []int{1,2,3,4,5,6,7,8}
fmt.Printf("slice1 addr:%p; data1 addr:%p, data2 addr:%p\n", &slice1, &slice1[0], &slice1[1])
ptr := unsafe.Pointer(&slice1)
opt := (*[3]int)(ptr)
fmt.Println("struct data in slice1: ", opt[0], opt[1], opt[2])
fmt.Println("===")
slice2 := slice1[1:]
fmt.Printf("slice2 addr %p; data2 addr:%p\n", &slice2, &slice2[0])
ptr2 := unsafe.Pointer(&slice2)
opt2 := (*[3]int)(ptr2)
fmt.Println("struct data in slice2: ", opt2[0], opt2[1], opt2[2])
// 输出
// slice1 addr:0xc00035a7f8; data1 addr:0xc00035f200,data2 addr:0xc00035f208
// struct data in slice1: 824637256192 8 8
// ===
// slice2 addr 0xc00035a810; data2 addr:0xc00035f208
// struct data in slice2: 824637256200 7 7
可以看到变量slice2和slice1的地址是不一样的。但是slice2的第一个数据的地址是,slice1第二个数据的地址, 同时底层结构体slice的array实参也相差8字节。这不是巧合,恰恰说明了两个切片都是用的同一个底层数组,印证了上面那张图。
我们在来看看make([]int, 1, 10)即slice make的实现源码
// go/src/runtime/slice.go
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 {
// NOTE: Produce a 'len out of range' error instead of a
// 'cap out of range' error when someone does make([]T, bignumber).
// 'cap out of range' is true too, but since the cap is only being
// supplied implicitly, saying len is clearer.
// See golang.org/issue/4085.
mem, overflow := math.MulUintptr(et.size, uintptr(len))
if overflow || mem > maxAlloc || len < 0 {
panicmakeslicelen()
}
panicmakeslicecap()
}
return mallocgc(mem, et, true)
}
func makeslice64(et *_type, len64, cap64 int64) unsafe.Pointer {
len := int(len64)
if int64(len) != len64 {
panicmakeslicelen()
}
cap := int(cap64)
if int64(cap) != cap64 {
panicmakeslicecap()
}
return makeslice(et, len, cap)
}
当切片的容量和大小大于 int类型所能表达的最大值时,就会调用makeslice64 处理;反之则调用makeslice。
可以看到最终都是调用mallocgc开辟空间。而mallocgc上具体怎么开辟也有讲究,源码上面的备注如下:
// go/src/runtime/malloc.go
// Allocate an object of size bytes.
// Small objects are allocated from the per-P cache's free lists.
// Large objects (> 32 kB) are allocated straight from the heap.
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
}
由于怎么开辟空间不属于本文的范畴,有兴趣的朋友可以自己查阅源码。
操作的底层原理
我们每次调用append追加数据,都是依靠位于growslice
函数实现的。细节代码有所省略,感兴趣的读者可以看看源码,我们主要挑重点看
// go/src/runtime/slice.go
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 {
// 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
}
}
}
可以看到扩容策略如下(权重按顺序依次递减):
- 如果新申请的容量大于2倍旧容量,则新容量就是申请容量大小
- 如果旧容量长度小于1024, 则新容量就是2倍旧容量大小
- 旧容量长度大于等于1024,则新容量就是旧容量的1.25
- 如果新容量的值都溢出int的最大值,新容量就是申请容量
然后我们就来测试一下扩容策略,主要测试第二条和第三条
func TestAppend(t *testing.T) {
slice := make([]int, 0)
oldCap := cap(slice)
for i := 0; i < 4096; i++ {
slice = append(slice, i)
newCap := cap(slice)
if newCap != oldCap {
fmt.Printf("oldCap = %d after append %d newCap = %d\n", oldCap, i, newCap)
oldCap = newCap
}
}
}
执行这段测试用例后会如下输出:
oldCap = 0 after append 0 newCap = 1
oldCap = 1 after append 1 newCap = 2
oldCap = 2 after append 2 newCap = 4
oldCap = 4 after append 4 newCap = 8
oldCap = 8 after append 8 newCap = 16
oldCap = 16 after append 16 newCap = 32
oldCap = 32 after append 32 newCap = 64
oldCap = 64 after append 64 newCap = 128
oldCap = 128 after append 128 newCap = 256
oldCap = 256 after append 256 newCap = 512
oldCap = 512 after append 512 newCap = 1024
oldCap = 1024 after append 1024 newCap = 1280
oldCap = 1280 after append 1280 newCap = 1696
oldCap = 1696 after append 1696 newCap = 2304
oldCap = 2304 after append 2304 newCap = 3072
oldCap = 3072 after append 3072 newCap = 4096
我们可以看到,newCap = 1024扩容后的newCap = 1.25 * 1025 = 1280。但1280扩容后newCap=1696 != 1280 * 1.25,这是为什么呢?再来看下面的代码
var overflow bool
var lenmem, newlenmem, capmem uintptr
// Specialize for common values of et.size.
// For 1 we don't need any division/multiplication.
// For sys.PtrSize, compiler will optimize division/multiplication into a shift by a constant.
// For powers of 2, use a variable shift.
switch {
case et.size == 1:
lenmem = uintptr(old.len)
newlenmem = uintptr(cap)
capmem = roundupsize(uintptr(newcap))
overflow = uintptr(newcap) > maxAlloc
newcap = int(capmem)
case et.size == sys.PtrSize:
lenmem = uintptr(old.len) * sys.PtrSize
newlenmem = uintptr(cap) * sys.PtrSize
capmem = roundupsize(uintptr(newcap) * sys.PtrSize) // (1280 * 1.25) * 8 = 12800
overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
newcap = int(capmem / sys.PtrSize) // sys.PtrSize = 8
case isPowerOfTwo(et.size):
var shift uintptr
if sys.PtrSize == 8 {
// Mask shift for better code generation.
shift = uintptr(sys.Ctz64(uint64(et.size))) & 63
} else {
shift = uintptr(sys.Ctz32(uint32(et.size))) & 31
}
lenmem = uintptr(old.len) << shift
newlenmem = uintptr(cap) << shift
capmem = roundupsize(uintptr(newcap) << shift)
overflow = uintptr(newcap) > (maxAlloc >> shift)
newcap = int(capmem >> shift)
default:
lenmem = uintptr(old.len) * et.size
newlenmem = uintptr(cap) * et.size
capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
capmem = roundupsize(capmem)
newcap = int(capmem / et.size)
}
我们主要看 et.size == sys.PtrSize
的里面的逻辑 我们可以看到最底下的newcap依赖一个capmem的值,而capmem是roundupsize函数
的返回。我们再看看roundupsize
的源码
// size = 12800 (可以看上一块代码段,12800是如何算出来的)
func roundupsize(size uintptr) uintptr {
if size < _MaxSmallSize {
if size <= smallSizeMax-8 {
return uintptr(class_to_size[size_to_class8[divRoundUp(size, smallSizeDiv)]])
} else {
return uintptr(class_to_size[size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]])
// class_to_size, smallSizeMax, size_to_class128, largeSizeDiv都是常量。 所以很容易计算
// 返回13568
}
}
if size+_PageSize < size {
return size
}
return alignUp(size, _PageSize)
}
经过计算我们得知roundupsize函数
返回的值是13568,所以newcap = int(capmem / sys.PtrSize) = 13568 / 8
1696就是这么得出来的。可以看出当容量大于1024后扩容,扩充因子并不是1.25,而是略大。
最后我来看一段常规的append
代码
func TestAppend1(t *testing.T) {
slice := make([]int, 0, 2)
slice = append(slice, 1)
ptr := unsafe.Pointer(&slice)
opt := (*[3]int)(ptr)
fmt.Println("array addr: ", opt[0])
slice = append(slice, 2)
ptr = unsafe.Pointer(&slice)
opt = (*[3]int)(ptr)
fmt.Println("array addr: ", opt[0])
slice = append(slice, 3)
ptr = unsafe.Pointer(&slice)
opt = (*[3]int)(ptr)
fmt.Println("array addr: ", opt[0])
}
// 输出:
// array addr: 824634054360
// array addr: 824634054360
// array addr: 824638172096
可以看到当slice扩容后,底层的数组地址变了。具体的源码如下
var p unsafe.Pointer
if et.ptrdata == 0 {
// 在老的切片后面继续扩充容量
p = mallocgc(capmem, nil, false)
// The append() that calls growslice is going to overwrite from old.len to cap (which will be the new length).
// Only clear the part that will not be overwritten.
// 先将 P 地址加上新的容量得到新切片容量的地址,然后将新切片容量地址后面的 capmem-newlenmem 个 bytes 这块内存初始化。为之后继续 append() 操作腾出空间。
memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
} else {
// Note: can't use rawmem (which avoids zeroing of memory), because then GC can scan uninitialized memory.
// 重新申请新的数组给新切片
// 重新申请 capmen 这个大的内存地址,并且初始化为0值
p = mallocgc(capmem, et, true)
if lenmem > 0 && writeBarrier.enabled {
// Only shade the pointers in old.array since we know the destination slice p
// only contains nil pointers because it has been cleared during alloc.
bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem-et.size+et.ptrdata)
}
}
// 将 lenmem 这个多个 bytes 从 old.array地址 拷贝到 p 的地址处
memmove(p, old.array, lenmem)
return slice{p, old.len, newcap}
}
当 et.ptrdata == 0 的时候,这时候就直接扩底层数据的大小,而不会新建数据,然后将老数组的值迁移过去。 (但et.ptrdata == 0 具体是指的什么类型的切片,这点博主也有点懵,如果有知道的朋友请留言告知一下,感谢)
其余情况就是,新建数组,然后将老数据迁移过去。最后返回新的slice结构体