一文詳解Go語言中切片的底層原理
大家好,我是二條,在上一篇我們學習了輕松理解Go中的內存逃逸問題,今天接著我們學習Go中切片的相關知識。本文不會單獨去講解切片的基礎語法,只會對切片的底層和在開發(fā)中需要注意的事項作分析。
在Go語言中,切片作為一種引用類型數據,相對數組而言是一種動態(tài)長度的數據類型,使用的場景也是非常多。但在使用切片的過程中,也有許多需要注意的事項。例如切片函數傳值、切片動態(tài)擴容、切片對底層數組的引用問題等等。今天分享的主題,就是圍繞切片進行。
切片的函數傳值
切片作為一種引用數據類型,在作為函數傳值時,如果函數內部對切片做了修改,會影響到原切片上。
package?main
import?"fmt"
func?main()?{
?sl1?:=?make([]int,?10)
?for?i?:=?0;?i?<?10;?i++?{
?}
?fmt.Println("切片sl1的值是",?sl1)
?change(sl1)
?fmt.Println("切片sl2的值是",?sl1)
}
func?change(sl?[]int)?{
?sl[0]?=?100
?fmt.Println("形參sl切片的值是",?sl)
}打印上述代碼:
切片sl1的值是 [1 2 3 4 5 6 7 8 9 10]
形參sl切片的值是 [100 2 3 4 5 6 7 8 9 10]
切片sl2的值是 [100 2 3 4 5 6 7 8 9 10]
通過上面的結果,不難看出來,在函數change()中修改了切片,原切片的小標0的值也發(fā)生了改變。這是因為切片是一種引用類型數據,在傳遞到函數change()時,使用的都是相同的底層數組(切片底層本質仍是一個數組)。因此,底層數組的值改變了,就會影響到其他指向該數組的切片上。
針對上述的問題,有什么解決方案,使得傳遞切片,不會影響原切片的值呢?可以采用切片復制的方式,重新創(chuàng)建一個新的切片當做函數的參數進行傳遞。
package?main
import?"fmt"
func?main()?{
?sl1?:=?make([]int,?10)
?for?i?:=?0;?i?<?10;?i++?{
??sl1[i]?=?i?+?1
?}
?fmt.Println("切片sl1的值是",?sl1)
?//?創(chuàng)建一個新的切片,當做參數傳遞。
?sl2?:=?make([]int,?10)
?copy(sl2,?sl1)
?change(sl2)
?fmt.Println("切片sl2的值是",?sl1)
}
func?change(sl?[]int)?{
?sl[0]?=?100
?fmt.Println("形參sl切片的值是",?sl)
}打印上述代碼:
切片sl1的值是 [1 2 3 4 5 6 7 8 9 10]
形參sl切片的值是 [100 2 3 4 5 6 7 8 9 10]
切片sl2的值是 [1 2 3 4 5 6 7 8 9 10]
通過上述運行結果,在change函數中,對切片下標為0做了值修改,對切片sl1的值沒有影響。
切片動態(tài)擴容機制
在Go中,切片是一種動態(tài)長度的引用數據類型。當切片的容量不足以容納新增加的元素時,底層會實現(xiàn)自動擴容用來存儲新添加的元素。先查看下面的一段實例代碼,證明切片存在動態(tài)擴容。
package?main
import?"fmt"
func?main()?{
?var?sl1?[]int
?fmt.Println("切片sl1的長度是",?len(sl1),?",容量是",?cap(sl1))
?for?i?:=?0;?i?<?10;?i++?{
??sl1?=?append(sl1,?i)
??fmt.Println("切片sl1的長度是",?len(sl1),?",容量是",?cap(sl1))
?}
}打印上述代碼:
切片sl1的長度是 0 ,容量是 0
切片sl1的長度是 1 ,容量是 1
切片sl1的長度是 2 ,容量是 2
切片sl1的長度是 3 ,容量是 4
切片sl1的長度是 4 ,容量是 4
切片sl1的長度是 5 ,容量是 8
切片sl1的長度是 6 ,容量是 8
切片sl1的長度是 7 ,容量是 8
切片sl1的長度是 8 ,容量是 8
切片sl1的長度是 9 ,容量是 16
切片sl1的長度是 10 ,容量是 16
可以看出,切片的長度是隨著for操作,依次遞增。但切片的容量就不是依次遞增,從明面上看,有點像以2的倍數在增加。具體增加的規(guī)律是怎么樣的呢?
要弄明白Go中的切片是如何實現(xiàn)擴容的,這就需要關注一下Go的版本。
在Go的1.18版本以前,是按照如下的規(guī)則來進行擴容:
1、如果原有切片的長度小于 1024,那么新的切片容量會直接擴展為原來的 2 倍。
2、如果原有切片的長度大于等于 1024,那么新的切片容量會擴展為原來的 1.25 倍,這一過程可能需要執(zhí)行多次才能達到期望的容量。
3、如果切片屬于第一種情況(長度小于 1024)并且需要擴容的容量小于 1024 字節(jié),那么新的切片容量會直接增加到原來的長度加上需要擴容的容量(新容量=原容量+擴容容量)。
從Go的1.18版本開始,是按照如下的規(guī)則進行擴容:
1、當原slice容量(oldcap)小于256的時候,新slice(newcap)容量為原來的2倍。
2、原slice容量超過256,新slice容量newcap = oldcap + (oldcap+3*256) / 4。
使用上面的代碼,將循環(huán)的值調到非常大,例如10w,甚至更大,你會發(fā)現(xiàn)切片的容量和長度始終是比較趨近,而不是差距很大。
例如我將循環(huán)設置到100w,這里就只打印最后幾行結果,不進行全部打印。
package?main
import?"fmt"
func?main()?{
?var?sl1?[]int
?fmt.Println("切片sl1的長度是",?len(sl1),?",容量是",?cap(sl1))
?for?i?:=?0;?i?<?1000000;?i++?{
??sl1?=?append(sl1,?i)
??fmt.Println("切片sl1的長度是",?len(sl1),?",容量是",?cap(sl1))
?}
}打印上述代碼結果為:
.................
切片sl1的長度是 999990 ,容量是 1055744
切片sl1的長度是 999991 ,容量是 1055744
切片sl1的長度是 999992 ,容量是 1055744
切片sl1的長度是 999993 ,容量是 1055744
切片sl1的長度是 999994 ,容量是 1055744
切片sl1的長度是 999995 ,容量是 1055744
切片sl1的長度是 999996 ,容量是 1055744
切片sl1的長度是 999997 ,容量是 1055744
切片sl1的長度是 999998 ,容量是 1055744
切片sl1的長度是 999999 ,容量是 1055744
切片sl1的長度是 1000000 ,容量是 1055744
上面講到的不同版本之間的規(guī)律,這個規(guī)律是怎么來的,我們可以直接源代碼。
首先看1.18版本開始的底層代碼,你需要找到Go的源碼文件,路徑為runtime/slice.go,該文件中有一個名為growslice()函數。這個函數的代碼很長,我們重點關注下述代碼,其他的代碼除了做一些邏輯處理,還處理了內存對齊問題,關于內存對齊就不在本篇提及。
//?type切片期望的類型,old舊切片,cap新切片期望最小的容量
func?growslice(et?*_type,?old?slice,?cap?int)?slice?{
??newcap?:=?old.cap//?老切片容量
??doublecap?:=?newcap?+?newcap//?老切片容量的兩倍
??if?cap?>?doublecap?{//?期望最小的容量?>?老切片的兩倍(新切片的容量?=?2?*?老切片的容量)
????newcap?=?cap
??}?else?{
????const?threshold?=?256
????if?old.cap?<?threshold?{
??????newcap?=?doublecap
????}?else?{
??????for?0?<?newcap?&&?newcap?<?cap?{
????????//?在2倍增長以及1.25倍之間尋找一種相對平衡的規(guī)則
????????newcap?+=?(newcap?+?3*threshold)?/?4
??????}
??????if?newcap?<=?0?{
????????newcap?=?cap
??????}
????}
??}
}接著來看1.18版本之前的源代碼,可以直接通過GitHub上進行查看。1.16GitHub源碼地址。
//?type切片期望的類型,old舊切片,cap新切片期望最小的容量
func?growslice(et?*_type,?old?slice,?cap?int)?slice?{
???newcap?:=?old.cap
?doublecap?:=?newcap?+?newcap
?if?cap?>?doublecap?{//?需要兩倍擴容時,則直接擴容為兩倍
??newcap?=?cap
?}?else?{
??if?old.cap?<?1024?{//?小于1024,直接擴容為2倍
???newcap?=?doublecap
??}?else?{
???//?原?slice?容量超過?1024,新?slice?容量變成原來的1.25倍
???for?0?<?newcap?&&?newcap?<?cap?{
????newcap?+=?newcap?/?4
???}
???if?newcap?<=?0?{
????newcap?=?cap
???}
??}
?}
}通過上述的代碼,已經總結出切片擴容的規(guī)律。如果你在實際的案例中,并非按照總結的規(guī)律進行擴容,這是因為切片擴容之后還考慮了內存對齊問題,也就是上述growslic()函數剩余部分。
切片操作對數組的影響
在Go中,切片和數組有一些共性,也有一些不同之處。
相同之處:
1、切片和數組在定義時,需要指定內部的元素類型,一旦定義元素類型,就只能存儲該類型的元素。
2、切片雖然是單獨的一種類型,底層仍然是一個數組,在Go源碼中,有這樣一段定義,通過閱讀這段代碼,可以總結出切片底層是一個struct數據結構。
type?slice?struct?{
?array?unsafe.Pointer?#?指向底層數組的指針
?len???int?#?切片的長度,也就是說當前切片中的元素個數
?cap???int?#?切片的容量,也就是說切片最大能夠存儲多少個元素
}
不同之處:
1、切片和數組最大的不同之處,在于切片的長度和容量是動態(tài)的,可以根據實際情況實現(xiàn)動態(tài)擴容,而數組是固定長度,一經定義長度,存儲的元素就不能超過定義時的長度。
下面有這樣一種場景,需要特別注意。
從一個切片中生產新的切片,使用截取實現(xiàn)。
func?clipSliceBySlice()?{
?s?:=?make([]int,?1000000)
?start?:=?time.Now()
?_?=?s[0:500000]
?elapsed?:=?time.Since(start)
?fmt.Printf("Time?taken?to?generate?slice?from?slice:?%s\n",?elapsed)
}從一個切片中生成新的切片,使用copy()函數實現(xiàn)。
func?clipSliceByCopy()?{
?s?:=?make([]int,?1000000)
?start?:=?time.Now()
?s2?:=?make([]int,?500000)
?copy(s2,?s[0:500000])
?elapsed?:=?time.Since(start)
?fmt.Printf("Time?taken?to?copy?slice?using?copy()?function:?%s\n",?elapsed)
}這兩段代碼,都是從一個切片中生成一個新的切片,但誰的性能效果更好呢?
1、第一種方式,生成新切片,底層仍然與原切片共用一個底層數組。在生成切片時,效率會更高一些。但存在一個問題,如果原切片和新切片對自身的元素做了修改,底層數組也會隨著改變,這樣會導致另外一個切片也跟著受影響。這種方式雖然效率更高,但是共用同一個底層數組,會存在數據安全問題。
2、第二種方式,生成新切片,使用的是copy()函數實現(xiàn),會發(fā)生一個內存拷貝。這樣新切片就是存儲在新的內存中,其底層的數組和原切片底層的數組,不在是共享。不管是老切片還是新切片內部元素發(fā)生變化,都只會影響到自身。這種方式雖然消耗的內存更大,但數據更加安全。
使用歸納
在實際的開發(fā)過程中,我們一般使用切片的場景要比數組多,這是為什么呢?
1、動態(tài)擴展:切片可以動態(tài)擴展或縮減,而數組的長度是固定的。使用切片可以更方便地處理不確定長度的數據集。
2、內存效率:切片的底層實現(xiàn)是數組,但是通過切片可以對底層的數組進行引用,避免了復制底層數據的開銷。因此,使用切片可以更高效地處理大量數據。
3、零值初始化:切片有一個默認值為0的長度和容量,這使得初始化切片更加方便。
4、內置函數:切片有許多內置函數,如append()、copy()等,這些函數可以更方便地操作切片。
本文總結
根據上面的幾個小問題進行演示,我們在日常開發(fā)中,使用切片重點可以關注在動態(tài)擴容和引用傳值上面,這也是經常出現(xiàn)問題的點。下面細分幾點進行歸納:
1、由于切片是引用類型,因此容易出現(xiàn)多個變量引用同一個底層數組,導致內存泄露和意外修改數據的情況。
2、當切片長度超過底層數組容量時,可以導致切片重新分配內存,這可能會帶來性能問題。
3、在使用切片時沒有正確計算長度和容量,也可能導致意料之外的結果。
4、切片常常被用作函數參數,由于其引用類型的特性,可能會導致函數內對切片數據的修改影響到外部變量。
5、如果切片的底層數組被修改,可能會對所有引用該底層數組的切片數據造成影響。
到此這篇關于一文詳解Go語言中切片的底層原理的文章就介紹到這了,更多相關Go切片內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!

