深度解密Go語言之Slice

来源:https://www.cnblogs.com/qcrao-2018/archive/2019/04/01/10631989.html
-Advertisement-
Play Games

slice 是 Go 語言一個很重要的數據結構。網上已經有很多文章寫過了,似乎沒必要再寫。但是每個人看問題的視角不同,寫出來的東西自然也不一樣。我這篇會從更底層的彙編語言去解讀它,這是一個新的世界。 ...


目錄

Go 語言的 slice 很好用,不過也有一些坑。slice 是 Go 語言一個很重要的數據結構。網上已經有很多文章寫過了,似乎沒必要再寫。但是每個人看問題的視角不同,寫出來的東西自然也不一樣。我這篇會從更底層的彙編語言去解讀它。而且在我寫這篇文章的過程中,發現絕大部分文章都存在一些問題,文章里會講到,這裡先不展開。

我希望本文可以終結這個話題,下次再有人想和你討論 slice,直接把這篇文章的鏈接丟過去就行了。

當我們在說 slice 時,到底在說什麼

slice 翻譯成中文就是切片,它和數組(array)很類似,可以用下標的方式進行訪問,如果越界,就會產生 panic。但是它比數組更靈活,可以自動地進行擴容。

瞭解 slice 的本質,最簡單的方法就是看它的源代碼:

// runtime/slice.go
type slice struct {
    array unsafe.Pointer // 元素指針
    len   int // 長度 
    cap   int // 容量
}

看到了嗎,slice 共有三個屬性:
指針,指向底層數組;
長度,表示切片可用元素的個數,也就是說使用下標對 slice 的元素進行訪問時,下標不能超過 slice 的長度;
容量,底層數組的元素個數,容量 >= 長度。在底層數組不進行擴容的情況下,容量也是 slice 可以擴張的最大限度。

切片數據結構

註意,底層數組是可以被多個 slice 同時指向的,因此對一個 slice 的元素進行操作是有可能影響到其他 slice 的。

slice 的創建

創建 slice 的方式有以下幾種:

序號 方式 代碼示例
1 直接聲明 var slice []int
2 new slice := *new([]int)
3 字面量 slice := []int{1,2,3,4,5}
4 make slice := make([]int, 5, 10)
5 從切片或數組“截取” slice := array[1:5]slice := sourceSlice[1:5]

直接聲明

第一種創建出來的 slice 其實是一個 nil slice。它的長度和容量都為0。和nil比較的結果為true

這裡比較混淆的是empty slice,它的長度和容量也都為0,但是所有的空切片的數據指針都指向同一個地址 0xc42003bda0。空切片和 nil 比較的結果為false

它們的內部結構如下圖:

nil slice 與 empty slice

創建方式 nil切片 空切片
方式一 var s1 []int var s2 = []int{}
方式二 var s4 = *new([]int) var s3 = make([]int, 0)
長度 0 0
容量 0 0
nil 比較 true false

nil 切片和空切片很相似,長度和容量都是0,官方建議儘量使用 nil 切片。

關於nil sliceempty slice的探索可以參考公眾號“碼洞”作者老錢寫的一篇文章《深度解析 Go 語言中「切片」的三種特殊狀態》,地址附在了參考資料部分。

字面量

比較簡單,直接用初始化表達式創建。

package main

import "fmt"

func main() {
    s1 := []int{0, 1, 2, 3, 8: 100}
    fmt.Println(s1, len(s1), cap(s1))
}

運行結果:

[0 1 2 3 0 0 0 0 100] 9 9

唯一值得註意的是上面的代碼例子中使用了索引號,直接賦值,這樣,其他未註明的元素則預設 0 值

make

make函數需要傳入三個參數:切片類型,長度,容量。當然,容量可以不傳,預設和長度相等。

上篇文章《走進Go的底層》中,我們學到了彙編這個工具,這次我們再次請出彙編來更深入地看看slice。如果沒看過上篇文章,建議先回去看完,再繼續閱讀本文效果更佳。

先來一小段玩具代碼,使用 make 關鍵字創建 slice

package main

import "fmt"

func main() {
    slice := make([]int, 5, 10) // 長度為5,容量為10
    slice[2] = 2 // 索引為2的元素賦值為2
    fmt.Println(slice)
}

執行如下命令,得到 Go 彙編代碼:

go tool compile -S main.go

我們只關註main函數:

