動態

詳情 返回 返回

Array 與 Slice 的源碼分析與高效使用-Golang 🔥 - 動態 詳情

在 Go 語言中,數組(array)和切片(slice)是兩種不同的數據結構,它們在內存分配機制上存在着顯著差異。深入理解這些差異及原理並恰當使用,能夠幫助我們提高代碼的執行效率。

在使用上,由於語法糖的存在,很多初學者對於二者並不敏感。數組的寫法是 [n]int,切片則是 []int,區別僅在於是否在 [] 中體現其長度。

從實現上講,slicearray 的一種封裝再實現,將長度不可變的數組,封裝進一個結構體,達到長度可變(擴容)的效果。slice 結構體如下:

// go1.12.6
type slice struct {
    array unsafe.Pointer    // 指向數組
    len   int                // 數組長度,即當前已利用的長度
    cap   int                // 數組容量,即內存的預分配
}

1. 二者在內存方面差異

通過對二者內存的分配、管理和傳參的理解,在開發中,根據實際情況做出更優的選擇,使得程序高效、安全運行。

1.1 核心定義與類型

  • 數組:固定長度的同類型元素序列,長度是類型的組成部分(如 [3]int[4]int 是不同類型)。
  • 切片:動態長度的、指向底層數組的指針(引用類型),由 (ptr, len, cap) 三元組組成(ptr 指向底層數組,len 是當前長度,cap 是底層數組容量)。

1.2 內存分配的核心差異

(1) 分配時機與大小

  • 數組:
    內存大小在編譯期確定,由類型和長度直接決定(總大小 = 元素大小 × 長度)。例如 [3]int24 字節(假設 int8 字節)。
    分配發生在聲明時:

    • 若為字面量初始化(如 a := [3]int{1,2,3}),編譯器可能將其分配在棧上(小數組)或堆上(大數組,由逃逸分析決定)。
    • 若通過 new([n]T) 創建,必然在堆上分配(返回指針)。
  • 切片:
    切片本身的結構(ptr, len, cap)是固定大小的(64 位系統下佔 24 字節),但它的底層數組需要動態分配內存。
    底層數組的分配時機由切片的創建方式決定:

    • 字面量初始化(如 s := []int{1,2,3}):直接分配底層數組(長度等於容量),切片頭指向該數組。
    • 使用 make([]T, len, cap):顯式指定長度和容量,分配底層數組(容量 ≥ 長度)。
    • 空切片(如 var s []ints := make([]int, 0)):初始時底層數組為 nil,無實際內存分配,直到首次賦值或 append 時觸發分配。

(2) 內存可變性

  • 數組:
    內存大小不可變,聲明後長度和容量(長度即容量)固定,無法擴容或縮容。若需調整長度,必須創建新數組並複製數據。
  • 切片:
    底層數組可動態擴容。當通過 append 添加元素超過當前容量時,Go 會自動分配一個更大的新底層數組(通常按原容量的 2 倍擴容,容量較大時可能按 1.25 倍),將舊數據複製到新數組,並更新切片的 ptrcap。舊底層數組若未被其他切片引用,會被垃圾回收。

(3) 內存共享與引用

  • 數組:
    數組是值類型,賦值或傳參時會複製整個數組(內存拷貝)。因此,兩個不同的數組變量即使元素相同,內存地址也不同。
  • 切片:
    切片是引用類型,賦值或傳參時僅複製切片頭(ptr, len, cap),底層數組被多個切片共享。修改任一切片的元素(如 s[i] = x)會影響所有共享該底層數組的切片。

1.3 傳參機制的差異

在 Go 語言中,所有參數傳遞都是值傳遞,即便是參數為指針,也依然伴隨着 copy,只不過給指針創建副本的代價是固定的(8 字節),不會因為數據的大小而產生變化。

