徹底理解Golang Slice
看完這篇文章,下面這些高頻面試題你都會答了吧
- Go slice的底層實現原理
- Go array和slice的區別
- Go slice深拷貝和淺拷貝
- Go slice擴容機制是怎樣的?
- 為什麼Go slice是非線程安全的?
實現原理
slice是無固定長度的數組,底層結構是一個結構體,包含如下3個屬性
一個 slice
在 golang 中佔用 24 個 bytes
type slice struct { array unsafe.Pointer len int cap int }
array : 包含了一個指向一個數組的指針,數據實際上存儲在這個指針指向的數組上,佔用 8 bytes
len: 當前 slice 使用到的長度,佔用8 bytes
cap : 當前 slice 的容量,同時也是底層數組 array 的長度, 8 bytes
slice並不是真正意義上的動態數組,而是一個引用類型。slice總是指向一個底層array,slice的聲明也可以像 array一樣,只是長度可變。 golang中通過語法糖,使得我們可以像聲明array一樣,自動創建slice結構體
根據 索引位置取切片 slice 元素值時,默認取值範圍是(0~ len ( slice )-1),一般輸出slice時,通常是指 slice[0:len(slice)-1],根據下標就可以輸出所指向底層數組中的值
主要特性
引用類型
golang 有三個常用的高級類型 slice 、map、channel, 它們都是 引用類型 ,當引用類型作為函數參數時,可能會修改原內容數據。
func sliceModify(s []int) { s[0] = 100 } func sliceAppend(s []int) []int { s = append(s, 100) return s } func sliceAppendPtr(s *[]int) { *s = append(*s, 100) return } // 注意:Go語言中所有的傳參都是值傳遞(傳值),都是一個副本,一個拷貝。 // 拷貝的內容是非引用類型(int、string、struct等這些),在函數中就無法修改原內容數據; // 拷貝的內容是引用類型(interface、指針、map、slice、chan等這些),這樣就可以修改原內容數據。 func TestSliceFn(t *testing.T) { // 參數為引用類型slice:外層slice的len/cap不會改變,指向的底層數組會改變 s := []int{1, 1, 1} newS := sliceAppend(s) // 函數內發生了擴容 t.Log(s, len(s), cap(s)) // [1 1 1] 3 3 t.Log(newS, len(newS), cap(newS)) // [1 1 1 100] 4 6 s2 := make([]int, 0, 5) newS = sliceAppend(s2) // 函數內未發生擴容 t.Log(s2, s2[0:5], len(s2), cap(s2)) // [] [100 0 0 0 0] 0 5 t.Log(newS, newS[0:5], len(newS), cap(newS)) // [100] [100 0 0 0 0] 1 5 // 參數為引用類型slice的指針:外層slice的len/cap會改變,指向的底層數組會改變 sliceAppendPtr(&s) t.Log(s, len(s), cap(s)) // [1 1 1 100] 4 6 sliceModify(s) t.Log(s, len(s), cap(s)) // [100 1 1 100] 4 6 }
公眾號後台caspar回覆【代碼】獲取本文所有示例代碼
切片狀態
切片有3種特殊的狀態,分為「零切片」、「空切片」和「nil 切片」
func TestSliceEmptyOrNil(t *testing.T) { var slice1 []int // slice1 is nil slice slice2 := make([]int, 0) // slcie2 is empty slice var slice3 = make([]int, 2) // slice3 is zero slice if slice1 == nil { t.Log("slice1 is nil.") // 會輸出這行 } if slice2 == nil { t.Log("slice2 is nil.") // 不會輸出這行 } t.Log(slice3) // [0 0] }
非線程安全
slice不支持併發讀寫,所以並不是線程安全的,使用多個 goroutine 對類型為 slice 的變量進行操作,每次輸出的值大概率都不會一樣,與預期值不一致; slice在併發執行中不會報錯,但是數據會丟失
/** * 切片非併發安全 * 多次執行,每次得到的結果都不一樣 * 可以考慮使用 channel 本身的特性 (阻塞) 來實現安全的併發讀寫 */ func TestSliceConcurrencySafe(t *testing.T) { a := make([]int, 0) var wg sync.WaitGroup for i := 0; i < 10000; i++ { wg.Add(1) go func(i int) { a = append(a, i) wg.Done() }(i) } wg.Wait() t.Log(len(a)) // not equal 10000 }
如果想實現slice線程安全,有兩種方式:
方式一:通過加鎖實現slice線程安全,適合對性能要求不高的場景。
func TestSliceConcurrencySafeByMutex(t *testing.T) { var lock sync.Mutex //互斥鎖 a := make([]int, 0) var wg sync.WaitGroup for i := 0; i < 10000; i++ { wg.Add(1) go func(i int) { defer wg.Done() lock.Lock() defer lock.Unlock() a = append(a, i) }(i) } wg.Wait() t.Log(len(a)) // equal 10000 }
方式二:通過channel實現slice線程安全,適合對性能要求高的場景。
func TestSliceConcurrencySafeByChanel(t *testing.T) { buffer := make(chan int) a := make([]int, 0) // 消費者 go func() { for v := range buffer { a = append(a, v) } }() // 生產者 var wg sync.WaitGroup for i := 0; i < 10000; i++ { wg.Add(1) go func(i int) { defer wg.Done() buffer <- i }(i) } wg.Wait() t.Log(len(a)) // equal 10000 }
共享存儲空間
多個切片如果共享同一個底層數組,這種情況下,對其中一個切片或者底層數組的更改,會影響到其他切片
/** * 切片共享存儲空間 */ func TestSliceShareMemory(t *testing.T) { slice1 := []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"} Q2 := slice1[3:6] t.Log(Q2, len(Q2), cap(Q2)) // [4 5 6] 3 9 Q3 := slice1[5:8] t.Log(Q3, len(Q3), cap(Q3)) // [6 7 8] 3 7 Q3[0] = "Unkown" t.Log(Q2, Q3) // [4 5 Unkown] [Unkown 7 8] a := []int{1, 2, 3, 4, 5} shadow := a[1:3] t.Log(shadow, a) // [2 3] [1 2 3 4 5] shadow = append(shadow, 100) // 會修改指向數組的所有切片 t.Log(shadow, a) // [2 3 100] [1 2 3 100 5] }
常用操作
創建
slice 的創建有4種方式,如下:
func TestSliceInit(t *testing.T) { // 初始化方式1:直接聲明 var slice1 []int t.Log(len(slice1), cap(slice1)) // 0, 0 slice1 = append(slice1, 1) t.Log(len(slice1), cap(slice1)) // 1, 1, 24 // 初始化方式2:使用字面量 slice2 := []int{1, 2, 3, 4} t.Log(len(slice2), cap(slice2)) // 4, 4, 24 // 初始化方式3:使用make創建slice slice3 := make([]int, 3, 5) // make([]T, len, cap) cap不傳則和len一樣 t.Log(len(slice3), cap(slice3)) // 3, 5 t.Log(slice3[0], slice3[1], slice3[2]) // 0, 0, 0 // t.Log(slice3[3], slice3[4]) // panic: runtime error: index out of range [3] with length 3 slice3 = append(slice3, 1) t.Log(len(slice3), cap(slice3)) // 4, 5, 24 // 初始化方式4: 從切片或數組“截取” arr := [100]int{} for i := range arr { arr[i] = i } slcie4 := arr[1:3] slice5 := make([]int, len(slcie4)) copy(slice5, slcie4) t.Log(len(slcie4), cap(slcie4), unsafe.Sizeof(slcie4)) // 2,99,24 t.Log(len(slice5), cap(slice5), unsafe.Sizeof(slice5)) // 2,2,24 }
增加
func TestSliceGrowing(t *testing.T) { slice1 := []int{} for i := 0; i < 10; i++ { slice1 = append(slice1, i) t.Log(len(slice1), cap(slice1)) } // 1 1 // 2 2 // 3 4 // 4 4 // 5 8 // 6 8 // 7 8 // 8 8 // 9 16 // 10 16 }
刪除
func TestSliceDelete(t *testing.T) { slice1 := []int{1, 2, 3, 4, 5} var x int // 刪除最後一個元素 x, slice1 = slice1[len(slice1)-1], slice1[:len(slice1)-1] t.Log(x, slice1, len(slice1), cap(slice1)) // 5 [1 2 3 4] 4 5 // 刪除第2個元素 slice1 = append(slice1[:2], slice1[3:]...) t.Log(slice1, len(slice1), cap(slice1)) // [1 2 4] 3 5 }
查找
v := s[i] // 下標訪問
修改
s[i] = 5 // 下標修改
截取
/** * 切片截取 */ func TestSliceSubstr(t *testing.T) { slice1 := []int{1, 2, 3, 4, 5} slice2 := slice1[:] // 截取 slice[left:right:max] // left:省略默認0 // right:省略默認len(slice1) // max: 省略默認len(slice1) // len = right-left+1 // cap = max-left t.Log(slice2, len(slice2), cap(slice2)) // 1 2 3 4 5] 5 5 slice3 := slice1[1:] t.Log(slice3, len(slice3), cap(slice3)) // [2 3 4 5] 4 4 slice4 := slice1[:2] t.Log(slice4, len(slice4), cap(slice4)) // [1 2] 2 5 slice5 := slice1[1:2] t.Log(slice5, len(slice5), cap(slice5)) // [2] 1 4 slice6 := slice1[:2:5] t.Log(slice6, len(slice6), cap(slice6)) // [1 2] 2 5 slice7 := slice1[1:2:2] t.Log(slice7, len(slice7), cap(slice7)) // [2] 1 1 }
遍歷
切片有3種遍歷方式
/** * 切片遍歷 */ func TestSliceTravel(t *testing.T) { slice1 := []int{1, 2, 3, 4} for i := 0; i < len(slice1); i++ { t.Log(slice1[i]) } for idx, e := range slice1 { t.Log(idx, e) } for _, e := range slice1 { t.Log(e) } }
反轉
func TestSliceReverse(t *testing.T) { a := []int{1, 2, 3, 4, 5} for left, right := 0, len(a)-1; left < right; left, right = left+1, right-1 { a[left], a[right] = a[right], a[left] } t.Log(a, len(a), cap(a)) // [5 4 3 2 1] 5 5 }
拷貝
開發中會經常的把一個變量複製給另一個變量,那麼這個過程,可能是深淺拷貝,那麼今天幫大家區分一下這兩個拷貝的區別和具體的區別
深拷貝
拷貝的是數據本身,創造一個樣的新對象,新創建的對象與原對象不共享內存,新創建的對象在內存中開闢一個新的內存地址,新對象值修改時不會影響原對象值。既然內存地址不同,釋放內存地址時,可分別釋放
值類型的數據,默認賦值操作都是深拷貝,Array、Int、String、Struct、Float,Bool。引用類型的數據如果想實現深拷貝,需要通過輔助函數完成
比如golang深拷貝copy 方法會把源切片值(即 from Slice )中的元素複製到目標切片(即 to Slice )中,並返回被複制的元素個數,copy 的兩個類型必須一致。copy 方法最終的 複製結果取決於較短的那個切片 ,當較短的切片複製完成,整個複製過程就全部完成了
/** * 深拷貝 */ func TestSliceDeepCopy(t *testing.T) { slice1 := []int{1, 2, 3, 4, 5} slice2 := make([]int, 5, 5) // 深拷貝 copy(slice2, slice1) t.Log(slice1, len(slice1), cap(slice1)) // [1 2 3 4 5] 5 5 t.Log(slice2, len(slice2), cap(slice2)) // [1 2 3 4 5] 5 5 slice1[1] = 100 t.Log(slice1, len(slice1), cap(slice1)) // [1 100 3 4 5] 5 5 t.Log(slice2, len(slice2), cap(slice2)) // [1 2 3 4 5] 5 5 }
淺拷貝
拷貝的是數據地址,只複製指向的對象的指針,此時新對象和老對象指向的內存地址是一樣的,新對象值修改時老對象也會變化。釋放內存地址時,同時釋放內存地址。
引用類型的數據,默認全部都是淺拷貝,Slice、Map等
目的切片和源切片指向同一個底層數組,任何一個數組元素改變,都會同時影響兩個數組。
/** * 淺拷貝 */ func TestSliceShadowCopy(t *testing.T) { slice1 := []int{1, 2, 3, 4, 5} // 淺拷貝(注意 := 對於引用類型是淺拷貝,對於值類型是深拷貝) slice2 := slice1 t.Logf("%p", slice1) // 0xc00001c120 t.Logf("%p", slice2) // 0xc00001c120 // 同時改變兩個數組,這時就是淺拷貝,未擴容時,修改 slice1 的元素之後,slice2 的元素也會跟着修改 slice1[0] = 10 t.Log(slice1, len(slice1), cap(slice1)) // [10 2 3 4 5] 5 5 t.Log(slice2, len(slice2), cap(slice2)) // [10 2 3 4 5] 5 5 // 注意下:擴容後,slice1和slice2不再指向同一個數組,修改 slice1 的元素之後,slice2 的元素不會被修改了 slice1 = append(slice1, 5, 6, 7, 8) slice1[0] = 11 // 這裏可以發現,slice1[0] 被修改為了 11, slice1[0] 還是10 t.Log(slice1, len(slice1), cap(slice1)) // [11 2 3 4 5 5 6 7 8] 9 10 t.Log(slice2, len(slice2), cap(slice2)) // [10 2 3 4 5] 5 5 }
在複製 slice 的時候,slice 中數組的指針也被複制了,在觸發擴容邏輯之前,兩個 slice 指向的是相同的數組,觸發擴容邏輯之後指向的就是不同的數組了
擴容
擴容會發生在slice append的時候,當slice的cap不足以容納新元素,就會進行擴容
源碼: http://github.com/golang/go/...
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 { // Check 0 < newcap to detect overflow // and prevent an infinite loop. for 0 < newcap && newcap < cap { newcap += newcap / 4 } // Set newcap to the requested cap when // the newcap calculation overflowed. if newcap <= 0 { newcap = cap } } } // 省略一些後續... }
- 如果新申請容量比兩倍原有容量大,那麼擴容後容量大小 等於 新申請容量
- 如果原有 slice 長度小於 1024, 那麼每次就擴容為原來的 2 倍
- 如果原 slice 大於等於 1024, 那麼每次擴容就擴為原來的 1.25 倍
內存泄露
由於slice的底層是數組,很可能數組很大,但slice所取的元素數量卻很小,這就導致數組佔用的絕大多數空間是被浪費的
Case1:
比如下面的代碼,如果傳入的 slice b
是很大的,然後引用很小部分給全局量 a
,那麼 b
未被引用的部分(下標1之後的數據)就不會被釋放,造成了所謂的內存泄漏。
var a []int func test(b []int) { a = b[:1] // 和b共用一個底層數組 return }
那麼只要全局量 a
在, b
就不會被回收。
如何避免?
在這樣的場景下注意:如果我們只用到一個slice的一小部分,那麼底層的整個數組也將繼續保存在內存當中。當這個底層數組很大,或者這樣的場景很多時,可能會造成內存急劇增加,造成崩潰。
所以在這樣的場景下,我們可以將需要的分片複製到一個新的slice中去,減少內存的佔用
var a []int func test(b []int) { a = make([]int, 1) copy(a, b[:0]) return }
Case2:
比如下面的代碼,返回的slice是很小一部分,這樣該函數退出後,原來那個體積較大的底層數組也無法被回收
func test2() []int{ s = make([]int, 0, 10000) for i := 0; i < 10000; i++ { s = append(s, p) } s2 := s[100:102] return s2 }
如何避免?
將需要的分片複製到一個新的slice中去,減少內存的佔用
func test2() []int{ s = make([]int, 0, 10000) for i := 0; i < 10000; i++ { // 一些計算... s = append(s, p) } s2 := make([]int, 2) copy(s2, s[100:102]) return s2 }
切片與數組對比
數組是一個固定長度的,初始化時候必須要指定長度,不指定長度的話就是切片了
數組是值類型,將一個數組賦值給另一個數組時,傳遞的是一份深拷貝,賦值和函數傳參操作都會複製整個數組數據,會佔用額外的內存;切片是引用類型,將一個切片賦值給另一個切片時,傳遞的是一份淺拷貝,賦值和函數傳參操作只會複製len和cap,但底層共用同一個數組,不會佔用額外的內存。
//a是一個數組,注意數組是一個固定長度的,初始化時候必須要指定長度,不指定長度的話就是切片了 a := [3]int{1, 2, 3} //b是數組,是a的一份深拷貝 b := a //c是切片,是引用類型,底層數組是a c := a[:] for i := 0; i < len(a); i++ { a[i] = a[i] + 1 } //改變a的值後,b是a的拷貝,b不變,c是引用,c的值改變 fmt.Println(a) //[2,3,4] fmt.Println(b) //[1 2 3] fmt.Println(c) //[2,3,4]
//a是一個切片,不指定長度的話就是切片了 a := []int{1, 2, 3} //b是切片,是a的一份拷貝 b := a //c是切片,是引用類型 c := a[:] for i := 0; i < len(a); i++ { a[i] = a[i] + 1 } //改變a的值後,b是a的淺拷貝,b的值改派,c是引用,c的值改變 fmt.Println(a) //[2,3,4] fmt.Println(b) //[2,3,4] fmt.Println(c) //[2,3,4]
總結
- 創建切片時可根據實際需要預分配容量,儘量避免追加過程中進行擴容操作,有利於提升性能
- 使用 append() 向切片追加元素時有可能觸發擴容,擴容後將會生成新的切片
- 使用 len()、cap()計算切片長度、容量時,時間複雜度均為 O(1),不需要遍歷切片
- 切片是非線程安全的,如果要實現線程安全,可以加鎖或者使用Channel
- 大數組作為函數參數時,會複製整個數組數據,消耗過多內存,建議使用切片或者指針
- 切片作為函數參數時,可以改變切片指向的數組,不能改變切片本身len和cap;想要改變切片本身,可以將改變後的切片返回 或者 將 切片指針 作為函數參數。
- 如果只用到大slice的一小部分,建議將需要的分片複製到一個新的slice中去,減少內存的佔用
本文由博客一文多發平台OpenWrite 發佈!
- 天才製造者:獨行俠、科技巨頭和AI|深度學習崛起十年
- Go內存管理一文足矣
- React如何原生實現防抖?
- 分佈式日誌存儲架構設計方案
- Chrome插件:雲音樂聽歌識曲
- 全場景AI推理引擎MindSpore Lite, 助力HMS Core視頻編輯服務打造更智能的剪輯體驗
- 頁面搭建系統的那些事兒
- 張文驍:遊戲開發的“零件人”夢碎之後|OneFlow U
- App 出海 —— Google 結算系統面面觀
- Curve 基於 Raft 的寫時延優化
- Pandas 中最常用的 7 個時間戳處理函數
- 實現Nest中參數的聯合類型校驗
- JDK內置鎖深入探究
- Docker 實戰教程之從入門到提高 (八)
- 騰訊三面:Cookie的SameSite瞭解吧,那SameParty呢?
- 實錄 | MegEngine 大 Kernel 卷積工程優化實踐
- 虛幻引擎 5 來了!不止 Lumen、Nanite 新技術,性能及 UI 均迎來大升級
- 前端新手快速上手APICloud App開發
- 高效使用Java構建工具|Maven篇|雲效工程師指北
- 雲音樂隱性關係鏈的探索與實踐