0x0000 00000 (main.go:5)TEXT    "".main(SB), $96-0
0x0000 00000 (main.go:5)MOVQ    (TLS), CX
0x0009 00009 (main.go:5)CMPQ    SP, 16(CX)
0x000d 00013 (main.go:5)JLS     228
0x0013 00019 (main.go:5)SUBQ    $96, SP
0x0017 00023 (main.go:5)MOVQ    BP, 88(SP)
0x001c 00028 (main.go:5)LEAQ    88(SP), BP
0x0021 00033 (main.go:5)FUNCDATA    $0, gclocals·69c1753bd5f81501d95132d08af04464(SB)
0x0021 00033 (main.go:5)FUNCDATA    $1, gclocals·57cc5e9a024203768cbab1c731570886(SB)
0x0021 00033 (main.go:5)LEAQ    type.int(SB), AX
0x0028 00040 (main.go:6)MOVQ    AX, (SP)
0x002c 00044 (main.go:6)MOVQ    $5, 8(SP)
0x0035 00053 (main.go:6)MOVQ    $10, 16(SP)
0x003e 00062 (main.go:6)PCDATA  $0, $0
0x003e 00062 (main.go:6)CALL    runtime.makeslice(SB)
0x0043 00067 (main.go:6)MOVQ    24(SP), AX
0x0048 00072 (main.go:6)MOVQ    32(SP), CX
0x004d 00077 (main.go:6)MOVQ    40(SP), DX
0x0052 00082 (main.go:7)CMPQ    CX, $2
0x0056 00086 (main.go:7)JLS     221
0x005c 00092 (main.go:7)MOVQ    $2, 16(AX)
0x0064 00100 (main.go:8)MOVQ    AX, ""..autotmp_2+64(SP)
0x0069 00105 (main.go:8)MOVQ    CX, ""..autotmp_2+72(SP)
0x006e 00110 (main.go:8)MOVQ    DX, ""..autotmp_2+80(SP)
0x0073 00115 (main.go:8)MOVQ    $0, ""..autotmp_1+48(SP)
0x007c 00124 (main.go:8)MOVQ    $0, ""..autotmp_1+56(SP)
0x0085 00133 (main.go:8)LEAQ    type.[]int(SB), AX
0x008c 00140 (main.go:8)MOVQ    AX, (SP)
0x0090 00144 (main.go:8)LEAQ    ""..autotmp_2+64(SP), AX
0x0095 00149 (main.go:8)MOVQ    AX, 8(SP)
0x009a 00154 (main.go:8)PCDATA  $0, $1
0x009a 00154 (main.go:8)CALL    runtime.convT2Eslice(SB)
0x009f 00159 (main.go:8)MOVQ    16(SP), AX
0x00a4 00164 (main.go:8)MOVQ    24(SP), CX
0x00a9 00169 (main.go:8)MOVQ    AX, ""..autotmp_1+48(SP)
0x00ae 00174 (main.go:8)MOVQ    CX, ""..autotmp_1+56(SP)
0x00b3 00179 (main.go:8)LEAQ    ""..autotmp_1+48(SP), AX
0x00b8 00184 (main.go:8)MOVQ    AX, (SP)
0x00bc 00188 (main.go:8)MOVQ    $1, 8(SP)
0x00c5 00197 (main.go:8)MOVQ    $1, 16(SP)
0x00ce 00206 (main.go:8)PCDATA  $0, $1
0x00ce 00206 (main.go:8)CALL    fmt.Println(SB)
0x00d3 00211 (main.go:9)MOVQ    88(SP), BP
0x00d8 00216 (main.go:9)ADDQ    $96, SP
0x00dc 00220 (main.go:9)RET
0x00dd 00221 (main.go:7)PCDATA  $0, $0
0x00dd 00221 (main.go:7)CALL    runtime.panicindex(SB)
0x00e2 00226 (main.go:7)UNDEF
0x00e4 00228 (main.go:7)NOP
0x00e4 00228 (main.go:5)PCDATA  $0, $-1
0x00e4 00228 (main.go:5)CALL    runtime.morestack_noctxt(SB)
0x00e9 00233 (main.go:5)JMP     0

先說明一下,Go 語言彙編 FUNCDATAPCDATA 是編譯器產生的,用於保存一些和垃圾收集相關的信息,我們先不用 care。

以上彙編代碼行數比較多,沒關係,因為命令都比較簡單,而且我們的 Go 源碼也足夠簡單,沒有理由看不明白。

我們先從上到下掃一眼,看到幾個關鍵函數:

CALL    runtime.makeslice(SB)
CALL    runtime.convT2Eslice(SB)
CALL    fmt.Println(SB)
CALL    runtime.morestack_noctxt(SB)
序號 功能
1 創建slice
2 類型轉換
3 列印函數
4 棧空間擴容

