【Go】深入剖析slice和array

arrayslice 看似相似,卻有著極大的不同,但他們之間還有著千次萬縷的聯(lián)系 slice 是引用類型、是 array 的引用,相當(dāng)于動(dòng)態(tài)數(shù)組,
這些都是 slice 的特性,但是 slice 底層如何表現(xiàn),內(nèi)存中是如何分配的,特別是在程序中大量使用 slice 的情況下,怎樣可以高效使用 slice
今天借助 Gounsafe 包來探索 arrayslice 的各種奧妙。

數(shù)組

slice 是在 array 的基礎(chǔ)上實(shí)現(xiàn)的,需要先詳細(xì)了解一下數(shù)組。

** 維基上如此介紹數(shù)組:**

在計(jì)算機(jī)科學(xué)中,數(shù)組數(shù)據(jù)結(jié)構(gòu)(英語:array data structure),簡稱數(shù)組(英語:Array),是由相同類型的元素(element)的集合所組成的數(shù)據(jù)結(jié)構(gòu),分配一塊連續(xù)的內(nèi)存來存儲(chǔ),利用元素的索引(index)可以計(jì)算出該元素對(duì)應(yīng)的存儲(chǔ)地址。
** 數(shù)組設(shè)計(jì)之初是在形式上依賴內(nèi)存分配而成的,所以必須在使用前預(yù)先請(qǐng)求空間。這使得數(shù)組有以下特性:**

  1. 請(qǐng)求空間以后大小固定,不能再改變(數(shù)據(jù)溢出問題);
  2. 在內(nèi)存中有空間連續(xù)性的表現(xiàn),中間不會(huì)存在其他程序需要調(diào)用的數(shù)據(jù),為此數(shù)組的專用內(nèi)存空間;
  3. 在舊式編程語言中(如有中階語言之稱的C),程序不會(huì)對(duì)數(shù)組的操作做下界判斷,也就有潛在的越界操作的風(fēng)險(xiǎn)(比如會(huì)把數(shù)據(jù)寫在運(yùn)行中程序需要調(diào)用的核心部分的內(nèi)存上)。

根據(jù)維基的介紹,了解到數(shù)組是存儲(chǔ)在一段連續(xù)的內(nèi)存中,每個(gè)元素的類型相同,即是每個(gè)元素的寬度相同,可以根據(jù)元素的寬度計(jì)算元素存儲(chǔ)的位置。
通過這段介紹總結(jié)一下數(shù)組有一下特性:

  • 分配在連續(xù)的內(nèi)存地址上
  • 元素類型一致,元素存儲(chǔ)寬度一致
  • 空間大小固定,不能修改
  • 可以通過索引計(jì)算出元素對(duì)應(yīng)存儲(chǔ)的位置(只需要知道數(shù)組內(nèi)存的起始位置和數(shù)據(jù)元素寬度即可)
  • 會(huì)出現(xiàn)數(shù)據(jù)溢出的問題(下標(biāo)越界)

Go 中的數(shù)組如何實(shí)現(xiàn)的呢,恰恰就是這么實(shí)現(xiàn)的,實(shí)際上幾乎所有計(jì)算機(jī)語言,數(shù)組的實(shí)現(xiàn)都是相似的,也擁有上面總結(jié)的特性。
Go 語言的數(shù)組不同于 C 語言或者其他語言的數(shù)組,C 語言的數(shù)組變量是指向數(shù)組第一個(gè)元素的指針;
Go 語言的數(shù)組是一個(gè)值,Go 語言中的數(shù)組是值類型,一個(gè)數(shù)組變量就表示著整個(gè)數(shù)組,意味著 Go 語言的數(shù)組在傳遞的時(shí)候,傳遞的是原數(shù)組的拷貝。

在程序中數(shù)組的初始化有兩種方法 arr := [10]int{}var arr [10]int,但是不能使用 make 來創(chuàng)建,數(shù)組這節(jié)結(jié)束時(shí)再探討一下這個(gè)問題。
使用 unsafe來看一下在內(nèi)存中都是如何存儲(chǔ)的吧:

package main

