深入學習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結構體

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