1是創建 slice 相關的;2是類型轉換;調用 fmt.Println需要將 slice 作一個轉換; 3是列印語句;4是棧空間擴容函數,在函數開始處,會檢查當前棧空間是否足夠,不夠的話需要調用它來進行擴容。暫時可以忽略。

調用了函數就會涉及到參數傳遞,Go 的參數傳遞都是通過 棧空間完成的。接下來,我們詳細分析這整個過程。

行數 作用
1 main函數定義,棧幀大小為 96B
2-4 判斷棧是否需要進行擴容,如果需要則跳到 228,這裡會調用 runtime.morestack_noctxt(SB) 進行棧擴容操作。具體細節後續還會有文章來講
5-9 caller BP 壓棧,具體細節後面會講到
10-15 調用 runtime.makeslice(SB) 函數及準備工作。*_type表示的是 int,也就是 slice 元素的類型。這裡對應的源碼是第6行,也就是調用 make 創建 slice 的那一行。510 分別代表長度和容量,函數參數會在棧頂準備好,之後執行函數調用命令 CALL,進入到被調用函數的棧幀,就會按順序從 caller 的棧頂取函數參數
16-18 接收 makeslice的返回值,通過 move 移動到寄存器中
19-21 給數組索引值為 2 的元素賦上值 2,因為是 int 型的 slice,元素大小為8位元組,所以 MOVQ $2, 16(AX) 此命令就是將 2 搬到索引為 2 的位置。這裡還會對索引值的大小進行檢查,如果越界,則會跳轉到 221,執行 panic 函數
22-26 分別通過寄存器 AX,CX,DXmakeslice 的返回值 move 到記憶體的其他位置,也稱為局部變數,這樣就構造出了 slice

makeslice 棧幀

左邊是棧上的數據,右邊是堆上的數據。array 指向 slice 的底層數據,被分配到堆上了。註意,棧上的地址是從高向低增長;堆則從低向高增長。棧左邊的數字表示對應的彙編代碼的行數,棧右邊箭頭則表示棧地址。(48)SP、(56)SP 表示的內容接著往下看。

註意,在圖中,棧地址是從下往上增長,所以 SP 表示的是圖中 *_type 所在的位置,其它的依此類推。

行數 作用
27-32 準備調用 runtime.convT2Eslice(SB)的函數參數
33-36 接收返回值,通過AX,CX寄存器 move 到(48)SP、(56)SP

convT2Eslice 的函數聲明如下:

func convT2Eslice(t *_type, elem unsafe.Pointer) (e eface) 

第一個參數是指針 *_type_type是一個表示類型的結構體,這裡傳入的就是 slice的類型 []int;第二個參數則是元素的指針,這裡傳入的就是 slice 底層數組的首地址。

返回值 eface 的結構體定義如下:

type eface struct {
    _type *_type
    data  unsafe.Pointer
}

由於我們會調用 fmt.Println(slice),看下函數原型:

func Println(a ...interface{}) (n int, err error)

Println 接收 interface 類型,因此我們需要將 slice 轉換成 interface 類型。由於 slice 沒有方法,是個“空 interface”。因此會調用 convT2Eslice 完成這一轉換過程。

convT2Eslice 函數返回的是類型指針和數據地址。源碼就不貼了,大體流程是:調用 mallocgc 分配一塊記憶體,把數據 copy 進到新的記憶體,然後返回這塊記憶體的地址,*_type 則直接返回傳入的參數。

convT2Eslice 棧幀

32(SP)40(SP) 其實是 makeslice 函數的返回值,這裡可以忽略。

還剩 fmt.Println(slice) 最後一個函數調用了,我們繼續。

行數 作用
37-40 準備 Println 函數參數。共3個參數,第一個是類型地址,還有兩個 1,這塊暫時還不知道為什麼要傳,有瞭解的同學可以在文章後面留言

所以調用 fmt.Println(slice) 時,實際是傳入了一個 slice類型的eface地址。這樣,Println就可以訪問類型中的數據,最終給“列印”出來。

fmt.Println 棧幀

最後,我們看下 main 函數棧幀的開始和收尾部分。

0x0013 00019 (main.go:5)SUBQ    $96, SP
0x0017 00023 (main.go:5)MOVQ    BP, 88(SP)
0x001c 00028 (main.go:5)LEAQ    88(SP), BP
…………………………
0x00d3 00211 (main.go:9)MOVQ    88(SP), BP
0x00d8 00216 (main.go:9)ADDQ    $96, SP
RET

BP可以理解為保存了當前函數棧幀棧底的地址,SP則保存棧頂的地址。