import (
    "fmt"
    "unsafe"
)

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

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

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

    // 設(shè)置數(shù)組指定索引元素的值
    *(*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 用來計(jì)算當(dāng)前變量的值在內(nèi)存中的大小,12 這個(gè)代表一個(gè) int 有4個(gè)字節(jié),3 * 4 就是 12。
這是在32位平臺(tái)上運(yùn)行得出的結(jié)果, 如果在64位平臺(tái)上運(yùn)行數(shù)組的大小是 24。從這里可以看出 [3]int 在內(nèi)存中由3個(gè)連續(xù)的 int 類型組成,且有 12 個(gè)字節(jié)那么長,這就說明了數(shù)組在內(nèi)存中沒有存儲(chǔ)多余的數(shù)據(jù),只存儲(chǔ)元素本身。

size := unsafe.Sizeof(arr[0]) 用來計(jì)算單個(gè)元素的寬度,int在32位平臺(tái)上就是4個(gè)字節(jié),uintptr(unsafe.Pointer(&arr[0])) 用來計(jì)算數(shù)組起始位置的指針,1*size 用來獲取索引為1的元素相對(duì)數(shù)組起始位置的偏移,unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size)) 獲取索引為1的元素指針,*(*int) 用來轉(zhuǎn)換指針位置的數(shù)據(jù)類型, 因?yàn)?int 是4個(gè)字節(jié),所以只會(huì)讀取4個(gè)字節(jié)的數(shù)據(jù),由元素類型限制數(shù)據(jù)寬度,來確定元素的結(jié)束位置,因此得到的結(jié)果是 2

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

數(shù)組在內(nèi)存中的結(jié)構(gòu)
package main

import (
    "fmt"
    "unsafe"
)

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

如上代碼,動(dòng)態(tài)的給數(shù)組設(shè)定長度,會(huì)導(dǎo)致編譯錯(cuò)誤 non-constant array bound n, 由此推導(dǎo)數(shù)組的所有操作都是編譯時(shí)完成的,會(huì)轉(zhuǎn)成對(duì)應(yīng)的指令,通過這個(gè)特性知道數(shù)組的長度是數(shù)組類型不可或缺的一部分,并且必須在編寫程序時(shí)確定。
可以通過 GOOS=linux GOARCH=amd64 go tool compile -S array.go 來獲取對(duì)應(yīng)的匯編代碼,在 array.go 中做一些數(shù)組相關(guān)的操作,查看轉(zhuǎn)換對(duì)應(yīng)的指令。

之前的疑問,為什么數(shù)組不能用 make 創(chuàng)建? 上面分析了解到數(shù)組操作是在編譯時(shí)轉(zhuǎn)換成對(duì)應(yīng)指令的,而 make 是在運(yùn)行時(shí)處理(特殊狀態(tài)下會(huì)做編譯器優(yōu)化,make可以被優(yōu)化,下面 slice 分析時(shí)來講)。

slice

因?yàn)閿?shù)組是固定長度且是值傳遞,很不靈活,所以在 Go 程序中很少看到數(shù)組的影子。然而 slice 無處不在,slice 以數(shù)組為基礎(chǔ),提供強(qiáng)大的功能和遍歷性。
slice 的類型規(guī)范是[]T,slice T元素的類型。與數(shù)組類型不同,slice 類型沒有指定的長度。

** slice 申明的幾種方法:**

s := []int{1, 2, 3} 簡短的賦值語句
var s []int var 申明
make([]int, 3, 8)make([]int, 3) make 內(nèi)置方法創(chuàng)建
s := ss[:5] 從切片或者數(shù)組創(chuàng)建

** slice 有兩個(gè)內(nèi)置函數(shù)來獲取其屬性:**

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]

這段輸出除了第一個(gè),剩余三個(gè)好像都能看出點(diǎn)什么, 10 不是創(chuàng)建 slice 的長度嗎,20 不就是指定的容量嗎, 最后這個(gè)看起來有點(diǎn)像 slice 里面的數(shù)據(jù),但是數(shù)量貌似有點(diǎn)多,從第三個(gè)元素和第十個(gè)元素來看,正好是給 slice 索引 210 指定的值,但是切片不是長度是 10 個(gè)嗎,難道這個(gè)是容量,容量剛好是 20個(gè)。

