【Go】深入剖析slice和array

qiyin發表於2019-01-13

轉載:

本文作者: 戚銀(thinkeridea)

本文連結: https://blog.thinkeridea.com/201901/go/shen_ru_pou_xi_slice_he_array.html

版權宣告: 本部落格所有文章除特別宣告外,均採用 CC BY 4.0 CN協議 許可協議。轉載請註明出處!


arrayslice 看似相似,卻有著極大的不同,但他們之間還有著千次萬縷的聯絡 slice 是引用型別、是 array 的引用,相當於動態陣列, 這些都是 slice 的特性,但是 slice 底層如何表現,記憶體中是如何分配的,特別是在程式中大量使用 slice 的情況下,怎樣可以高效使用 slice

今天藉助 Gounsafe 包來探索 arrayslice 的各種奧妙。

陣列

slice 是在 array 的基礎上實現的,需要先詳細瞭解一下陣列。

維基上如此介紹陣列:

在電腦科學中,陣列資料結構(英語:array data structure),簡稱陣列(英語:Array),是由相同型別的元素(element)的集合所組成的資料結構,分配一塊連續的記憶體來儲存,利用元素的索引(index)可以計算出該元素對應的儲存地址。

陣列設計之初是在形式上依賴記憶體分配而成的,所以必須在使用前預先請求空間。這使得陣列有以下特性:

  1. 請求空間以後大小固定,不能再改變(資料溢位問題);

  2. 在記憶體中有空間連續性的表現,中間不會存在其他程式需要呼叫的資料,為此陣列的專用記憶體空間;

  3. 在舊式程式語言中(如有中階語言之稱的C),程式不會對陣列的操作做下界判斷,也就有潛在的越界操作的風險(比如會把資料寫在執行中程式需要呼叫的核心部分的記憶體上)。

根據維基的介紹,瞭解到陣列是儲存在一段連續的記憶體中,每個元素的型別相同,即是每個元素的寬度相同,可以根據元素的寬度計算元素儲存的位置。

通過這段介紹總結一下陣列有一下特性:

  • 分配在連續的記憶體地址上
  • 元素型別一致,元素儲存寬度一致
  • 空間大小固定,不能修改
  • 可以通過索引計算出元素對應儲存的位置(只需要知道陣列記憶體的起始位置和資料元素寬度即可)
  • 會出現資料溢位的問題(下標越界)

Go 中的陣列如何實現的呢,恰恰就是這麼實現的,實際上幾乎所有計算機語言,陣列的實現都是相似的,也擁有上面總結的特性。

Go 語言的陣列不同於 C 語言或者其他語言的陣列,C 語言的陣列變數是指向陣列第一個元素的指標;

Go 語言的陣列是一個值,Go 語言中的陣列是值型別,一個陣列變數就表示著整個陣列,意味著 Go 語言的陣列在傳遞的時候,傳遞的是原陣列的拷貝。

在程式中陣列的初始化有兩種方法 arr := [10]int{}var arr [10]int,但是不能使用 make 來建立,陣列這節結束時再探討一下這個問題。

使用 unsafe來看一下在記憶體中都是如何儲存的吧:

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    var arr = [3]int{1, 2, 3}

    fmt.Println(unsafe.Sizeof(arr))
    size := unsafe.Sizeof(arr[0])

    // 獲取陣列指定索引元素的值
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size)))

    // 設定陣列指定索引元素的值
    *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size)) = 10

    fmt.Println(arr[1])
}

這段程式碼的輸出如下 (Go Playground):

12 2 10

首先說 12fmt.Println(unsafe.Sizeof(arr)) 輸出的,unsafe.Sizeof 用來計算當前變數的值在記憶體中的大小,12 這個代表一個 int 有4個位元組,3 * 4 就是 12