初始,BPSP 分別有一個初始狀態。

main 函數執行的時候,先根據 main 函數棧幀大小確定 SP 的新指向,使得 main 函數棧幀大小達到 96B。之後把老的 BP 保存到 main 函數棧幀的底部,並使 BP 寄存器重新指向新的棧底,也就是 main 函數棧幀的棧底。

最後,當 main 函數執行完畢,把它棧底的 BP 給回彈回到 BP 寄存器,恢復調用前的初始狀態。一切都像是沒有發生一樣,完美的現場。

棧幀變化

這部分,又詳細地分析了一遍函數調用的過程。一方面,讓大家複習一下上一篇文章講的內容;另一方面,向大家展示如何找到 Go 中的一個函數背後真實調用了哪些函數。像例子中,我們就看到了 make 函數背後,實際上是調用了 makeslice 函數;還有一點,讓大家對彙編不那麼“懼怕”,可以輕鬆地分析一些東西。

截取

截取也是比較常見的一種創建 slice 的方法,可以從數組或者 slice 直接截取,當然需要指定起止索引位置。

基於已有 slice 創建新 slice 對象,被稱為 reslice。新 slice 和老 slice 共用底層數組,新老 slice 對底層數組的更改都會影響到彼此。基於數組創建的新 slice 對象也是同樣的效果:對數組或 slice 元素作的更改都會影響到彼此。

值得註意的是,新老 slice 或者新 slice 老數組互相影響的前提是兩者共用底層數組,如果因為執行 append 操作使得新 slice 底層數組擴容,移動到了新的位置,兩者就不會相互影響了。所以,問題的關鍵在於兩者是否會共用底層數組

截取操作採用如下方式:

 data := [...]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
 slice := data[2:4:6] // data[low, high, max]

data 使用3個索引值,截取出新的 slice。這裡 data 可以是數組或者 slicelow 是最低索引值,這裡是閉區間,也就是說第一個元素是 data 位於 low 索引處的元素;而 highmax 則是開區間,表示最後一個元素只能是索引 high-1 處的元素,而最大容量則只能是索引 max-1 處的元素。

max >= high >= low

high == low 時,新 slice 為空。

還有一點,highmax 必須在老數組或者老 slice 的容量(cap)範圍內。

來看一個例子,來自雨痕大佬《Go學習筆記》第四版,P43頁,參考資料里有開源書籍地址。這裡我會進行擴展,並會作詳細說明:

package main

import "fmt"

func main() {
    slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    s1 := slice[2:5]
    s2 := s1[2:6:7]

    s2 = append(s2, 100)
    s2 = append(s2, 200)

    s1[2] = 20

    fmt.Println(s1)
    fmt.Println(s2)
    fmt.Println(slice)
}

先看下代碼運行的結果:

[2 3 20]
[4 5 6 7 100 200]
[0 1 2 3 20 5 6 7 100 9]

我們來走一遍代碼,初始狀態如下:

slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := slice[2:5]
s2 := s1[2:6:7]

s1slice 索引2(閉區間)到索引5(開區間,元素真正取到索引4),長度為3,容量預設到數組結尾,為8。
s2s1 的索引2(閉區間)到索引6(開區間,元素真正取到索引5),容量到索引7(開區間,真正到索引6),為5。

slice origin

接著,向 s2 尾部追加一個元素 100:

s2 = append(s2, 100)

s2 容量剛好夠,直接追加。不過,這會修改原始數組對應位置的元素。這一改動,數組和 s1 都可以看得到。

append 100

再次向 s2 追加元素200:

s2 = append(s2, 100)

這時,s2 的容量不夠用,該擴容了。於是,s2 另起爐竈,將原來的元素複製新的位置,擴大自己的容量。並且為了應對未來可能的 append 帶來的再一次擴容,s2 會在此次擴容的時候多留一些 buffer,將新的容量將擴大為原始容量的2倍,也就是10了。

append 200

最後,修改 s1 索引為2位置的元素:

s1[2] = 20

這次只會影響原始數組相應位置的元素。它影響不到 s2 了,人家已經遠走高飛了。

s1[2]=20

再提一點,列印 s1 的時候,只會列印出 s1 長度以內的元素。所以,只會列印出3個元素,雖然它的底層數組不止3個元素。

至於,我們想在彙編層面看看到底它們是如何共用底層數組的,限於篇幅,這裡不再展開。感興趣的同學可以在公眾號後臺回覆:切片截取

我會給你詳細分析函數調用關係,對共用底層數組的行為也會一目瞭然。二維碼見文章底部。

