在 Go 語言中,數組(array)和切片(slice)是兩種不同的數據結構,它們在內存分配機制上存在着顯著差異。深入理解這些差異及原理並恰當使用,能夠幫助我們提高代碼的執行效率。
在使用上,由於語法糖的存在,很多初學者對於二者並不敏感。數組的寫法是 [n]int,切片則是 []int,區別僅在於是否在 [] 中體現其長度。
從實現上講,slice 是 array 的一種封裝再實現,將長度不可變的數組,封裝進一個結構體,達到長度可變(擴容)的效果。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]int佔24字節(假設int為8字節)。
分配發生在聲明時:- 若為字面量初始化(如
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 []int或s := make([]int, 0)):初始時底層數組為 nil,無實際內存分配,直到首次賦值或append時觸發分配。
- 字面量初始化(如
(2) 內存可變性
- 數組:
內存大小不可變,聲明後長度和容量(長度即容量)固定,無法擴容或縮容。若需調整長度,必須創建新數組並複製數據。 - 切片:
底層數組可動態擴容。當通過append添加元素超過當前容量時,Go 會自動分配一個更大的新底層數組(通常按原容量的2倍擴容,容量較大時可能按1.25倍),將舊數據複製到新數組,並更新切片的ptr和cap。舊底層數組若未被其他切片引用,會被垃圾回收。
(3) 內存共享與引用
- 數組:
數組是值類型,賦值或傳參時會複製整個數組(內存拷貝)。因此,兩個不同的數組變量即使元素相同,內存地址也不同。 - 切片:
切片是引用類型,賦值或傳參時僅複製切片頭(ptr,len,cap),底層數組被多個切片共享。修改任一切片的元素(如s[i] = x)會影響所有共享該底層數組的切片。
1.3 傳參機制的差異
在 Go 語言中,所有參數傳遞都是值傳遞,即便是參數為指針,也依然伴隨着 copy,只不過給指針創建副本的代價是固定的(8 字節),不會因為數據的大小而產生變化。
因此,也不存在引用傳遞這個概念,從其他語言(C++)轉型過來的朋友,尤其要注意。
- 數組傳參:(複製整個數組)
數組是值類型,當作為參數傳遞給函數時,Go 會完整複製整個數組的內存(即創建一個新的同類型數組,並將原數組的所有元素逐一拷貝到新數組中)。
例如,傳遞一個長度為N的[N]int數組時,複製的字節數為N * sizeof(int)(假設int佔8字節,則複製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)
len是Slice中當前有效元素的數量(即“已使用的元素個數”)。它是Slice的“可見範圍”,訪問超出len的索引會觸發panic(越界錯誤)。 - 容量(cap)
cap是Slice底層數組從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 位置,len 為 high - low,cap 為原 cap - low。
關鍵特點:
- 截取不會複製底層數組的元素,僅複製
Slice頭(24字節),因此時間複雜度為O(1),效率極高。 - 新
Slice與原數據共享底層數組,修改其中一個的元素會直接影響另一個(除非底層數組被重新分配,擴容)。
3.2 擴容(Growth)
Slice 的擴容(Growth)是其動態調整容量的核心機制,用於解決因元素追加(append)導致容量不足的問題。擴容的本質是分配新的底層數組,並將舊數據複製到新數組中,從而擴展 Slice 的容量。理解擴容邏輯有助於:
- 預判內存分配行為,優化性能(如預分配容量)。
- 避免因共享底層數組導致的意外修改。
- 合理使用 append,減少不必要的內存複製。
擴容觸發條件
Slice 的擴容僅在通過 append 追加元素時觸發,噹噹前容量(cap)不足以容納新元素的總數量(即 len(s) + n,n 為追加的元素數量)時,必須觸發擴容。
- 擴容策略:小數組 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 函數實現,核心步驟如下:
- 計算新容量
根據原容量(oldCap)和需要的最小容量(newLen),確定新容量(newCap): - 分配新底層數組
調用mallocgc函數為新容量(newCap)分配內存,內存大小為newCap* 元素大小(et.size)。 - 複製舊數據到新數組
使用 memmove 函數將舊底層數組的元素(old.Data到old.Data+old.Len* 元素大小)複製到新數組的起始位置。 - 更新 Slice 頭
返回一個新的sliceHeader,其ptr指向新數組,len為newLen(len(s) + n),cap為newCap。
注意事項
-
擴容後原
Slice不受影響
擴容會生成一個新的底層數組,原Slice仍指向舊數組(除非原Slice被重新賦值)。例如:s1 := []int{1, 2, 3} s2 := s1 // s2 與 s1 共享底層數組 s1 = append(s1, 4) // s1 擴容,指向新數組 fmt.Println(s2) // 輸出 [1 2 3](s2 仍指向舊數組) - 擴容的內存分配位置
新底層數組始終分配在堆上(即使原Slice是棧上的),因為Slice可能被外部引用(如返回給其他函數),生命週期需延長至堆級別。運行時的逃逸分析會確保擴容後的數組逃逸到堆。另外,容量過大的Slice也必然會被分配到堆上,以防止棧溢出,目前棧的內存一般為2MB。 - 複製成本與性能優化
小數組的2倍擴容雖然會增加單次複製成本,但減少了未來擴容次數(如從3→6→12,僅需兩次擴容即可支持12個元素)。
大數組的1.65倍擴容平衡了內存利用率和複製成本(如從512→848,僅增加336個元素空間)。 -
避免頻繁擴容
在已知需要大量元素時,可通過 make 預分配足夠的容量,減少擴容次數:// 預分配容量為 1000 的 Slice(避免多次擴容) s := make([]int, 0, 1000) for i := 0; i < 1000; i++ { s = append(s, i) // 不會觸發擴容,直接填充預分配的空間 }
關於擴容係數的調整,詳細信息可以查看這個 commit。