這是在32位平臺上執行得出的結果, 如果在64位平臺上執行陣列的大小是 24。從這裡可以看出 [3]int 在記憶體中由3個連續的 int 型別組成,且有 12 個位元組那麼長,這就說明了陣列在記憶體中沒有儲存多餘的資料,只儲存元素本身。

size := unsafe.Sizeof(arr[0]) 用來計算單個元素的寬度,int在32位平臺上就是4個位元組,uintptr(unsafe.Pointer(&arr[0])) 用來計算陣列起始位置的指標,1*size 用來獲取索引為1的元素相對陣列起始位置的偏移,unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size)) 獲取索引為1的元素指標,*(*int) 用來轉換指標位置的資料型別, 因為 int 是4個位元組,所以只會讀取4個位元組的資料,由元素型別限制資料寬度,來確定元素的結束位置,因此得到的結果是 2

上一個步驟獲取元素的值,其中先獲取了元素的指標,賦值的時候只需要對這個指標位置設定值就可以了, *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size)) = 10 就是用來給指定下標元素賦值。

陣列在記憶體中的結構

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    n:= 10
    var arr = [n]int{}
    fmt.Println(arr)
}

如上程式碼,動態的給陣列設定長度,會導致編譯錯誤 non-constant array bound n, 由此推導陣列的所有操作都是編譯時完成的,會轉成對應的指令,通過這個特性知道陣列的長度是陣列型別不可或缺的一部分,並且必須在編寫程式時確定。

可以通過 GOOS=linux GOARCH=amd64 go tool compile -S array.go 來獲取對應的彙編程式碼,在 array.go 中做一些陣列相關的操作,檢視轉換對應的指令。

之前的疑問,為什麼陣列不能用 make 建立? 上面分析瞭解到陣列操作是在編譯時轉換成對應指令的,而 make 是在執行時處理(特殊狀態下會做編譯器優化,make可以被優化,下面 slice 分析時來講)。

slice

因為陣列是固定長度且是值傳遞,很不靈活,所以在 Go 程式中很少看到陣列的影子。然而 slice 無處不在,slice 以陣列為基礎,提供強大的功能和遍歷性。

slice 的型別規範是[]T,slice T元素的型別。與陣列型別不同,slice 型別沒有指定的長度。

slice 申明的幾種方法:

s := []int{1, 2, 3} 簡短的賦值語句

var s []int var 申明

make([]int, 3, 8)make([]int, 3) make 內建方法建立

s := ss[:5] 從切片或者陣列建立

slice 有兩個內建函式來獲取其屬性:

len 獲取 slice 的長度

cap 獲取 slice 的容量

slice 的屬性,這東西是什麼,還需藉助 unsafe 來探究一下。

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    s := make([]int, 10, 20)

    s[2] = 100
    s[9] = 200

    size := unsafe.Sizeof(0)
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size*2)))

    fmt.Println(*(*[20]int)(unsafe.Pointer(*(*uintptr)(unsafe.Pointer(&s)))))
}

這段程式碼的輸出如下 (Go Playground):

c00007ce90

10

20

[0 0 100 0 0 0 0 0 0 200 0 0 0 0 0 0 0 0 0 0]

這段輸出除了第一個,剩餘三個好像都能看出點什麼, 10 不是建立 slice 的長度嗎,20 不就是指定的容量嗎, 最後這個看起來有點像 slice 裡面的資料,但是數量貌似有點多,從第三個元素和第十個元素來看,正好是給 slice 索引 210 指定的值,但是切片不是長度是 10 個嗎,難道這個是容量,容量剛好是 20個。

第二和第三個輸出很好弄明白,就是 slice 的長度和容量, 最後一個其實是 slice 引用底層陣列的資料,因為建立容量為 20,所以底層陣列的長度就是 20,從這裡瞭解到切片是引用底層陣列上的一段資料,底層陣列的長度就是 slice 的容量,由於陣列長度不可變的特性,當 slice 的長度達到容量大小之後就需要考慮擴容,不是說陣列長度不能變嗎,那 slice 怎麼實現擴容呢, 其實就是在記憶體上分配一個更大的陣列,把當前陣列上的內容拷貝到新的陣列上, slice 來引用新的陣列,這樣就實現擴容了。