slice 和數組的區別在哪

slice 的底層數據是數組,slice 是對數組的封裝,它描述一個數組的片段。兩者都可以通過下標來訪問單個元素。

數組是定長的,長度定義好之後,不能再更改。在 Go 中,數組是不常見的,因為其長度是類型的一部分,限制了它的表達能力,比如 [3]int[4]int 就是不同的類型。

而切片則非常靈活,它可以動態地擴容。切片的類型和長度無關。

append 到底做了什麼

先來看看 append 函數的原型:

func append(slice []Type, elems ...Type) []Type

append 函數的參數長度可變,因此可以追加多個值到 slice 中,還可以用 ... 傳入 slice,直接追加一個切片。

slice = append(slice, elem1, elem2)
slice = append(slice, anotherSlice...)

append函數返回值是一個新的slice,Go編譯器不允許調用了 append 函數後不使用返回值。

append(slice, elem1, elem2)
append(slice, anotherSlice...)

所以上面的用法是錯的,不能編譯通過。

使用 append 可以向 slice 追加元素,實際上是往底層數組添加元素。但是底層數組的長度是固定的,如果索引 len-1 所指向的元素已經是底層數組的最後一個元素,就沒法再添加了。

這時,slice 會遷移到新的記憶體位置,新底層數組的長度也會增加,這樣就可以放置新增的元素。同時,為了應對未來可能再次發生的 append 操作,新的底層數組的長度,也就是新 slice 的容量是留了一定的 buffer 的。否則,每次添加元素的時候,都會發生遷移,成本太高。

新 slice 預留的 buffer 大小是有一定規律的。網上大多數的文章都是這樣描述的:

當原 slice 容量小於 1024 的時候,新 slice 容量變成原來的 2 倍;原 slice 容量超過 1024,新 slice 容量變成原來的1.25倍。

我在這裡先說結論:以上描述是錯誤的。

為了說明上面的規律是錯誤的,我寫了一小段玩具代碼:

package main

import "fmt"

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

    oldCap := cap(s)

    for i := 0; i < 2048; i++ {
        s = append(s, i)

        newCap := cap(s)

        if newCap != oldCap {
            fmt.Printf("[%d -> %4d] cap = %-4d  |  after append %-4d  cap = %-4d\n", 0, i-1, oldCap, i, newCap)
            oldCap = newCap
        }
    }
}

我先創建了一個空的 slice,然後,在一個迴圈里不斷往裡面 append 新的元素。然後記錄容量的變化,並且每當容量發生變化的時候,記錄下老的容量,以及添加完元素之後的容量,同時記下此時 slice 里的元素。這樣,我就可以觀察,新老 slice 的容量變化情況,從而找出規律。

運行結果:

[0 ->   -1] cap = 0     |  after append 0     cap = 1   
[0 ->    0] cap = 1     |  after append 1     cap = 2   
[0 ->    1] cap = 2     |  after append 2     cap = 4   
[0 ->    3] cap = 4     |  after append 4     cap = 8   
[0 ->    7] cap = 8     |  after append 8     cap = 16  
[0 ->   15] cap = 16    |  after append 16    cap = 32  
[0 ->   31] cap = 32    |  after append 32    cap = 64  
[0 ->   63] cap = 64    |  after append 64    cap = 128 
[0 ->  127] cap = 128   |  after append 128   cap = 256 
[0 ->  255] cap = 256   |  after append 256   cap = 512 
[0 ->  511] cap = 512   |  after append 512   cap = 1024
[0 -> 1023] cap = 1024  |  after append 1024  cap = 1280
[0 -> 1279] cap = 1280  |  after append 1280  cap = 1696
[0 -> 1695] cap = 1696  |  after append 1696  cap = 2304

在老 slice 容量小於1024的時候,新 slice 的容量的確是老 slice 的2倍。目前還算正確。

但是,當老 slice 容量大於等於 1024 的時候,情況就有變化了。當向 slice 中添加元素 1280 的時候,老 slice 的容量為 1280,之後變成了 1696,兩者並不是 1.25 倍的關係(1696/1280=1.325)。添加完 1696 後,新的容量 2304 當然也不是 16961.25 倍。

可見,現在網上各種文章中的擴容策略並不正確。我們直接搬出源碼:源碼面前,了無秘密。

從前面彙編代碼我們也看到了,向 slice 追加元素的時候,若容量不夠,會調用 growslice 函數,所以我們直接看它的代碼。

