深入解析 Go 中 Slice 底層實現(xiàn)
切片是 Go 中的一種基本的數(shù)據(jù)結(jié)構,使用這種結(jié)構可以用來管理數(shù)據(jù)集合。切片的設計想法是由動態(tài)數(shù)組概念而來,為了開發(fā)者可以更加方便的使一個數(shù)據(jù)結(jié)構可以自動增加和減少。但是切片本身并不是動態(tài)數(shù)據(jù)或者數(shù)組指針。切片常見的操作有 reslice、append、copy。與此同時,切片還具有可索引,可迭代的優(yōu)秀特性。
一. 切片和數(shù)組
關于切片和數(shù)組怎么選擇?接下來好好討論討論這個問題。
在 Go 中,與 C 數(shù)組變量隱式作為指針使用不同,Go 數(shù)組是值類型,賦值和函數(shù)傳參操作都會復制整個數(shù)組數(shù)據(jù)。
- 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)
- }
打印結(jié)果:
- arrayA : 0xc4200bebf0 , [100 200]
- arrayB : 0xc4200bec00 , [100 200]
- func Array : 0xc4200bec30 , [100 200]
可以看到,三個內(nèi)存地址都不同,這也就驗證了 Go 中數(shù)組賦值和函數(shù)傳參都是值復制的。那這會導致什么問題呢?
假想每次傳參都用數(shù)組,那么每次數(shù)組都要被復制一遍。如果數(shù)組大小有 100萬,在64位機器上就需要花費大約 800W 字節(jié),即 8MB 內(nèi)存。這樣會消耗掉大量的內(nèi)存。于是乎有人想到,函數(shù)傳參用數(shù)組的指針。
- func main() {
- arrayA := []int{100, 200}
- testArrayPoint(&arrayA) // 1.傳數(shù)組指針
- arrayB := arrayA[:]
- testArrayPoint(&arrayB) // 2.傳切片
- fmt.Printf("arrayA : %p , %v\n", &arrayA, arrayA)
- }
- func testArrayPoint(x *[]int) {
- fmt.Printf("func Array : %p , %v\n", x, *x)
- (*x)[1] += 100
- }
打印結(jié)果:
- func Array : 0xc4200b0140 , [100 200]
- func Array : 0xc4200b0180 , [100 300]
- arrayA : 0xc4200b0140 , [100 400]
這也就證明了數(shù)組指針確實到達了我們想要的效果?,F(xiàn)在就算是傳入10億的數(shù)組,也只需要再棧上分配一個8個字節(jié)的內(nèi)存給指針就可以了。這樣更加高效的利用內(nèi)存,性能也比之前的好。
不過傳指針會有一個弊端,從打印結(jié)果可以看到,***行和第三行指針地址都是同一個,萬一原數(shù)組的指針指向更改了,那么函數(shù)里面的指針指向都會跟著更改。
切片的優(yōu)勢也就表現(xiàn)出來了。用切片傳數(shù)組參數(shù),既可以達到節(jié)約內(nèi)存的目的,也可以達到合理處理好共享內(nèi)存的問題。打印結(jié)果第二行就是切片,切片的指針和原來數(shù)組的指針是不同的。
由此我們可以得出結(jié)論:
把***個大數(shù)組傳遞給函數(shù)會消耗很多內(nèi)存,采用切片的方式傳參可以避免上述問題。切片是引用傳遞,所以它們不需要使用額外的內(nèi)存并且比使用數(shù)組更有效率。
但是,依舊有反例。
- package main
- import "testing"
- func array() [1024]int {
- var x [1024]int
- for i := 0; i < len(x); i++ {
- x[i] = i
- }
- return x
- }
- func slice() []int {
- x := make([]int, 1024)
- for i := 0; i < len(x); i++ {
- x[i] = i
- }
- return x
- }
- func BenchmarkArray(b *testing.B) {
- for i := 0; i < b.N; i++ {
- array()
- }
- }
- func BenchmarkSlice(b *testing.B) {
- for i := 0; i < b.N; i++ {
- slice()
- }
- }
我們做一次性能測試,并且禁用內(nèi)聯(lián)和優(yōu)化,來觀察切片的堆上內(nèi)存分配的情況。
- go test -bench . -benchmem -gcflags "-N -l"
輸出結(jié)果比較“令人意外”:
- BenchmarkArray-4 500000 3637 ns/op 0 B/op 0 alloc s/op
- BenchmarkSlice-4 300000 4055 ns/op 8192 B/op 1 alloc s/op
解釋一下上述結(jié)果,在測試 Array 的時候,用的是4核,循環(huán)次數(shù)是500000,平均每次執(zhí)行時間是3637 ns,每次執(zhí)行堆上分配內(nèi)存總量是0,分配次數(shù)也是0 。
而切片的結(jié)果就“差”一點,同樣也是用的是4核,循環(huán)次數(shù)是300000,平均每次執(zhí)行時間是4055 ns,但是每次執(zhí)行一次,堆上分配內(nèi)存總量是8192,分配次數(shù)也是1 。
這樣對比看來,并非所有時候都適合用切片代替數(shù)組,因為切片底層數(shù)組可能會在堆上分配內(nèi)存,而且小數(shù)組在棧上拷貝的消耗也未必比
make 消耗大。
二. 切片的數(shù)據(jù)結(jié)構
切片本身并不是動態(tài)數(shù)組或者數(shù)組指針。它內(nèi)部實現(xiàn)的數(shù)據(jù)結(jié)構通過指針引用底層數(shù)組,設定相關屬性將數(shù)據(jù)讀寫操作限定在指定的區(qū)域內(nèi)。切片本身是一個只讀對象,其工作機制類似數(shù)組指針的一種封裝。
切片(slice)是對數(shù)組一個連續(xù)片段的引用,所以切片是一個引用類型(因此更類似于 C/C++ 中的數(shù)組類型,或者 Python 中的 list 類型)。這個片段可以是整個數(shù)組,或者是由起始和終止索引標識的一些項的子集。需要注意的是,終止索引標識的項不包括在切片內(nèi)。切片提供了一個與指向數(shù)組的動態(tài)窗口。
給定項的切片索引可能比相關數(shù)組的相同元素的索引小。和數(shù)組不同的是,切片的長度可以在運行時修改,最小為 0 ***為相關數(shù)組的長度:切片是一個長度可變的數(shù)組。
Slice 的數(shù)據(jù)結(jié)構定義如下:
- type slice struct {
- array unsafe.Pointer
- len int
- cap int
- }
切片的結(jié)構體由3部分構成,Pointer 是指向一個數(shù)組的指針,len 代表當前切片的長度,cap 是當前切片的容量。cap 總是大于等于 len 的。
如果想從 slice 中得到一塊內(nèi)存地址,可以這樣做:
- s := make([]byte, 200)
- ptr := unsafe.Pointer(&s[0])
如果反過來呢?從 Go 的內(nèi)存地址中構造一個 slice。
- var ptr unsafe.Pointer
- var s1 = struct {
- addr uintptr
- len int
- cap int
- }{ptr, length, length}
- s := *(*[]byte)(unsafe.Pointer(&s1))
構造一個虛擬的結(jié)構體,把 slice 的數(shù)據(jù)結(jié)構拼出來。
當然還有更加直接的方法,在 Go 的反射中就存在一個與之對應的數(shù)據(jù)結(jié)構 SliceHeader,我們可以用它來構造一個 slice
- var o []byte
- sliceHeader := (*reflect.SliceHeader)((unsafe.Pointer(&o)))
- sliceHeader.Cap = length
- sliceHeader.Len = length
- sliceHeader.Data = uintptr(ptr)
三. 創(chuàng)建切片
make 函數(shù)允許在運行期動態(tài)指定數(shù)組長度,繞開了數(shù)組類型必須使用編譯期常量的限制。
創(chuàng)建切片有兩種形式,make 創(chuàng)建切片,空切片。
1. make 和切片字面量
- func makeslice(et *_type, len, cap int) slice {
- // 根據(jù)切片的數(shù)據(jù)類型,獲取切片的***容量
- maxElements := maxSliceCap(et.size)
- // 比較切片的長度,長度值域應該在[0,maxElements]之間
- if len < 0 || uintptr(len) > maxElements {
- panic(errorString("makeslice: len out of range"))
- }
- // 比較切片的容量,容量值域應該在[len,maxElements]之間
- if cap < len || uintptr(cap) > maxElements {
- panic(errorString("makeslice: cap out of range"))
- }
- // 根據(jù)切片的容量申請內(nèi)存
- p := mallocgc(et.size*uintptr(cap), et, true)
- // 返回申請好內(nèi)存的切片的首地址
- return slice{p, len, cap}
- }
還有一個 int64 的版本:
- func makeslice64(et *_type, len64, cap64 int64) slice {
- len := int(len64)
- if int64(len) != len64 {
- panic(errorString("makeslice: len out of range"))
- }
- cap := int(cap64)
- if int64(cap) != cap64 {
- panic(errorString("makeslice: cap out of range"))
- }
- return makeslice(et, len, cap)
- }
實現(xiàn)原理和上面的是一樣的,只不過多了把 int64 轉(zhuǎn)換成 int 這一步罷了。
上圖是用 make 函數(shù)創(chuàng)建的一個 len = 4, cap = 6 的切片。內(nèi)存空間申請了6個 int 類型的內(nèi)存大小。由于 len = 4,所以后面2個暫時訪問不到,但是容量還是在的。這時候數(shù)組里面每個變量都是0 。
除了 make 函數(shù)可以創(chuàng)建切片以外,字面量也可以創(chuàng)建切片。
這里是用字面量創(chuàng)建的一個 len = 6,cap = 6 的切片,這時候數(shù)組里面每個元素的值都初始化完成了。需要注意的是 [ ] 里面不要寫數(shù)組的容量,因為如果寫了個數(shù)以后就是數(shù)組了,而不是切片了。
還有一種簡單的字面量創(chuàng)建切片的方法。如上圖。上圖就 Slice A 創(chuàng)建出了一個 len = 3,cap = 3 的切片。從原數(shù)組的第二位元素(0是***位)開始切,一直切到第四位為止(不包括第五位)。同理,Slice B 創(chuàng)建出了一個 len = 2,cap = 4 的切片。
2. nil 和空切片
nil 切片和空切片也是常用的。
- var slice []int
nil 切片被用在很多標準庫和內(nèi)置函數(shù)中,描述一個不存在的切片的時候,就需要用到 nil 切片。比如函數(shù)在發(fā)生異常的時候,返回的切片就是 nil 切片。nil 切片的指針指向 nil。
空切片一般會用來表示一個空的集合。比如數(shù)據(jù)庫查詢,一條結(jié)果也沒有查到,那么就可以返回一個空切片。
- silce := make( []int , 0 )
- slice := []int{ }
空切片和 nil 切片的區(qū)別在于,空切片指向的地址不是nil,指向的是一個內(nèi)存地址,但是它沒有分配任何內(nèi)存空間,即底層元素包含0個元素。
***需要說明的一點是。不管是使用 nil 切片還是空切片,對其調(diào)用內(nèi)置函數(shù) append,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,還調(diào)用了擴容方法,那么就新生成一個新的容量的切片返回。
- 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 {
- for newcap < cap {
- newcap += newcap / 4
- }
- }
- }
- // 計算新的切片的容量,長度。
- 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 {
- // 在老的切片后面繼續(xù)擴充容量
- p = mallocgc(capmem, nil, false)
- // 將 lenmem 這個多個 bytes 從 old.array地址 拷貝到 p 的地址處
- memmove(p, old.array, lenmem)
- // 先將 P 地址加上新的容量得到新切片容量的地址,然后將新切片容量地址后面的 capmem-newlenmem 個 bytes 這塊內(nèi)存初始化。為之后繼續(xù) append() 操作騰出空間。
- memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
- } else {
- // 重新申請新的數(shù)組給新切片
- // 重新申請 capmen 這個大的內(nèi)存地址,并且初始化為0值
- p = mallocgc(capmem, et, true)
- if !writeBarrier.enabled {
- // 如果還不能打開寫鎖,那么只能把 lenmem 大小的 bytes 字節(jié)從 old.array 拷貝到 p 的地址處
- memmove(p, old.array, lenmem)
- } else {
- // 循環(huán)拷貝老的切片的值
- for i := uintptr(0); i < lenmem; i += et.size {
- typedmemmove(et, add(p, i), add(old.array, i))
- }
- }
- }
- // 返回最終新切片,容量更新為***擴容之后的容量
- return slice{p, old.len, newcap}
- }
上述就是擴容的實現(xiàn)。主要需要關注的有兩點,一個是擴容時候的策略,還有一個就是擴容是生成全新的內(nèi)存地址還是在原來的地址后追加。
1. 擴容策略
先看看擴容策略。
- func main() {
- slice := []int{10, 20, 30, 40}
- newSlice := append(slice, 50)
- fmt.Printf("Before slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice))
- fmt.Printf("Before newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice))
- newSlice[1] += 10
- fmt.Printf("After slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice))
- fmt.Printf("After newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice))
- }
輸出結(jié)果:
- Before slice = [10 20 30 40], Pointer = 0xc4200b0140, len = 4, cap = 4
- Before newSlice = [10 20 30 40 50], Pointer = 0xc4200b0180, len = 5, cap = 8
- After slice = [10 20 30 40], Pointer = 0xc4200b0140, len = 4, cap = 4
- After newSlice = [10 30 30 40 50], Pointer = 0xc4200b0180, len = 5, cap = 8
用圖表示出上述過程。
從圖上我們可以很容易的看出,新的切片和之前的切片已經(jīng)不同了,因為新的切片更改了一個值,并沒有影響到原來的數(shù)組,新切片指向的數(shù)組是一個全新的數(shù)組。并且 cap 容量也發(fā)生了變化。這之間究竟發(fā)生了什么呢?
Go 中切片擴容的策略是這樣的:
如果切片的容量小于 1024 個元素,于是擴容的時候就翻倍增加容量。上面那個例子也驗證了這一情況,總?cè)萘繌脑瓉淼?個翻倍到現(xiàn)在的8個。
一旦元素個數(shù)超過 1024 個元素,那么增長因子就變成 1.25 ,即每次增加原來容量的四分之一。
注意:擴容擴大的容量都是針對原來的容量而言的,而不是針對原來數(shù)組的長度而言的。
2. 新數(shù)組 or 老數(shù)組 ?
再談談擴容之后的數(shù)組一定是新的么?這個不一定,分兩種情況。
情況一:
- func main() {
- array := [4]int{10, 20, 30, 40}
- slice := array[0:2]
- newSlice := append(slice, 50)
- fmt.Printf("Before slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice))
- fmt.Printf("Before newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice))
- newSlice[1] += 10
- fmt.Printf("After slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice))
- fmt.Printf("After newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice))
- fmt.Printf("After array = %v\n", array)
- }
打印輸出:
- Before slice = [10 20], Pointer = 0xc4200c0040, len = 2, cap = 4
- Before newSlice = [10 20 50], Pointer = 0xc4200c0060, len = 3, cap = 4
- After slice = [10 30], Pointer = 0xc4200c0040, len = 2, cap = 4
- After newSlice = [10 30 50], Pointer = 0xc4200c0060, len = 3, cap = 4
- After array = [10 30 50 40]
把上述過程用圖表示出來,如下圖。
通過打印的結(jié)果,我們可以看到,在這種情況下,擴容以后并沒有新建一個新的數(shù)組,擴容前后的數(shù)組都是同一個,這也就導致了新的切片修改了一個值,也影響到了老的切片了。并且 append() 操作也改變了原來數(shù)組里面的值。一個 append() 操作影響了這么多地方,如果原數(shù)組上有多個切片,那么這些切片都會被影響!無意間就產(chǎn)生了莫名的 bug!
這種情況,由于原數(shù)組還有容量可以擴容,所以執(zhí)行 append() 操作以后,會在原數(shù)組上直接操作,所以這種情況下,擴容以后的數(shù)組還是指向原來的數(shù)組。
這種情況也極容易出現(xiàn)在字面量創(chuàng)建切片時候,第三個參數(shù) cap 傳值的時候,如果用字面量創(chuàng)建切片,cap 并不等于指向數(shù)組的總?cè)萘?,那么這種情況就會發(fā)生。
- slice := array[1:2:3]
上面這種情況非常危險,極度容易產(chǎn)生 bug 。
建議用字面量創(chuàng)建切片的時候,cap 的值一定要保持清醒,避免共享原數(shù)組導致的 bug。
情況二:
情況二其實就是在擴容策略里面舉的例子,在那個例子中之所以生成了新的切片,是因為原來數(shù)組的容量已經(jīng)達到了***值,再想擴容, Go 默認會先開一片內(nèi)存區(qū)域,把原來的值拷貝過來,然后再執(zhí)行 append() 操作。這種情況絲毫不影響原數(shù)組。
所以建議盡量避免情況一,盡量使用情況二,避免 bug 產(chǎn)生。
五. 切片拷貝
Slice 中拷貝方法有2個。
- func slicecopy(to, fm slice, width uintptr) int {
- // 如果源切片或者目標切片有一個長度為0,那么就不需要拷貝,直接 return
- if fm.len == 0 || to.len == 0 {
- return 0
- }
- // n 記錄下源切片或者目標切片較短的那一個的長度
- n := fm.len
- if to.len < n {
- n = to.len
- }
- // 如果入?yún)?nbsp;width = 0,也不需要拷貝了,返回較短的切片的長度
- if width == 0 {
- return n
- }
- // 如果開啟了競爭檢測
- if raceenabled {
- callerpc := getcallerpc(unsafe.Pointer(&to))
- pc := funcPC(slicecopy)
- racewriterangepc(to.array, uintptr(n*int(width)), callerpc, pc)
- racereadrangepc(fm.array, uintptr(n*int(width)), callerpc, pc)
- }
- // 如果開啟了 The memory sanitizer (msan)
- if msanenabled {
- msanwrite(to.array, uintptr(n*int(width)))
- msanread(fm.array, uintptr(n*int(width)))
- }
- size := uintptr(n) * width
- if size == 1 {
- // TODO: is this still worth it with new memmove impl?
- // 如果只有一個元素,那么指針直接轉(zhuǎn)換即可
- *(*byte)(to.array) = *(*byte)(fm.array) // known to be a byte pointer
- } else {
- // 如果不止一個元素,那么就把 size 個 bytes 從 fm.array 地址開始,拷貝到 to.array 地址之后
- memmove(to.array, fm.array, size)
- }
- return n
- }
在這個方法中,slicecopy 方法會把源切片值(即 fm Slice )中的元素復制到目標切片(即 to Slice )中,并返回被復制的元素個數(shù),copy 的兩個類型必須一致。slicecopy 方法最終的復制結(jié)果取決于較短的那個切片,當較短的切片復制完成,整個復制過程就全部完成了。
舉個例子,比如:
- func main() {
- array := []int{10, 20, 30, 40}
- slice := make([]int, 6)
- n := copy(slice, array)
- fmt.Println(n,slice)
- }
還有一個拷貝的方法,這個方法原理和 slicecopy 方法類似,不在贅述了,注釋寫在代碼里面了。
- func slicestringcopy(to []byte, fm string) int {
- // 如果源切片或者目標切片有一個長度為0,那么就不需要拷貝,直接 return
- if len(fm) == 0 || len(to) == 0 {
- return 0
- }
- // n 記錄下源切片或者目標切片較短的那一個的長度
- n := len(fm)
- if len(to) < n {
- n = len(to)
- }
- // 如果開啟了競爭檢測
- if raceenabled {
- callerpc := getcallerpc(unsafe.Pointer(&to))
- pc := funcPC(slicestringcopy)
- racewriterangepc(unsafe.Pointer(&to[0]), uintptr(n), callerpc, pc)
- }
- // 如果開啟了 The memory sanitizer (msan)
- if msanenabled {
- msanwrite(unsafe.Pointer(&to[0]), uintptr(n))
- }
- // 拷貝字符串至字節(jié)數(shù)組
- memmove(unsafe.Pointer(&to[0]), stringStructOf(&fm).str, uintptr(n))
- return n
- }
再舉個例子,比如:
- func main() {
- slice := make([]byte, 3)
- n := copy(slice, "abcdef")
- fmt.Println(n,slice)
- }
輸出:
- 3 [97,98,99]
說到拷貝,切片中有一個需要注意的問題。
- 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
從上面結(jié)果我們可以看到,如果用 range 的方式去遍歷一個切片,拿到的 Value 其實是切片里面的值拷貝。所以每次打印 Value 的地址都不變。
由于 Value 是值拷貝的,并非引用傳遞,所以直接改 Value 是達不到更改原切片值的目的的,需要通過 &slice[index] 獲取真實的地址。
【本文是51CTO專欄作者“halfrost”的原創(chuàng)稿件,轉(zhuǎn)載請通過51CTO聯(lián)系原作者獲取授權】