第二和第三個(gè)輸出很好弄明白,就是 slice 的長度和容量, 最后一個(gè)其實(shí)是 slice 引用底層數(shù)組的數(shù)據(jù),因?yàn)閯?chuàng)建容量為 20,所以底層數(shù)組的長度就是 20,從這里了解到切片是引用底層數(shù)組上的一段數(shù)據(jù),底層數(shù)組的長度就是 slice 的容量,由于數(shù)組長度不可變的特性,當(dāng) slice 的長度達(dá)到容量大小之后就需要考慮擴(kuò)容,不是說數(shù)組長度不能變嗎,那 slice 怎么實(shí)現(xiàn)擴(kuò)容呢, 其實(shí)就是在內(nèi)存上分配一個(gè)更大的數(shù)組,把當(dāng)前數(shù)組上的內(nèi)容拷貝到新的數(shù)組上, slice 來引用新的數(shù)組,這樣就實(shí)現(xiàn)擴(kuò)容了。

說了這么多,還是沒有看出來 slice 是如何引用數(shù)組的,額…… 之前的程序還有一個(gè)輸出沒有搞懂是什么,難道這個(gè)就是底層數(shù)組的引用。

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]

這段輸出看起來有點(diǎn)小復(fù)雜,第一行輸出就不用說了吧,這個(gè)是打印整個(gè)數(shù)組的數(shù)據(jù)。先分析一下 s1 變量的下面的輸出吧,s1 := arr[:] 引用了整個(gè)數(shù)組,所以在第5、6行輸出都是10,因?yàn)閿?shù)組長度為10,所有 s1 的長度和容量都為10,那第3、4行輸出是什么呢,他們?cè)趺炊家粯幽?,之前分析?shù)組的時(shí)候 通過 uintptr(unsafe.Pointer(&arr[0])) 來獲取數(shù)組起始位置的指針的,那么第4行打印的就是數(shù)組的指針,這么就了解了第三行輸出的是上面了吧,就是數(shù)組起始位置的指針,所以 *(*uintptr)(unsafe.Pointer(&s1)) 獲取的就是引用數(shù)組的指針,但是這個(gè)并不是數(shù)組起始位置的指針,而是 slice 引用數(shù)組元素的指針,為什么這么說呢?

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

** 總結(jié):**

  • slice 是的起始位置是引用數(shù)組元素位置的指針。
  • slice 的長度是引用數(shù)組元素起始位置到結(jié)束位置的長度。
  • slice 的容量是引用數(shù)組元素起始位置到數(shù)組末尾的長度。

經(jīng)過上面一輪分析了解到 slice 有三個(gè)屬性,引用數(shù)組元素位置指針、長度和容量。實(shí)際上 slice 的結(jié)構(gòu)像下圖一樣:

slice

slice 增長

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

package main

import (
    "fmt"
    "unsafe"
)

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

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

    s = append(s, 1)

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

    s = append(s, 1)

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

以上代碼的輸出(Go Playground):

c000082e90
9 10
c000082e90
10 10
c00009a000
11 20

從結(jié)果上看前兩次地址是一樣的,初始化一個(gè)長度為9,容量為10的 slice,當(dāng)?shù)谝淮?append 的時(shí)候容量是足夠的,所以底層引用數(shù)組地址未發(fā)生變化,此時(shí) slice 的長度和容量都為10,之后再次 append 的時(shí)候發(fā)現(xiàn)底層數(shù)組的地址不一樣了,因?yàn)?slice 的長度超過了容量,但是新的 slice 容量并不是11而是20,這要說 slice 的機(jī)制了,因?yàn)閿?shù)組長度不可變,想擴(kuò)容 slice就必須分配一個(gè)更大的數(shù)組,并把之前的數(shù)據(jù)拷貝到新數(shù)組,如果一次只增加1個(gè)長度,那就會(huì)那發(fā)生大量的內(nèi)存分配和數(shù)據(jù)拷貝,這個(gè)成本是很大的,所以 slice 是有一個(gè)增長策略的。

Go 標(biāo)準(zhǔn)庫 runtime/slice.go 當(dāng)中有詳細(xì)的 slice 增長策略的邏輯:

func growslice(et *_type, old slice, cap int) slice {
    .....
    
    // 計(jì)算新的容量,核心算法用來決定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
            }
        }
    }

    // 根據(jù)et.size調(diào)整新的容量
    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)  // 分配新的內(nèi)存
        memmove(p, old.array, lenmem) // 拷貝數(shù)據(jù)
        memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
    } else {
        p = mallocgc(capmem, et, true) // 分配新的內(nèi)存
        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)) // 拷貝數(shù)據(jù)
            }
        }
    }

    return slice{p, old.len, newcap} // 新slice引用新的數(shù)組,長度為舊數(shù)組的長度,容量為新數(shù)組的容量
}