說了這麼多,還是沒有看出來 slice 是如何引用陣列的,額…… 之前的程式還有一個輸出沒有搞懂是什麼,難道這個就是底層陣列的引用。

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    arr := [10]int{1, 2, 3}
    arr[7] = 100
    arr[9] = 200

    fmt.Println(arr)

    s1 := arr[:]
    s2 := arr[2:8]

    size := unsafe.Sizeof(0)
    fmt.Println("----------s1---------")
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s1)))
    fmt.Printf("%x\n", uintptr(unsafe.Pointer(&arr[0])))

    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size*2)))

    fmt.Println(s1)
    fmt.Println(*(*[10]int)(unsafe.Pointer(*(*uintptr)(unsafe.Pointer(&s1)))))

    fmt.Println("----------s2---------")
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s2)))
    fmt.Printf("%x\n", uintptr(unsafe.Pointer(&arr[0]))+size*2)

    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s2)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s2)) + size*2)))

    fmt.Println(s2)
    fmt.Println(*(*[8]int)(unsafe.Pointer(*(*uintptr)(unsafe.Pointer(&s2)))))
}

以上程式碼輸出如下(Go Playground):

[1 2 3 0 0 0 0 100 0 200]
  ----------s1---------
  c00001c0a0
  c00001c0a0
  10
  10
  [1 2 3 0 0 0 0 100 0 200]
  [1 2 3 0 0 0 0 100 0 200]
  ----------s2---------
  c00001c0b0
  c00001c0b0
  6
  8
  [3 0 0 0 0 100]
 [3 0 0 0 0 100 0 200]

這段輸出看起來有點小複雜,第一行輸出就不用說了吧,這個是列印整個陣列的資料。先分析一下 s1 變數的下面的輸出吧,s1 := arr[:] 引用了整個陣列,所以在第5、6行輸出都是10,因為陣列長度為10,所有 s1 的長度和容量都為10,那第3、4行輸出是什麼呢,他們怎麼都一樣呢,之前分析陣列的時候 通過 uintptr(unsafe.Pointer(&arr[0])) 來獲取陣列起始位置的指標的,那麼第4行列印的就是陣列的指標,這麼就瞭解了第三行輸出的是上面了吧,就是陣列起始位置的指標,所以 *(*uintptr)(unsafe.Pointer(&s1)) 獲取的就是引用陣列的指標,但是這個並不是陣列起始位置的指標,而是 slice 引用陣列元素的指標,為什麼這麼說呢?

接著看 s2 變數下面的輸出吧,s2 := arr[2:8] 引用陣列第3~8的元素,那麼 s2 的長度就是 6。 根據經驗可以知道 s2 變數輸出下面第3行就是 slice 的長度,但是為啥第4行是 8 呢,slice 應用陣列的指定索引起始位置到陣列結尾就是 slice 的容量, 所以 所以從第3個位置到末尾,就是8個容量。在看第1行和第2行的輸出,之前分析陣列的時候通過 uintptr(unsafe.Pointer(&arr[0]))+size*2 來獲取陣列指定索引位置的指標,那麼這段第2行就是陣列索引為2的元素指標,*(*uintptr)(unsafe.Pointer(&s2)) 是獲取切片的指標,第1行和第2行輸出一致,所以 slice 實際是引用陣列元素位置的指標,並不是陣列起始位置的指標。

總結:

  • slice 是的起始位置是引用陣列元素位置的指標。
  • slice 的長度是引用陣列元素起始位置到結束位置的長度。
  • slice 的容量是引用陣列元素起始位置到陣列末尾的長度。

經過上面一輪分析瞭解到 slice 有三個屬性,引用陣列元素位置指標、長度和容量。實際上 slice 的結構像下圖一樣:

slice

slice 增長

slice 是如何增長的,用 unsafe 分析一下看看:

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    s := make([]int, 9, 10)

    // 引用底層的陣列地址
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))

    s = append(s, 1)

    // 引用底層的陣列地址
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))

    s = append(s, 1)

    // 引用底層的陣列地址
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
}

以上程式碼的輸出(Go Playground):

c000082e90
  9 10
  c000082e90
  10 10
  c00009a000
 11 20

從結果上看前兩次地址是一樣的,初始化一個長度為9,容量為10的 slice,當第一次 append 的時候容量是足夠的,所以底層引用陣列地址未發生變化,此時 slice 的長度和容量都為10,之後再次 append 的時候發現底層陣列的地址不一樣了,因為 slice 的長度超過了容量,但是新的 slice 容量並不是11而是20,這要說 slice 的機制了,因為陣列長度不可變,想擴容 slice就必須分配一個更大的陣列,並把之前的資料拷貝到新陣列,如果一次只增加1個長度,那就會那發生大量的記憶體分配和資料拷貝,這個成本是很大的,所以 slice 是有一個增長策略的。

Go 標準庫 runtime/slice.go 當中有詳細的 slice 增長策略的邏輯:

func growslice(et *_type, old slice, cap int) slice {
    .....

    // 計算新的容量,核心演算法用來決定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
            }
        }
    }

    // 根據et.size調整新的容量
    var overflow bool
    var lenmem, newlenmem, capmem uintptr
    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)
        overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
        newcap = int(capmem / sys.PtrSize)
    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 = roundupsize(uintptr(newcap) * et.size)
        overflow = uintptr(newcap) > maxSliceCap(et.size)
        newcap = int(capmem / et.size)
    }

    ......

    var p unsafe.Pointer
    if et.kind&kindNoPointers != 0 {
        p = mallocgc(capmem, nil, false)  // 分配新的記憶體
        memmove(p, old.array, lenmem) // 拷貝資料
        memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
    } else {
        p = mallocgc(capmem, et, true) // 分配新的記憶體
        if !writeBarrier.enabled {
            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} // 新slice引用新的陣列,長度為舊陣列的長度,容量為新陣列的容量
}

基本呢就三個步驟,計算新的容量、分配新的陣列、拷貝資料到新陣列,社群很多人分享 slice 的增長方法,實際都不是很精確,因為大家只分析了計算 newcap 的那一段,也就是上面註釋的第一部分,下面的 switch 根據 et.size 來調整 newcap 一段被直接忽略,社群的結論是:"如果 selic 的容量小於1024個元素,那麼擴容的時候 slicecap 就翻番,乘以2;一旦元素個數超過1024個元素,增長因子就變成1.25,即每次增加原來容量的四分之一" 大多數情況也確實如此,但是根據 newcap 的計算規則,如果新的容量超過舊的容量2倍時會直接按新的容量分配,真的是這樣嗎?

package main

import (
    "fmt"
)

func main() {
    s := make([]int, 10, 10)
    fmt.Println(len(s), cap(s))
    s2 := make([]int, 40)

    s = append(s, s2...)
    fmt.Println(len(s), cap(s))

}

以上程式碼的輸出(Go Playground):

10 10

50 52

這個結果有點出人意料, 如果是2倍增長應該是 10 * 2 * 2 * 2 結果應該是80, 如果說新的容量高於舊容量的兩倍但結果也不是50,實際上 newcap 的結果就是50,那段邏輯很好理解,但是switch 根據 et.size 來調整 newcap 後就是52了,這段邏輯走到了 case et.size == sys.PtrSize 這段,詳細的以後做原始碼分析再說。

