1. 切片的結構
一個切片在運行時由指針、長度和容量三部分構成
指針指向切片元素對應的底層數組元素的地址;長度對應切片中元素的數目,長度不能超過容量;容量一般是從切片的開始位置到底層數組的結尾位置的長度
2. 切片的底層原理
在編譯時構建抽象語法樹階段會將切片構建為如下類型:
type Slice struct {
Elem *Type
}
編譯時使用NewSlice函數創建一個新的切片類型,並需要傳遞切片元素的類型。從中可以看出,切片元素的類型是在編譯期間確定的
2.1 切片的make初始化
在編譯時,對於字面量的重要優化是判斷變量應該被分配在棧區還是應該逃逸到堆區
如果make函數初始化了一個太大的切片,該切片就會逃逸到堆區;如果分配了一個比較小的切片,就會被分配到棧區
這個切片大小的臨界值默認為64KB(不確定后續是否會存在優化),因此make([]int64, 1023) 和 make([]int64, 1024) 是完全不同的內存布局
2.2 切片擴容原理
切片使用append函數添加元素,但不是使用了append就需要擴容
只要沒有超過當前分配的cap大小,就不會發生擴容
切片擴容的現象說明了go語言並不會在每次append時都進行擴容,也不會每增加一個元素就擴容一次,因為擴容涉及內存分配,將損害性能
append函數的核心在運行時調用了runtime/slice.go文件下的growslice函數:
func growslice(et *_type, old slice, cap int) slice {
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.len < 1024 {
newcap = doublecap
} else {
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
if newcap <= 0 {
newcap = cap
}
}
}
...
}
上面的代碼顯示了擴容的核心邏輯,golang中切片的擴容策略為:
- 如果申請的容量cap大於2倍舊容量old.cap,最終新的容量newcap為新申請的容量
- 如果舊的切片長度小於1024,則最終容量是舊容量的2倍
- 如果舊切片長度大於或等於1024,則最終容量從舊容量開始循環增加1/4,直到最終容量大於或等於新申請的容量為止
- 如果最終容量計算值溢出,即超過了int的最大范圍,則最終容量就是新申請的容量
為了內存對齊,申請的內存可能大於實際類型✖️容量大小
如果切片需要擴容,那么最后需要在堆區申請內存
擴容后的新切片不一定擁有新的地址,因此在使用append函數時,通常會采用 a = append(a, T) 的方式
當切片類型不是指針,分配內存后只需要將內存后面的值清空
當切片類型為指針,設計垃圾回收寫屏障開啟時,對舊切片中的指針指向的對象進行標記
2.3 切片復制
復制的切片不會改變指向底層數組的數據源,但有些時候我們希望創建一個新的數組,並且與舊數組不共享相同的數據源,這時可以使用copy函數:
// 創建目標切片
numbers1 := make([]int, len(numbers), cap(numbers)*2)
// 將numbers元素復制到numbers1中
count := copy(numbers1, numbers)
當然切片元素也可以直接復制給一個數組,但是要考慮二者容量的問題
如果在復制時,數組長度和切片的長度不相等,那么復制的元素為len(arr)和len(slice)的較小值
copy函數在運行時主要調用了memmove函數,用於實現內存的復制
如果采用協程調用的方式go copy(arr,slice)或者加入了race檢測,則會轉而調用運行時slicestringcopy或者slicecopy函數,進行額外的檢查