基本呢就三個(gè)步驟,計(jì)算新的容量、分配新的數(shù)組、拷貝數(shù)據(jù)到新數(shù)組,社區(qū)很多人分享 slice 的增長方法,實(shí)際都不是很精確,因?yàn)榇蠹抑环治隽擞?jì)算 newcap 的那一段,也就是上面注釋的第一部分,下面的 switch 根據(jù) et.size 來調(diào)整 newcap 一段被直接忽略,社區(qū)的結(jié)論是:"如果 selic 的容量小于1024個(gè)元素,那么擴(kuò)容的時(shí)候 slicecap 就翻番,乘以2;一旦元素個(gè)數(shù)超過1024個(gè)元素,增長因子就變成1.25,即每次增加原來容量的四分之一" 大多數(shù)情況也確實(shí)如此,但是根據(jù) newcap 的計(jì)算規(guī)則,如果新的容量超過舊的容量2倍時(shí)會(huì)直接按新的容量分配,真的是這樣嗎?

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

這個(gè)結(jié)果有點(diǎn)出人意料, 如果是2倍增長應(yīng)該是 10 * 2 * 2 * 2 結(jié)果應(yīng)該是80, 如果說新的容量高于舊容量的兩倍但結(jié)果也不是50,實(shí)際上 newcap 的結(jié)果就是50,那段邏輯很好理解,但是switch 根據(jù) et.size 來調(diào)整 newcap 后就是52了,這段邏輯走到了 case et.size == sys.PtrSize 這段,詳細(xì)的以后做源碼分析再說。

** 總結(jié) **

  • 當(dāng) slice 的長度超過其容量,會(huì)分配新的數(shù)組,并把舊數(shù)組上的值拷貝到新的數(shù)組
  • 逐個(gè)元素添加到 slice 并操過其容量, 如果 selic 的容量小于1024個(gè)元素,那么擴(kuò)容的時(shí)候 slicecap 就翻番,乘以2;一旦元素個(gè)數(shù)超過1024個(gè)元素,增長因子就變成1.25,即每次增加原來容量的四分之一。
  • 批量添加元素,當(dāng)新的容量高于舊容量的兩倍,就會(huì)分配比新容量稍大一些,并不會(huì)按上面第二條的規(guī)則擴(kuò)容。
  • 當(dāng) slice 發(fā)生擴(kuò)容,引用新數(shù)組后,slice 操作不會(huì)再影響舊的數(shù)組,而是新的數(shù)組(社區(qū)經(jīng)常討論的傳遞 slice 容量超出后,修改數(shù)據(jù)不會(huì)作用到舊的數(shù)據(jù)上),所以往往設(shè)計(jì)函數(shù)如果會(huì)對(duì)長度調(diào)整都會(huì)返回新的 slice,例如 append 方法。

slice 是引用類型?

slice 不發(fā)生擴(kuò)容,所有的修改都會(huì)作用在原數(shù)組上,那如果把 slice 傳遞給一個(gè)函數(shù)或者賦值給另一個(gè)變量會(huì)發(fā)生什么呢,slice 是引用類型,會(huì)有新的內(nèi)存被分配嗎。

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)))
}

這個(gè)例子(Go Playground)比較長就不逐一分析了,在這個(gè)例子里面調(diào)用函數(shù)傳遞 slice 其變量的地址發(fā)生了變化, 但是引用數(shù)組的地址,slice 的長度和容量都沒有變化, 這說明是對(duì) slice 的淺拷貝,拷貝 slice 的三個(gè)屬性創(chuàng)建一個(gè)新的變量,雖然引用底層數(shù)組還是一個(gè),但是變量并不是一個(gè)。

第二個(gè)創(chuàng)建 s1 變量,使用 s 為其賦值,發(fā)現(xiàn) s1 和函數(shù)調(diào)用一樣也是 s 的淺拷貝,之后修改 s1 的長度發(fā)現(xiàn) s1 的長度發(fā)生變化,但是 s 的長度保持不變, 這也說明 s1 就是 s 的淺拷貝。