總結

  • slice 的長度超過其容量,會分配新的陣列,並把舊陣列上的值拷貝到新的陣列
  • 逐個元素新增到 slice 並操過其容量, 如果 selic 的容量小於1024個元素,那麼擴容的時候 slicecap 就翻番,乘以2;一旦元素個數超過1024個元素,增長因子就變成1.25,即每次增加原來容量的四分之一。

  • 批量新增元素,當新的容量高於舊容量的兩倍,就會分配比新容量稍大一些,並不會按上面第二條的規則擴容。

  • slice 發生擴容,引用新陣列後,slice 操作不會再影響舊的陣列,而是新的陣列(社群經常討論的傳遞 slice 容量超出後,修改資料不會作用到舊的資料上),所以往往設計函式如果會對長度調整都會返回新的 slice,例如 append 方法。

slice 是引用型別?

slice 不發生擴容,所有的修改都會作用在原陣列上,那如果把 slice 傳遞給一個函式或者賦值給另一個變數會發生什麼呢,slice 是引用型別,會有新的記憶體被分配嗎。

package main

import (
    "fmt"
    "strings"
    "unsafe"
)

func main() {
    s := make([]int, 10, 20)

    size := unsafe.Sizeof(0)
    fmt.Printf("%p\n", &s)
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size*2)))

    slice(s)

    s1 := s
    fmt.Printf("%p\n", &s1)
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s1)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size*2)))

    fmt.Println(strings.Repeat("-", 50))

    *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size)) = 20

    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size*2)))

    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s1)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size*2)))

    fmt.Println(s)
    fmt.Println(s1)
    fmt.Println(strings.Repeat("-", 50))

    s2 := s
    s2 = append(s2, 1)

    fmt.Println(len(s), cap(s), s)
    fmt.Println(len(s1), cap(s1), s1)
    fmt.Println(len(s2), cap(s2), s2)

}

func slice(s []int) {
    size := unsafe.Sizeof(0)
    fmt.Printf("%p\n", &s)
    fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size)))
    fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size*2)))
}

這個例子(Go Playground)比較長就不逐一分析了,在這個例子裡面呼叫函式傳遞 slice 其變數的地址發生了變化, 但是引用陣列的地址,slice 的長度和容量都沒有變化, 這說明是對 slice 的淺拷貝,拷貝 slice 的三個屬性建立一個新的變數,雖然引用底層陣列還是一個,但是變數並不是一個。

第二個建立 s1 變數,使用 s 為其賦值,發現 s1 和函式呼叫一樣也是 s 的淺拷貝,之後修改 s1 的長度發現 s1 的長度發生變化,但是 s 的長度保持不變, 這也說明 s1 就是 s 的淺拷貝。

這樣設計有什麼優勢呢,第三步建立 s2 變數, 並且 append 一個元素, 發現 s2 的長度發生變化了, s 並沒有,雖然這個資料就在底層陣列上,但是用常規的方法 s 是看不到第11個位置上的資料的, s1 因為長度覆蓋到第11個元素,所有能夠看到這個資料的變化。這裡能看到採用淺拷貝的方式可以使得切片的屬性各自獨立,而不會相互影響,這樣可以有一定的隔離性,缺點也很明顯,如果兩個變數都引用同一個陣列,同時 append, 在不發生擴容的情況下,總是最後一個 append 的結果被保留,可能引起一些程式設計上疑惑。

總結

slice 是引用型別,但是和 C 傳引用是有區別的, C 裡面的傳引用是在編譯器對原變數資料引用, 並不會發生記憶體分配,而 Go 裡面的引用型別傳遞和賦值會進行淺拷貝,在32位平臺上有12個位元組的記憶體分配, 在64位上有24位元組的記憶體分配。

傳引用和引用型別是有區別的, slice 是引用型別。

slice 的三種狀態

slice 有三種狀態:零切片、空切片、nil切片。

零切片

所有的型別都有零值,如果 slice 所引用陣列元素都沒有賦值,就是所有元素都是型別零值,那這就是零切片。

package main

import "fmt"