// go 1.9.5 src/runtime/slice.go:82
func growslice(et *_type, old slice, cap int) slice {
    // ……
    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        if old.len < 1024 {
            newcap = doublecap
        } else {
            for newcap < cap {
                newcap += newcap / 4
            }
        }
    }
    // ……
    
    capmem = roundupsize(uintptr(newcap) * ptrSize)
    newcap = int(capmem / ptrSize)
}

看到了嗎?如果只看前半部分,現在網上各種文章里說的 newcap 的規律是對的。現實是,後半部分還對 newcap 作了一個記憶體對齊,這個和記憶體分配策略相關。進行記憶體對齊之後,新 slice 的容量是要 大於等於 老 slice 容量的 2倍或者1.25倍

之後,向 Go 記憶體管理器申請記憶體,將老 slice 中的數據複製過去,並且將 append 的元素添加到新的底層數組中。

最後,向 growslice 函數調用者返回一個新的 slice,這個 slice 的長度並沒有變化,而容量卻增大了。

關於 append,我們最後來看一個例子,來源於參考資料部分的【Golang Slice的擴容規則】。

package main

import "fmt"

func main() {
    s := []int{1,2}
    s = append(s,4,5,6)
    fmt.Printf("len=%d, cap=%d",len(s),cap(s))
}

運行結果是:

len=5, cap=6

如果按網上各種文章中總結的那樣:小於原 slice 長度小於 1024 的時候,容量每次增加 1 倍。添加元素 4 的時候,容量變為4;添加元素 5 的時候不變;添加元素 6 的時候容量增加 1 倍,變成 8。

那上面代碼的運行結果就是:

len=5, cap=8

這是錯誤的!我們來仔細看看,為什麼會這樣,再次搬出代碼:

// go 1.9.5 src/runtime/slice.go:82
func growslice(et *_type, old slice, cap int) slice {
    // ……
    newcap := old.cap
    doublecap := newcap + newcap
    if cap > doublecap {
        newcap = cap
    } else {
        // ……
    }
    // ……
    
    capmem = roundupsize(uintptr(newcap) * ptrSize)
    newcap = int(capmem / ptrSize)
}

這個函數的參數依次是 元素的類型,老的 slice,新 slice 最小求的容量

例子中 s 原來只有 2 個元素,lencap 都為 2,append 了三個元素後,長度變為 3,容量最小要變成 5,即調用 growslice 函數時,傳入的第三個參數應該為 5。即 cap=5。而一方面,doublecap 是原 slice容量的 2 倍,等於 4。滿足第一個 if 條件,所以 newcap 變成了 5。

接著調用了 roundupsize 函數,傳入 40。(代碼中ptrSize是指一個指針的大小,在64位機上是8)

我們再看記憶體對齊,搬出 roundupsize 函數的代碼:

// src/runtime/msize.go:13
func roundupsize(size uintptr) uintptr {
    if size < _MaxSmallSize {
        if size <= smallSizeMax-8 {
            return uintptr(class_to_size[size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]])
        } else {
            //……
        }
    }
    //……
}

const _MaxSmallSize = 32768
const smallSizeMax = 1024
const smallSizeDiv = 8

很明顯,我們最終將返回這個式子的結果:

class_to_size[size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]]

這是 Go 源碼中有關記憶體分配的兩個 sliceclass_to_size通過 spanClass獲取 span劃分的 object大小。而 size_to_class8 表示通過 size 獲取它的 spanClass

var size_to_class8 = [smallSizeMax/smallSizeDiv + 1]uint8{0, 1, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 18, 18, 19, 19, 19, 19, 20, 20, 20, 20, 21, 21, 21, 21, 22, 22, 22, 22, 23, 23, 23, 23, 24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 28, 28, 28, 28, 28, 28, 28, 28, 29, 29, 29, 29, 29, 29, 29, 29, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31}

var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}

我們傳進去的 size 等於 40。所以 (size+smallSizeDiv-1)/smallSizeDiv = 5;獲取 size_to_class8 數組中索引為 5 的元素為 4;獲取 class_to_size 中索引為 4 的元素為 48

最終,新的 slice 的容量為 6

newcap = int(capmem / ptrSize) // 6

至於,上面的兩個魔法數組的由來,暫時就不展開了。

為什麼 nil slice 可以直接 append

其實 nil slice 或者 empty slice 都是可以通過調用 append 函數來獲得底層數組的擴容。最終都是調用 mallocgc 來向 Go 的記憶體管理器申請到一塊記憶體,然後再賦給原來的nil sliceempty slice,然後搖身一變,成為“真正”的 slice 了。

傳 slice 和 slice 指針有什麼區別

