array 和 slice 看似相似,卻有著極大的不同,但他們之間還有著千次萬縷的聯(lián)系 slice 是引用類型、是 array 的引用,相當(dāng)于動(dòng)態(tài)數(shù)組,
這些都是 slice 的特性,但是 slice 底層如何表現(xiàn),內(nèi)存中是如何分配的,特別是在程序中大量使用 slice 的情況下,怎樣可以高效使用 slice?
今天借助 Go 的 unsafe 包來探索 array 和 slice 的各種奧妙。
數(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ù)組有以下特性:**
- 請(qǐng)求空間以后大小固定,不能再改變(數(shù)據(jù)溢出問題);
- 在內(nèi)存中有空間連續(xù)性的表現(xiàn),中間不會(huì)存在其他程序需要調(diào)用的數(shù)據(jù),為此數(shù)組的專用內(nèi)存空間;
- 在舊式編程語言中(如有中階語言之稱的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
首先說 12 是 fmt.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)元素賦值。

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 []intvar申明
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 索引 2 和 10 指定的值,但是切片不是長度是 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 是如何增長的,用 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í)候 slice 的 cap 就翻番,乘以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í)候slice的cap就翻番,乘以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é)說。要注意 s1 和 s2 的長度和容量都為0,且引用數(shù)組指針都是 18349960, 這點(diǎn)太重要了,因?yàn)樗麄兌贾赶?zerobase 這個(gè)特殊的指針,是沒有內(nèi)存分配的。

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

總結(jié)
零切片就是其元素值都是元素類型的零值的切片。
空切片就是數(shù)組指針不為nil,且 slice 的長度為0。
nil切片就是引用底層數(shù)組指針為 nil 的 slice。
操作上零切片、空切片和正常的切片都沒有任何區(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)注明出處!