func main() {
    var s = make([]int, 10)
    fmt.Println(s)

    var s1 = make([]*int, 10)
    fmt.Println(s1)

    var s2 = make([]string, 10)
    fmt.Println(s2)
}

以上程式碼輸出(Go Playground):

[0 0 0 0 0 0 0 0 0 0]

[ ]

[ ]

零切片很好理解,陣列元素都為型別零值即為零切片,這種狀態下的 slice 和正常的 slice 操作沒有任何區別。

空切片

空切片可以理解就是切片的長度為0,就是說 slice 沒有元素。 社群大多數解釋空切片為引用底層陣列為 zerobase 這個特殊的指標。但是從操作上看空切片所有的表現就是切片長度為0,如果容量也為零底層陣列就會指向 zerobase ,這樣就不會發生記憶體分配, 如果容量不會零就會指向底層資料,會有記憶體分配。

package main

import (
    "fmt"
    "reflect"
    "strings"
    "unsafe"
)

func main() {
    var s []int
    s1 := make([]int, 0)
    s2 := make([]int, 0, 0)
    s3 := make([]int, 0, 100)

    arr := [10]int{}
    s4 := arr[:0]

    fmt.Println(strings.Repeat("--s--", 10))
    fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s)))
    fmt.Println(s)
    fmt.Println(s == nil)

    fmt.Println(strings.Repeat("--s1--", 10))
    fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s1)))
    fmt.Println(s1)
    fmt.Println(s1 == nil)

    fmt.Println(strings.Repeat("--s2--", 10))
    fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s2)))
    fmt.Println(s2)
    fmt.Println(s2 == nil)

    fmt.Println(strings.Repeat("--s3--", 10))
    fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s3)))
    fmt.Println(s3)
    fmt.Println(s3 == nil)

    fmt.Println(strings.Repeat("--s4--", 10))
    fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s4)))
    fmt.Println(s4)
    fmt.Println(s4 == nil)
}

以上程式碼輸出(Go Playground):

--s----s----s----s----s----s----s----s----s----s--
 {0 0 0}
 []
 --s1----s1----s1----s1----s1----s1----s1----s1----s1----s1--
 {18349960 0 0}
 []
 --s2----s2----s2----s2----s2----s2----s2----s2----s2----s2--
 {18349960 0 0}
 []
 --s3----s3----s3----s3----s3----s3----s3----s3----s3----s3--
 {824634269696 0 100}
 []
 --s4----s4----s4----s4----s4----s4----s4----s4----s4----s4--
 {824633835680 0 10}
[]

以上示例中除了 s 其它的 slice 都是空切片,列印出來全部都是 []s 是nil切片下一小節說。要注意 s1s2 的長度和容量都為0,且引用陣列指標都是 18349960, 這點太重要了,因為他們都指向 zerobase 這個特殊的指標,是沒有記憶體分配的。

slice

nil切片

什麼是nil切片,這個名字說明nil切片沒有引用任何底層陣列,底層陣列的地址為nil就是nil切片。上一小節中的 s 就是一個nil切片,它的底層陣列指標為0,代表是一個 nil 指標。

slice

總結

零切片就是其元素值都是元素型別的零值的切片。

空切片就是陣列指標不為nil,且 slice 的長度為0。

nil切片就是引用底層陣列指標為 nilslice

操作上零切片、空切片和正常的切片都沒有任何區別,但是nil切片會多兩個特性,一個nil切片等於 nil 值,且進行 json 序列化時其值為 null,nil切片還可以通過賦值為 nil 獲得。

陣列與 slice 大比拼

對陣列和 slice 做了效能測試,原始碼在 GitHub

對不同容量和陣列和切片做效能測試,程式碼如下,分為:100、1000、10000、100000、1000000、10000000

func BenchmarkSlice100(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := make([]int, 100)
        for i, v := range s {
            s[i] = 1 + i
            _ = v
        }
    }
}