這樣設(shè)計(jì)有什么優(yōu)勢呢,第三步創(chuàng)建 s2 變量, 并且 append 一個(gè)元素, 發(fā)現(xiàn) s2 的長度發(fā)生變化了, s 并沒有,雖然這個(gè)數(shù)據(jù)就在底層數(shù)組上,但是用常規(guī)的方法 s 是看不到第11個(gè)位置上的數(shù)據(jù)的, s1 因?yàn)殚L度覆蓋到第11個(gè)元素,所有能夠看到這個(gè)數(shù)據(jù)的變化。這里能看到采用淺拷貝的方式可以使得切片的屬性各自獨(dú)立,而不會(huì)相互影響,這樣可以有一定的隔離性,缺點(diǎn)也很明顯,如果兩個(gè)變量都引用同一個(gè)數(shù)組,同時(shí) append, 在不發(fā)生擴(kuò)容的情況下,總是最后一個(gè) append 的結(jié)果被保留,可能引起一些編程上疑惑。

** 總結(jié) **

slice 是引用類型,但是和 C 傳引用是有區(qū)別的, C 里面的傳引用是在編譯器對(duì)原變量數(shù)據(jù)引用, 并不會(huì)發(fā)生內(nèi)存分配,而 Go 里面的引用類型傳遞和賦值會(huì)進(jìn)行淺拷貝,在32位平臺(tái)上有12個(gè)字節(jié)的內(nèi)存分配, 在64位上有24字節(jié)的內(nèi)存分配。

*** 傳引用和引用類型是有區(qū)別的, slice 是引用類型。***

slice 的三種狀態(tài)

slice 有三種狀態(tài):零切片、空切片、nil切片。

零切片

所有的類型都有零值,如果 slice 所引用數(shù)組元素都沒有賦值,就是所有元素都是類型零值,那這就是零切片。

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

零切片很好理解,數(shù)組元素都為類型零值即為零切片,這種狀態(tài)下的 slice 和正常的 slice 操作沒有任何區(qū)別。

空切片

空切片可以理解就是切片的長度為0,就是說 slice 沒有元素。 社區(qū)大多數(shù)解釋空切片為引用底層數(shù)組為 zerobase 這個(gè)特殊的指針。但是從操作上看空切片所有的表現(xiàn)就是切片長度為0,如果容量也為零底層數(shù)組就會(huì)指向 zerobase ,這樣就不會(huì)發(fā)生內(nèi)存分配, 如果容量不會(huì)零就會(huì)指向底層數(shù)據(jù),會(huì)有內(nèi)存分配。

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切片下一小節(jié)說。要注意 s1s2 的長度和容量都為0,且引用數(shù)組指針都是 18349960, 這點(diǎn)太重要了,因?yàn)樗麄兌贾赶?zerobase 這個(gè)特殊的指針,是沒有內(nèi)存分配的。

slice

nil切片

什么是nil切片,這個(gè)名字說明nil切片沒有引用任何底層數(shù)組,底層數(shù)組的地址為nil就是nil切片。上一小節(jié)中的 s 就是一個(gè)nil切片,它的底層數(shù)組指針為0,代表是一個(gè) nil 指針。

slice

總結(jié)

零切片就是其元素值都是元素類型的零值的切片。
空切片就是數(shù)組指針不為nil,且 slice 的長度為0。
nil切片就是引用底層數(shù)組指針為 nilslice

操作上零切片、空切片和正常的切片都沒有任何區(qū)別,但是nil切片會(huì)多兩個(gè)特性,一個(gè)nil切片等于 nil 值,且進(jìn)行 json 序列化時(shí)其值為 null,nil切片還可以通過賦值為 nil 獲得。

數(shù)組與 slice 大比拼

對(duì)數(shù)組和 slice 做了性能測試,源碼在 GitHub。

對(duì)不同容量和數(shù)組和切片做性能測試,代碼如下,分為: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
        }
    }
}

測試結(jié)果如下:

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

從上面的結(jié)果可以發(fā)現(xiàn)數(shù)組和 slice 在1000以內(nèi)的容量上時(shí)性能機(jī)會(huì)一致,而且都沒有內(nèi)存分配,這應(yīng)該是編譯器對(duì) slice 的特殊優(yōu)化。
從10000~1000000容量時(shí)數(shù)組的效率就比slice好了一倍有余,主要原因是數(shù)組在沒有內(nèi)存分配做了編譯優(yōu)化,而 slice 有內(nèi)存分配。
但是10000000容量往后數(shù)組性能大幅度下降,slice 是數(shù)組性能的兩倍,兩個(gè)都在運(yùn)行時(shí)做了內(nèi)存分配,其實(shí)這么大的數(shù)組還真是不常見,也沒有比較做編譯器優(yōu)化了。