前面我們說到,slice 其實是一個結構體,包含了三個成員:len, cap, array。分別表示切片長度,容量,底層數據的地址。

當 slice 作為函數參數時,就是一個普通的結構體。其實很好理解:若直接傳 slice,在調用者看來,實參 slice 並不會被函數中的操作改變;若傳的是 slice 的指針,在調用者看來,是會被改變原 slice 的。

值的註意的是,不管傳的是 slice 還是 slice 指針,如果改變了 slice 底層數組的數據,會反應到實參 slice 的底層數據。為什麼能改變底層數組的數據?很好理解:底層數據在 slice 結構體里是一個指針,僅管 slice 結構體自身不會被改變,也就是說底層數據地址不會被改變。 但是通過指向底層數據的指針,可以改變切片的底層數據,沒有問題。

通過 slice 的 array 欄位就可以拿到數組的地址。在代碼里,是直接通過類似 s[i]=10 這種操作改變 slice 底層數組元素值。

另外,啰嗦一句,Go 語言的函數參數傳遞,只有值傳遞,沒有引用傳遞。後面會再寫一篇相關的文章,敬請期待。

再來看一個年幼無知的代碼片段:

package main

func main() {
    s := []int{1, 1, 1}
    f(s)
    fmt.Println(s)
}

func f(s []int) {
    // i只是一個副本,不能改變s中元素的值
    /*for _, i := range s {
        i++
    }
    */

    for i := range s {
        s[i] += 1
    }
}

運行一下,程式輸出:

[2 2 2]

果真改變了原始 slice 的底層數據。這裡傳遞的是一個 slice 的副本,在 f 函數中,s 只是 main 函數中 s 的一個拷貝。在f 函數內部,對 s 的作用並不會改變外層 main 函數的 s

要想真的改變外層 slice,只有將返回的新的 slice 賦值到原始 slice,或者向函數傳遞一個指向 slice 的指針。我們再來看一個例子:

package main

import "fmt"

func myAppend(s []int) []int {
    // 這裡 s 雖然改變了,但並不會影響外層函數的 s
    s = append(s, 100)
    return s
}

func myAppendPtr(s *[]int) {
    // 會改變外層 s 本身
    *s = append(*s, 100)
    return
}

func main() {
    s := []int{1, 1, 1}
    newS := myAppend(s)

    fmt.Println(s)
    fmt.Println(newS)

    s = newS

    myAppendPtr(&s)
    fmt.Println(s)
}

運行結果:

[1 1 1]
[1 1 1 100]
[1 1 1 100 100]

myAppend 函數里,雖然改變了 s,但它只是一個值傳遞,並不會影響外層的 s,因此第一行列印出來的結果仍然是 [1 1 1]

newS 是一個新的 slice,它是基於 s 得到的。因此它列印的是追加了一個 100 之後的結果: [1 1 1 100]

最後,將 newS 賦值給了 ss 這時才真正變成了一個新的slice。之後,再給 myAppendPtr 函數傳入一個 s 指針,這回它真的被改變了:[1 1 1 100 100]

總結

到此,關於 slice 的部分就講完了,不知大家有沒有看過癮。我們最後來總結一下:

  • 切片是對底層數組的一個抽象,描述了它的一個片段。
  • 切片實際上是一個結構體,它有三個欄位:長度,容量,底層數據的地址。
  • 多個切片可能共用同一個底層數組,這種情況下,對其中一個切片或者底層數組的更改,會影響到其他切片。
  • append 函數會在切片容量不夠的情況下,調用 growslice 函數獲取所需要的記憶體,這稱為擴容,擴容會改變元素原來的位置。
  • 擴容策略並不是簡單的擴為原切片容量的 2 倍或 1.25 倍,還有記憶體對齊的操作。擴容後的容量 >= 原容量的 2 倍或 1.25 倍。
  • 當直接用切片作為函數參數時,可以改變切片的元素,不能改變切片本身;想要改變切片本身,可以將改變後的切片返回,函數調用者接收改變後的切片或者將切片指針作為函數參數。

最後,如果你覺得本文對你有幫助的話,幫我點一下右下角的“推薦”吧,感謝!

QR

參考資料