因此,也不存在引用傳遞這個概念,從其他語言(C++)轉型過來的朋友,尤其要注意。

  • 數組傳參:(複製整個數組)
    數組是值類型,當作為參數傳遞給函數時,Go 會完整複製整個數組的內存(即創建一個新的同類型數組,並將原數組的所有元素逐一拷貝到新數組中)。
    例如,傳遞一個長度為 N[N]int 數組時,複製的字節數為 N * sizeof(int)(假設 int8 字節,則複製 8N 字節)。
  • 切片傳參:(複製切片頭)
    切片是引用類型,其本質是一個包含 ptr(底層數組指針)、len(長度)、cap(容量)的結構體(64 位系統下佔 24 字節)。當切片作為參數傳遞時,Go 僅複製這個結構體的副本(即複製 24 字節),不會複製底層數組。

切片的傳參開銷是固定的(24 字節),與底層數組的長度無關;而數組的傳參開銷隨長度線性增長(8N 字節)。當數組很長時,切片傳參的效率遠高於數組(因複製開銷僅 24 字節 vs 數組的 8*N 字節)。

因此,在需要處理大數據或動態調整長度的場景下,應優先使用切片;在需要嚴格值語義且數組較小時,則考慮使用數組。

另外,Go 編譯器會對小數組(通常長度較小,如 [3]int)進行優化:若數組在函數調用中未被修改,可能直接通過指針訪問原數組(避免複製)。但對於大數組,這種優化無法生效,必須完整複製。

2. Slice的長度與容量

