深刻解析 Go 中 Slice 底層實現

切片是 Go 中的一種基本的數據結構,使用這種結構能夠用來管理數據集合。切片的設計想法是由動態數組概念而來,爲了開發者能夠更加方便的使一個數據結構能夠自動增長和減小。可是切片自己並非動態數據或者數組指針。切片常見的操做有 reslice、append、copy。與此同時,切片還具備可索引,可迭代的優秀特性。git

一. 切片和數組

關於切片和數組怎麼選擇?接下來好好討論討論這個問題。github

在 Go 中,與 C 數組變量隱式做爲指針使用不一樣,Go 數組是值類型,賦值和函數傳參操做都會複製整個數組數據。數據庫

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)
}複製代碼

打印結果:vim

arrayA : 0xc4200bebf0 , [100 200]
arrayB : 0xc4200bec00 , [100 200]
func Array : 0xc4200bec30 , [100 200]複製代碼

能夠看到,三個內存地址都不一樣,這也就驗證了 Go 中數組賦值和函數傳參都是值複製的。那這會致使什麼問題呢?數組

假想每次傳參都用數組,那麼每次數組都要被複制一遍。若是數組大小有 100萬,在64位機器上就須要花費大約 800W 字節,即 8MB 內存。這樣會消耗掉大量的內存。因而乎有人想到,函數傳參用數組的指針。數據結構

func main() {
    arrayA := []int{100, 200}
    testArrayPoint(&arrayA)   // 1.傳數組指針
    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
}複製代碼

打印結果:app

func Array : 0xc4200b0140 , [100 200] func Array : 0xc4200b0180 , [100 300] arrayA : 0xc4200b0140 , [100 400]複製代碼

這也就證實了數組指針確實到達了咱們想要的效果。如今就算是傳入10億的數組,也只須要再棧上分配一個8個字節的內存給指針就能夠了。這樣更加高效的利用內存,性能也比以前的好。函數

不過傳指針會有一個弊端,從打印結果能夠看到,第一行和第三行指針地址都是同一個,萬一原數組的指針指向更改了,那麼函數裏面的指針指向都會跟着更改。性能

切片的優點也就表現出來了。用切片傳數組參數,既能夠達到節約內存的目的,也能夠達到合理處理好共享內存的問題。打印結果第二行就是切片,切片的指針和原來數組的指針是不一樣的。學習

由此咱們能夠得出結論:

把第一個大數組傳遞給函數會消耗不少內存,採用切片的方式傳參能夠避免上述問題。切片是引用傳遞,因此它們不須要使用額外的內存而且比使用數組更有效率。

可是,依舊有反例。

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()
    }
}複製代碼

咱們作一次性能測試,而且禁用內聯和優化,來觀察切片的堆上內存分配的狀況。

go test -bench . -benchmem -gcflags "-N -l"複製代碼

輸出結果比較「使人意外」:

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複製代碼

解釋一下上述結果,在測試 Array 的時候,用的是4核,循環次數是500000,平均每次執行時間是3637 ns,每次執行堆上分配內存總量是0,分配次數也是0 。

而切片的結果就「差」一點,一樣也是用的是4核,循環次數是300000,平均每次執行時間是4055 ns,可是每次執行一次,堆上分配內存總量是8192,分配次數也是1 。

這樣對比看來,並不是全部時候都適合用切片代替數組,由於切片底層數組可能會在堆上分配內存,並且小數組在棧上拷貝的消耗也未必比
make 消耗大。

二. 切片的數據結構

切片自己並非動態數組或者數組指針。它內部實現的數據結構經過指針引用底層數組,設定相關屬性將數據讀寫操做限定在指定的區域內。切片自己是一個只讀對象,其工做機制相似數組指針的一種封裝

切片(slice)是對數組一個連續片斷的引用,因此切片是一個引用類型(所以更相似於 C/C++ 中的數組類型,或者 Python 中的 list 類型)。這個片斷能夠是整個數組,或者是由起始和終止索引標識的一些項的子集。須要注意的是,終止索引標識的項不包括在切片內。切片提供了一個與指向數組的動態窗口。

給定項的切片索引可能比相關數組的相同元素的索引小。和數組不一樣的是,切片的長度能夠在運行時修改,最小爲 0 最大爲相關數組的長度:切片是一個長度可變的數組。