【碼洞《深度解析 Go 語言中「切片」的三種特殊狀態》】https://juejin.im/post/5bea58df6fb9a049f153bca8
【老錢 數組】https://juejin.im/post/5be53bc251882516c15af2e0
【老錢 切片】https://juejin.im/post/5be8e0b1f265da614d08b45a
【golang interface源碼】https://i6448038.github.io/2018/10/01/Golang-interface/
【golang interface源碼】http://legendtkl.com/2017/07/01/golang-interface-implement/
【interface】https://www.jishuwen.com/d/2C9z#tuit
【雨痕開源Go學習筆記】https://github.com/qyuhen/book
【slice 圖很漂亮】https://halfrost.com/go_slice/
【Golang Slice的擴容規則】https://jodezer.github.io/2017/05/golangSlice%E7%9A%84%E6%89%A9%E5%AE%B9%E8%A7%84%E5%88%99
【slice作為參數】https://www.cnblogs.com/fwdqxl/p/9317769.html
【源碼】https://ictar.xyz/2018/10/25/%E6%B7%B1%E5%85%A5%E6%B5%85%E5%87%BA-go-slice/
【append機制 譯文】https://brantou.github.io/2017/05/24/go-array-slice-string/
【slice 彙編】http://xargin.com/go-slice/
【slice tricks】https://colobu.com/2017/03/22/Slice-Tricks/
【有圖】https://i6448038.github.io/2018/08/11/array-and-slice-principle/
【slice的本質】https://www.flysnow.org/2018/12/21/golang-sliceheader.html
【slice使用技巧】https://blog.thinkeridea.com/201901/go/slice_de_yi_xie_shi_yong_ji_qiao.html
【slice/array、記憶體增長】https://blog.thinkeridea.com/201901/go/shen_ru_pou_xi_slice_he_array.html


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 又和大家見面了。首先,和大家說聲抱歉,之前的幾篇文章,可能條理清晰之類的做的不太好,每篇文章的篇幅也比較長,小編在收到讀者的建議之後, 也是認真的思考了一番。之前的想法是儘量把一個模塊介紹完,沒想到一個模塊寫著寫著就寫長了。在之後的文章里,需要認真分段,做到能簡潔就簡潔,能分塊就分塊,在利用大家碎片 ...
  • 官網:www.fhadmin.org 工作流模塊 1.模型管理 :web線上流程設計器、預覽流程xml、導出xml、部署流程 2.流程管理 :導入導出流程資源文件、查看流程圖、根據流程實例反射出流程模型、激活掛起 3.運行中流程:查看流程信息、當前任務節點、當前流程圖、作廢暫停流程、指派待辦人 4. ...
  • 記錄一下最近整理的spring boot項目 項目地址:https://gitee.com/xl0917/spring-boot 1.選擇Spring Initializr 一直點擊next,直到創建完成 2.創建spring boot子模塊,創建無任何模板的maven項目 3.項目結構 4.pom ...
  • 前言 去年年底,博主有購房的意願,本來是打算在青島市北購房,怎奈工作變動,意向轉移到了李滄,坐等了半年以後,最終選擇在紅島附近購置了期房。 也許一些知道青島紅島的小伙伴會問我,為什麼會跑到那鳥不拉屎的地方去買房子,目前只能是一個字:"賭、賭、賭",重要的事情說三遍。下麵來分析一下,我為什麼沒有在李滄 ...
  • 驗證碼探究 如果你是一個數據挖掘愛好者,那麼驗證碼是你避免不過去的一個天坑,和各種驗證碼鬥爭,必然是你成長的一條道路,接下來的幾篇文章,我會儘量的找到各種驗證碼,並且去嘗試解決掉它,中間有些技術甚至我都沒有見過,來吧,一起Coding吧 數字+字母的驗證碼 我隨便在百度圖片搜索了一個驗證碼,如下 今 ...
  • 1.別瞎寫,方法里能用封裝好的類,就別自己寫HashMap. 2.方法名,整的方法名都是啥?退出close,用out. 3.git提交版本,自己寫的代碼,註釋,提交版本的時候,一定要清理掉.每個判斷能不能用單元測試測測. 4.啥時候捕獲錯誤,什麼時候拋出錯誤,什麼時候聲明錯誤,在方法里每個都try_ ...
  • 流,確定是筆者內心很嚮往的天堂,有他之後JAVA在處理數據就變更加的靈動。加上lambda表達不喜歡都不行。JAVA8也為流在提供另一個功能——並行流。即是有並行流,那麼是不是也有順序流。沒有錯。我前面操作的一般都是順序流。在JAVA8裡面並行流和順序流是可以轉變的。來看一個例子——筆者列印數字。 ...
  • #!/usr/bin/env python# -*- coding:utf-8 -*-# 1.簡述解釋型語言和編譯型語言的區別?"""1.解釋型語言:Python,PHP,Ruby.特點是一行一行的解釋,一行一行的傳輸給電腦,報錯行前面可以執行.2.編譯型語言:C,C++,Java,C#,Go.特 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...