【Go】深入剖析slice和array

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]
[<nil> <nil> <nil> <nil> <nil> <nil> <nil> <nil> <nil> <nil>]
[ ]

零切片很好理解,數組元素都爲類型零值即爲零切片,這種狀態下的 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,否則就要自己做擴容很麻煩, 對於確定大小的集合建議使用數組。

轉載:

本文作者: 戚銀(thinkeridea)
本文鏈接: https://blog.thinkeridea.com/...
版權聲明: 本博客所有文章除特別聲明外,均採用 CC BY 4.0 CN協議 許可協議。轉載請註明出處!

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