Slice 的數據結構定義以下:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}複製代碼

切片的結構體由3部分構成,Pointer 是指向一個數組的指針,len 表明當前切片的長度,cap 是當前切片的容量。cap 老是大於等於 len 的。

若是想從 slice 中獲得一塊內存地址,能夠這樣作:

s := make([]byte, 200)
ptr := unsafe.Pointer(&s[0])複製代碼

若是反過來呢?從 Go 的內存地址中構造一個 slice。

var ptr unsafe.Pointer
var s1 = struct {
    addr uintptr
    len int
    cap int
}{ptr, length, length}
s := *(*[]byte)(unsafe.Pointer(&s1))複製代碼

構造一個虛擬的結構體,把 slice 的數據結構拼出來。

固然還有更加直接的方法,在 Go 的反射中就存在一個與之對應的數據結構 SliceHeader,咱們能夠用它來構造一個 slice

var o []byte
sliceHeader := (*reflect.SliceHeader)((unsafe.Pointer(&o)))
sliceHeader.Cap = length
sliceHeader.Len = length
sliceHeader.Data = uintptr(ptr)複製代碼

三. 建立切片

make 函數容許在運行期動態指定數組長度,繞開了數組類型必須使用編譯期常量的限制。

建立切片有兩種形式,make 建立切片,空切片。

1. make 和切片字面量

func makeslice(et *_type, len, cap int) slice {
    // 根據切片的數據類型,獲取切片的最大容量
    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"))
    }
    // 根據切片的容量申請內存
    p := mallocgc(et.size*uintptr(cap), et, true)
    // 返回申請好內存的切片的首地址
    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)
}複製代碼

實現原理和上面的是同樣的,只不過多了把 int64 轉換成 int 這一步罷了。

上圖是用 make 函數建立的一個 len = 4, cap = 6 的切片。內存空間申請了6個 int 類型的內存大小。因爲 len = 4,因此後面2個暫時訪問不到,可是容量仍是在的。這時候數組裏面每一個變量都是0 。

除了 make 函數能夠建立切片之外,字面量也能夠建立切片。

這裏是用字面量建立的一個 len = 6,cap = 6 的切片,這時候數組裏面每一個元素的值都初始化完成了。須要注意的是 [ ] 裏面不要寫數組的容量,由於若是寫了個數之後就是數組了,而不是切片了。

還有一種簡單的字面量建立切片的方法。如上圖。上圖就 Slice A 建立出了一個 len = 3,cap = 3 的切片。從原數組的第二位元素(0是第一位)開始切,一直切到第四位爲止(不包括第五位)。同理,Slice B 建立出了一個 len = 2,cap = 4 的切片。

2. nil 和空切片

nil 切片和空切片也是經常使用的。

var slice []int複製代碼

nil 切片被用在不少標準庫和內置函數中,描述一個不存在的切片的時候,就須要用到 nil 切片。好比函數在發生異常的時候,返回的切片就是 nil 切片。nil 切片的指針指向 nil。

空切片通常會用來表示一個空的集合。好比數據庫查詢,一條結果也沒有查到,那麼就能夠返回一個空切片。

silce := make( []int , 0 )
slice := []int{ }複製代碼

空切片和 nil 切片的區別在於,空切片指向的地址不是nil,指向的是一個內存地址,可是它沒有分配任何內存空間,即底層元素包含0個元素。

最後須要說明的一點是。不論是使用 nil 切片仍是空切片,對其調用內置函數 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,還調用了擴容方法,那麼就新生成一個新的容量的切片返回。
        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 {
        // 在老的切片後面繼續擴充容量
        p = mallocgc(capmem, nil, false)
        // 將 lenmem 這個多個 bytes 從 old.array地址 拷貝到 p 的地址處
        memmove(p, old.array, lenmem)
        // 先將 P 地址加上新的容量獲得新切片容量的地址,而後將新切片容量地址後面的 capmem-newlenmem 個 bytes 這塊內存初始化。爲以後繼續 append() 操做騰出空間。
        memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
    } else {
        // 從新申請新的數組給新切片
        // 從新申請 capmen 這個大的內存地址,而且初始化爲0值
        p = mallocgc(capmem, et, true)
        if !writeBarrier.enabled {
            // 若是還不能打開寫鎖,那麼只能把 lenmem 大小的 bytes 字節從 old.array 拷貝到 p 的地址處
            memmove(p, old.array, lenmem)
        } else {
            // 循環拷貝老的切片的值
            for i := uintptr(0); i < lenmem; i += et.size {
                typedmemmove(et, add(p, i), add(old.array, i))
            }
        }
    }
    // 返回最終新切片,容量更新爲最新擴容以後的容量
    return slice{p, old.len, newcap}
}複製代碼