func BenchmarkArray100(b *testing.B) {
    for i := 0; i < b.N; i++ {
        a := [100]int{}
        for i, v := range a {
            a[i] = 1 + i
            _ = v
        }
    }
}

測試結果如下:

goos: darwin
 goarch: amd64
 pkg: github.com/thinkeridea/example/array_slice/test
 BenchmarkSlice100-8            20000000            69.8 ns/op         0 B/op          0 allocs/op
 BenchmarkArray100-8            20000000            69.0 ns/op         0 B/op          0 allocs/op
 BenchmarkSlice1000-8            5000000           318 ns/op           0 B/op          0 allocs/op
 BenchmarkArray1000-8            5000000           316 ns/op           0 B/op          0 allocs/op
 BenchmarkSlice10000-8            200000          9024 ns/op       81920 B/op          1 allocs/op
 BenchmarkArray10000-8            500000          3143 ns/op           0 B/op          0 allocs/op
 BenchmarkSlice100000-8            10000        114398 ns/op      802816 B/op          1 allocs/op
 BenchmarkArray100000-8            20000         61856 ns/op           0 B/op          0 allocs/op
 BenchmarkSlice1000000-8            2000        927946 ns/op     8003584 B/op          1 allocs/op
 BenchmarkArray1000000-8            5000        342442 ns/op           0 B/op          0 allocs/op
 BenchmarkSlice10000000-8            100      10555770 ns/op    80003072 B/op          1 allocs/op
 BenchmarkArray10000000-8             50      22918998 ns/op    80003072 B/op          1 allocs/op
 PASS
ok      github.com/thinkeridea/example/array_slice/test 23.333s

從上面的結果可以發現陣列和 slice 在1000以內的容量上時效能機會一致,而且都沒有記憶體分配,這應該是編譯器對 slice 的特殊優化。

從10000~1000000容量時陣列的效率就比slice好了一倍有餘,主要原因是陣列在沒有記憶體分配做了編譯優化,而 slice 有記憶體分配。

但是10000000容量往後陣列效能大幅度下降,slice 是陣列效能的兩倍,兩個都在執行時做了記憶體分配,其實這麼大的陣列還真是不常見,也沒有比較做編譯器優化了。

slice 與陣列的應用場景總結

slice 和陣列有些差別,特別是應用層上,特性差別很大,那什麼時間使用陣列,什麼時間使用切片呢。

之前做了效能測試,在1000以內效能幾乎一致,只有10000~1000000時才會出現陣列效能好於 slice,由於陣列在編譯時確定長度,也就是再編寫程式時必須確認長度,所有往常不會用到更大的陣列,大多數都在1000以內的長度。我認為如果在編寫程式是就已經確定資料長度,建議用陣列,而且竟可能是區域性使用的位置建議用陣列(避免傳遞產生值拷貝),比如一天24小時,一小時60分鐘,ip是4個 byte這種情況是可以用時陣列的。

為什麼推薦用陣列,只要能在編寫程式是確定資料長度我都會用陣列,因為其型別會幫助閱讀理解程式,dayHour := [24]Data 一眼就知道是按小時切分資料儲存的,如要傳遞陣列時可以考慮傳遞陣列的指標,當然會帶來一些操作不方便,往常我使用陣列都是不需要傳遞給其它函式的,可能會在 struct 裡面儲存陣列,然後傳遞 struct 的指標,或者用 unsafe 來反解析陣列指標到新的陣列,也不會產生資料拷貝,並且只增加一句轉換語句。slice 會比陣列多儲存三個 int 的屬性,而且指標引用會增加 GC 掃描的成本,每次傳遞都會對這三個屬性進行拷貝,如果可以也可以考慮傳遞 slice 的指標,指標只有一個 int 的大小。

對於不確定大小的資料只能用 slice,否則就要自己做擴容很麻煩, 對於確定大小的集合建議使用陣列。

相關文章