Slice 的核心特性由兩個關鍵屬性定義:長度(Length,len)和容量(Capacity,cap)。它們共同決定了 Slice 的行為(如能否追加元素、是否共享底層數組等)

  • 長度(len
    lenSlice 中當前有效元素的數量(即“已使用的元素個數”)。它是 Slice 的“可見範圍”,訪問超出 len 的索引會觸發 panic(越界錯誤)。
  • 容量(cap)
    capSlice 底層數組從 Slice 起始位置到數組末尾的元素總數(即“底層數組可用的最大元素個數”)。它決定了 Slice 能容納的最大長度(無需擴容)。

3. Slice 的截取與擴容

通過對 slice 截取與擴容的深入理解,幫助我們在開發中把控數據截取修改的安全性,以及降低數據擴容的頻次,提高執行效率。

3.1 截取(Slicing

Slice 的截取(Slicing)是其核心特性之一,允許從一個已有的 Slice 或數組中創建一個新的 Slice,新 Slice 與原數據共享底層數組的部分元素。理解截取機制有助於高效處理數組子集、避免數據複製,並正確管理內存共享。

截取的語法與基本概念

Slice 的截取通過 [low:high] 語法實現,其中:

  • low:起始索引(包含),表示新 Slice 從原數據的第 low 個元素開始。
  • high:結束索引(不包含),表示新 Slice 到原數據的第 high 個元素結束(不包含該元素)。

Slice 的長度(Len)為 high - low,容量(Cap)為原 Slice 的容量(Cap)減去 low(即底層數組從 low 到末尾的可用空間)。

截取的底層邏輯

Slice 的本質是 (ptr, len, cap) 三元組,截取操作的本質是創建一個新的 Slice 頭,其 ptr 指向原底層數組的 low 位置,lenhigh - lowcap 為原 cap - low

關鍵特點:

  • 截取不會複製底層數組的元素,僅複製 Slice 頭(24 字節),因此時間複雜度為 O(1),效率極高。
  • Slice 與原數據共享底層數組,修改其中一個的元素會直接影響另一個(除非底層數組被重新分配,擴容)。

3.2 擴容(Growth

Slice 的擴容(Growth)是其動態調整容量的核心機制,用於解決因元素追加(append)導致容量不足的問題。擴容的本質是分配新的底層數組,並將舊數據複製到新數組中,從而擴展 Slice 的容量。理解擴容邏輯有助於:

  • 預判內存分配行為,優化性能(如預分配容量)。
  • 避免因共享底層數組導致的意外修改。
  • 合理使用 append,減少不必要的內存複製。

擴容觸發條件

Slice 的擴容僅在通過 append 追加元素時觸發,噹噹前容量(cap)不足以容納新元素的總數量(即 len(s) + nn 為追加的元素數量)時,必須觸發擴容。

  • 擴容策略:小數組 vs 大數組
    Go 的擴容策略根據原容量的大小分為兩種模式,目的是平衡內存利用率和複製效率:
  • 小數組(原容量較小):2 倍擴容
    若原容量(oldCap)較小(通常指 oldCap ≤ 512),擴容後的新容量(newCap)為原容量的 2 倍。
    因為,小數組擴容頻率高,2 倍擴容可減少未來可能的多次擴容操作,降低整體複製成本。
    注:go1.17的版本中,閾值是 1024,而非 512
  • 大數組(原容量較大):1.65~1.25 倍擴容
    若原容量(oldCap)較大(通常指 oldCap > 1024),擴容後的新容量為原容量的 1.65 ~ 1.25 倍,隨着容量變大,這個係數逐漸減小。
    因為,大數組擴容頻率低,1.N 倍擴容可避免內存過度浪費(2 倍可能導致大量內存閒置)。
    注:go1.17的版本中,是持續 1.25 倍擴容,後面的版本優化為從 1.65 遞減。
  • 極端情況:直接滿足需求容量
    若通過 append 顯式指定了足夠大的容量(如 append(s, make([]T, 1000)...)),或擴容後的 newCap 超過上述策略的計算值,則直接使用所需的最小容量(newLen)。

擴容的底層實現(growslice 函數)

Go 的擴容邏輯由運行時(runtime 包)的 growslice 函數實現,核心步驟如下:

  1. 計算新容量
    根據原容量(oldCap)和需要的最小容量(newLen),確定新容量(newCap):
  2. 分配新底層數組
    調用 mallocgc 函數為新容量(newCap)分配內存,內存大小為 newCap * 元素大小(et.size)。
  3. 複製舊數據到新數組
    使用 memmove 函數將舊底層數組的元素(old.Dataold.Data + old.Len * 元素大小)複製到新數組的起始位置。
  4. 更新 Slice 頭
    返回一個新的 sliceHeader,其 ptr 指向新數組,lennewLen(len(s) + n)capnewCap

注意事項

  1. 擴容後原 Slice 不受影響
    擴容會生成一個新的底層數組,原 Slice 仍指向舊數組(除非原 Slice 被重新賦值)。例如:

    s1 := []int{1, 2, 3}
    s2 := s1          // s2 與 s1 共享底層數組
    s1 = append(s1, 4) // s1 擴容,指向新數組
    fmt.Println(s2)    // 輸出 [1 2 3](s2 仍指向舊數組)
  2. 擴容的內存分配位置
    新底層數組始終分配在堆上(即使原 Slice 是棧上的),因為 Slice 可能被外部引用(如返回給其他函數),生命週期需延長至堆級別。運行時的逃逸分析會確保擴容後的數組逃逸到堆。另外,容量過大的 Slice 也必然會被分配到堆上,以防止棧溢出,目前棧的內存一般為 2MB
  3. 複製成本與性能優化
    小數組的 2 倍擴容雖然會增加單次複製成本,但減少了未來擴容次數(如從 3→6→12,僅需兩次擴容即可支持 12 個元素)。
    大數組的 1.65 倍擴容平衡了內存利用率和複製成本(如從 512→848,僅增加 336 個元素空間)。
  4. 避免頻繁擴容
    在已知需要大量元素時,可通過 make 預分配足夠的容量,減少擴容次數:

    // 預分配容量為 1000 的 Slice(避免多次擴容)
    s := make([]int, 0, 1000) 
    for i := 0; i < 1000; i++ {
     s = append(s, i) // 不會觸發擴容,直接填充預分配的空間
    }

關於擴容係數的調整,詳細信息可以查看這個 commit。

user avatar free_like_bird 頭像 meiyoufujideyidongdianyuan 頭像 vistart 頭像 xiaolanbenlan 頭像 innsane 頭像 weiwudejiqimao 頭像 5e4jkgqh 頭像 jiangpengfei_5ecce944a3d8a 頭像 phpnan 頭像 gangyidesongshu 頭像 ahfuzhang 頭像
點贊 11 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.