上述就是擴容的實現。主要須要關注的有兩點,一個是擴容時候的策略,還有一個就是擴容是生成全新的內存地址仍是在原來的地址後追加。

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))
}複製代碼

輸出結果:

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複製代碼

用圖表示出上述過程。

從圖上咱們能夠很容易的看出,新的切片和以前的切片已經不一樣了,由於新的切片更改了一個值,並無影響到原來的數組,新切片指向的數組是一個全新的數組。而且 cap 容量也發生了變化。這之間究竟發生了什麼呢?

Go 中切片擴容的策略是這樣的:

若是切片的容量小於 1024 個元素,因而擴容的時候就翻倍增長容量。上面那個例子也驗證了這一狀況,總容量從原來的4個翻倍到如今的8個。

一旦元素個數超過 1024 個元素,那麼增加因子就變成 1.25 ,即每次增長原來容量的四分之一。

注意:擴容擴大的容量都是針對原來的容量而言的,而不是針對原來數組的長度而言的。

2. 新數組 or 老數組 ?

再談談擴容以後的數組必定是新的麼?這個不必定,分兩種狀況。

狀況一:

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]複製代碼

把上述過程用圖表示出來,以下圖。

經過打印的結果,咱們能夠看到,在這種狀況下,擴容之後並無新建一個新的數組,擴容先後的數組都是同一個,這也就致使了新的切片修改了一個值,也影響到了老的切片了。而且 append() 操做也改變了原來數組裏面的值。一個 append() 操做影響了這麼多地方,若是原數組上有多個切片,那麼這些切片都會被影響!無心間就產生了莫名的 bug!

這種狀況,因爲原數組還有容量能夠擴容,因此執行 append() 操做之後,會在原數組上直接操做,因此這種狀況下,擴容之後的數組仍是指向原來的數組。

這種狀況也極容易出如今字面量建立切片時候,第三個參數 cap 傳值的時候,若是用字面量建立切片,cap 並不等於指向數組的總容量,那麼這種狀況就會發生。

slice := array[1:2:3]複製代碼

上面這種狀況很是危險,極度容易產生 bug 。

建議用字面量建立切片的時候,cap 的值必定要保持清醒,避免共享原數組致使的 bug。

狀況二:

狀況二其實就是在擴容策略裏面舉的例子,在那個例子中之因此生成了新的切片,是由於原來數組的容量已經達到了最大值,再想擴容, Go 默認會先開一片內存區域,把原來的值拷貝過來,而後再執行 append() 操做。這種狀況絲絕不影響原數組。

因此建議儘可能避免狀況一,儘可能使用狀況二,避免 bug 產生。

五. 切片拷貝

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
    }
    // 若是入參 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?
        // 若是隻有一個元素,那麼指針直接轉換便可
        *(*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 )中,並返回被複制的元素個數,copy 的兩個類型必須一致。slicecopy 方法最終的複製結果取決於較短的那個切片,當較短的切片複製完成,整個複製過程就所有完成了。

舉個例子,好比:

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))
    }
    // 拷貝字符串至字節數組
    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複製代碼

從上面結果咱們能夠看到,若是用 range 的方式去遍歷一個切片,拿到的 Value 實際上是切片裏面的值拷貝。因此每次打印 Value 的地址都不變。

因爲 Value 是值拷貝的,並不是引用傳遞,因此直接改 Value 是達不到更改原切片值的目的的,須要經過 &slice[index] 獲取真實的地址。


Reference:
《Go in action》
《Go 語言學習筆記》

GitHub Repo:Halfrost-Field

Follow: halfrost · GitHub

Source: halfrost.com/go_slice/

相關文章
相關標籤/搜索