【Go】深入剖析 slice 和 array
文章來源:https://blog.thinkeridea.com/...
array
和 slice
看似相似,卻有著極大的不同,但他們之間還有著千次萬縷的聯絡 slice
是引用型別、是 array
的引用,相當於動態陣列,
這些都是 slice
的特性,但是 slice
底層如何表現,記憶體中是如何分配的,特別是在程式中大量使用 slice
的情況下,怎樣可以高效使用 slice
?
今天藉助 Go
的 unsafe
包來探索 array
和 slice
的各種奧妙。
陣列
slice
是在 array
的基礎上實現的,需要先詳細瞭解一下陣列。
維基上如此介紹陣列:
在電腦科學中,陣列資料結構(英語:array data structure),簡稱陣列(英語:Array),是由相同型別的元素(element)的集合所組成的資料結構,分配一塊連續的記憶體來儲存,利用元素的索引(index)可以計算出該元素對應的儲存地址。
陣列設計之初是在形式上依賴記憶體分配而成的,所以必須在使用前預先請求空間。這使得陣列有以下特性:
- 請求空間以後大小固定,不能再改變(資料溢位問題);
- 在記憶體中有空間連續性的表現,中間不會存在其他程式需要呼叫的資料,為此陣列的專用記憶體空間;
- 在舊式程式語言中(如有中階語言之稱的 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
首先說 12
是 fmt.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
索引 2
和 10
指定的值,但是切片不是長度是 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
是如何增長的,用 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 個元素,那麼擴容的時候 slice
的 cap
就翻番,乘以 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 個元素,那麼擴容的時候slice
的cap
就翻番,乘以 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 切片下一小節說。要注意 s1
和 s2
的長度和容量都為 0,且引用陣列指標都是 18349960
, 這點太重要了,因為他們都指向 zerobase
這個特殊的指標,是沒有記憶體分配的。
nil 切片
什麼是 nil 切片,這個名字說明 nil 切片沒有引用任何底層陣列,底層陣列的地址為 nil 就是 nil 切片。上一小節中的 s
就是一個 nil 切片,它的底層陣列指標為 0,代表是一個 nil
指標。
總結
零切片就是其元素值都是元素型別的零值的切片。
空切片就是陣列指標不為 nil,且 slice
的長度為 0。
nil 切片就是引用底層陣列指標為 nil
的 slice
。
操作上零切片、空切片和正常的切片都沒有任何區別,但是 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 協議 許可協議。轉載請註明出處!
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- 【Go】深入剖析slice和arrayGo
- 詳解go語言的array和slice 【一】Go
- Go通關04:正確使用 array、slice 和 map!Go
- Go快速入門 07 | 集合型別: array、slice 和 map的使用Go型別
- go map 和 sliceGo
- 【譯】Rust中的array、vector和sliceRust
- 深入解析 Go 中 Slice 底層實現Go
- Array、Slice、Map原理淺析
- 解析Array.prototype.slice.call(argume
- go slice使用Go
- Go slice切片的“陷阱”和本質Go
- go slice深拷貝和淺拷貝Go
- go slice深複製和淺複製Go
- 深入剖析容器網路和 iptables
- Go的100天之旅-06陣列和SliceGo陣列
- Go中的切片SliceGo
- Go 切片 slice - Go 學習記錄Go
- 深入剖析KafkaKafka
- 深度解密Go語言之Slice解密Go
- Go 語言中的 切片 --sliceGo
- 【Go語言基礎】sliceGo
- go-slice實現的使用和基本原理Go
- GO 中 slice 的實現原理Go
- AbstractQueuedSynchronizer(AQS)深入剖析AQS
- 深入剖析ConcurrentHashMap(2)HashMap
- 深入剖析 Spring WebFluxSpringWebUX
- 見微知著 帶你透過記憶體看 Slice 和 Array的異同記憶體
- 為什麼 Go map 和 slice 是非執行緒安全的?Go執行緒
- 關於 Go 中 Map 型別和 Slice 型別的傳遞Go型別
- go(golang)之slice的小想法1Golang
- 【Go】slice的一些使用技巧Go
- 分析go中slice的奇怪現象Go
- 兄弟連go教程(17)資料 - SliceGo
- Go語言slice的本質-SliceHeaderGoHeader
- 【Go進階—資料結構】sliceGo資料結構
- 深入剖析Redis客戶端Jedis的特性和原理Redis客戶端
- 深入剖析LinkedList原始碼原始碼
- 探秘Runtime - 深入剖析CategoryGo