深入学习golang--slice

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
			}
		}
	}

可以看到扩容策略如下(权重按顺序依次递减):

  1. 如果新申请的容量大于2倍旧容量,则新容量就是申请容量大小
  2. 如果旧容量长度小于1024, 则新容量就是2倍旧容量大小
  3. 旧容量长度大于等于1024,则新容量就是旧容量的1.25
  4. 如果新容量的值都溢出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结构体

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章