slice 與數(shù)組的應(yīng)用場景總結(jié)

slice 和數(shù)組有些差別,特別是應(yīng)用層上,特性差別很大,那什么時(shí)間使用數(shù)組,什么時(shí)間使用切片呢。
之前做了性能測試,在1000以內(nèi)性能幾乎一致,只有10000~1000000時(shí)才會(huì)出現(xiàn)數(shù)組性能好于 slice,由于數(shù)組在編譯時(shí)確定長度,也就是再編寫程序時(shí)必須確認(rèn)長度,所有往常不會(huì)用到更大的數(shù)組,大多數(shù)都在1000以內(nèi)的長度。我認(rèn)為如果在編寫程序是就已經(jīng)確定數(shù)據(jù)長度,建議用數(shù)組,而且竟可能是局部使用的位置建議用數(shù)組(避免傳遞產(chǎn)生值拷貝),比如一天24小時(shí),一小時(shí)60分鐘,ip是4個(gè) byte這種情況是可以用時(shí)數(shù)組的。

為什么推薦用數(shù)組,只要能在編寫程序是確定數(shù)據(jù)長度我都會(huì)用數(shù)組,因?yàn)槠漕愋蜁?huì)幫助閱讀理解程序,dayHour := [24]Data 一眼就知道是按小時(shí)切分?jǐn)?shù)據(jù)存儲(chǔ)的,如要傳遞數(shù)組時(shí)可以考慮傳遞數(shù)組的指針,當(dāng)然會(huì)帶來一些操作不方便,往常我使用數(shù)組都是不需要傳遞給其它函數(shù)的,可能會(huì)在 struct 里面保存數(shù)組,然后傳遞 struct 的指針,或者用 unsafe 來反解析數(shù)組指針到新的數(shù)組,也不會(huì)產(chǎn)生數(shù)據(jù)拷貝,并且只增加一句轉(zhuǎn)換語句。slice 會(huì)比數(shù)組多存儲(chǔ)三個(gè) int 的屬性,而且指針引用會(huì)增加 GC 掃描的成本,每次傳遞都會(huì)對(duì)這三個(gè)屬性進(jìn)行拷貝,如果可以也可以考慮傳遞 slice 的指針,指針只有一個(gè) int 的大小。

** 對(duì)于不確定大小的數(shù)據(jù)只能用 slice,否則就要自己做擴(kuò)容很麻煩, 對(duì)于確定大小的集合建議使用數(shù)組。**

轉(zhuǎn)載:

本文作者: 戚銀(thinkeridea)
本文鏈接: https://blog.thinkeridea.com/201901/go/shen_ru_pou_xi_slice_he_array.html
版權(quán)聲明: 本博客所有文章除特別聲明外,均采用 CC BY 4.0 CN協(xié)議 許可協(xié)議。轉(zhuǎn)載請(qǐng)注明出處!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 切片是 Go 中的一種基本的數(shù)據(jù)結(jié)構(gòu),使用這種結(jié)構(gòu)可以用來管理數(shù)據(jù)集合。切片的設(shè)計(jì)想法是由動(dòng)態(tài)數(shù)組概念而來,為了開...
    一縷殤流化隱半邊冰霜閱讀 11,469評(píng)論 21 55
  • 出處---Go編程語言 歡迎來到 Go 編程語言指南。本指南涵蓋了該語言的大部分重要特性 Go 語言的交互式簡介,...
    Tuberose閱讀 18,746評(píng)論 1 46
  • 數(shù)組Go語言中的數(shù)組是定長的同一類型數(shù)據(jù)的集合,數(shù)組索引是從0開始的。數(shù)組有以下幾種創(chuàng)建方式 以下是一些特殊數(shù)組 ...
    小杰的快樂時(shí)光閱讀 1,970評(píng)論 0 0
  • 原文地址:深入理解 Go Slice 是什么 在 Go 中,Slice(切片)是抽象在 Array(數(shù)組)之上的特...
    EDDYCJY閱讀 1,358評(píng)論 0 12
  • 葭管細(xì)灰動(dòng) 冬至一陽升 梅蕾欲綻朵 柳線似返青 白首一身靜 夕陽半天澄 昨夜少年夢 今朝身猶輕
    一船明月閱讀 226評(píng)論 0 0

友情鏈接更多精彩內(nèi)容