聊聊內存中的Slice操作
本文主要關注 slice 的相關操作:
- 元素賦值(修改)
- make
- copy
- make and copy
- append
環境
- OS : Ubuntu 20.04.2 LTS; x86_64
- Go : go version go1.16.2 linux/amd64
聲明
操作系統、處理器架構、Golang版本不同,均有可能造成相同的源碼編譯后運行時內存地址、數據結構不同。
本文僅保證學習過程中的分析數據在當前環境下的準確有效性。
代碼清單
- package main
- import "fmt"
- func main() {
- var src = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
- src[3] = 100
- //src[13] = 200
- dst := makeSlice()
- makeSliceCopy(src)
- growSlice(src)
- copySlice(dst, src)
- sliceStringCopy([]byte("hello world"), "hello slice")
- }
- //go:noinline
- func sliceStringCopy(slice []byte, s string) {
- copy(slice, s)
- PrintInterface(string(slice))
- }
- //go:noinline
- func copySlice(dst []int, src []int) {
- copy(dst, src)
- PrintInterface(dst)
- }
- //go:noinline
- func growSlice(slice []int) {
- slice = append(slice, 11)
- PrintInterface(slice)
- }
- //go:noinline
- func makeSliceCopy(array []int) {
- slice := make([]int, 5)
- copy(slice, array)
- PrintInterface(slice)
- }
- //go:noinline
- func makeSlice() []int {
- slice := make([]int, 5)
- //slice := make([]int, 5, 10)
- //slice := make([]int, 10, 5) // "len larger than cap in make(%v)"
- return slice
- }
- //go:noinline
- func PrintInterface(v interface{}) {
- fmt.Println("it =", v)
- }
深入內存
1. 元素賦值
該操作很簡單,直接通過偏移量定位元素內存并賦值,對應一條機器指令:

如果如下元素索引超過runtime.slice.cap, 則會panic。
- src[13] = 200

查看可執行程序,Golang編譯器發現代碼異常之后,直接使用runtime.panicIndex函數調用替換了元素賦值及之后的所有操作,退出程序。

這很令人好奇:明明編譯時期發現了代碼邏輯錯誤,但并沒有終止編譯過程,而是把它變成一個運行時異常。難道運行時異常更好嗎?
針對這個問題暫時沒有找到合理的答案,只能猜測這是編譯器為了應對各種代碼場景的一個通用編譯處理邏輯,而不是僅僅為了處理本例中的情況。
2. make
使用make關鍵字動態創建 slice。編譯之后make會變成什么指令,視情況而定。
代碼清單中第42行的makeSlice函數編譯之后,對應的機器指令如下:

可以看到,make關鍵字編譯之后,變成了 runtime.makeslice 函數調用,其實現如下:
- func makeslice(et *_type, len, cap int) unsafe.Pointer {
- // 計算需要分配的內存字節數
- mem, overflow := math.MulUintptr(et.size, uintptr(cap))
- if overflow || mem > maxAlloc || len < 0 || len > cap {
- mem, overflow := math.MulUintptr(et.size, uintptr(len))
- if overflow || mem > maxAlloc || len < 0 {
- panicmakeslicelen()
- }
- panicmakeslicecap()
- }
- // 直接分配內存
- return mallocgc(mem, et, true)
- }
以上代碼非常簡單,有幾個判斷條件稍微解釋下:
(1)overflow表示元素大小和元素數量的乘積是否溢出,即是否大于64位無符號整數的最大值,肯定是不能大于的;
(2)maxAlloc的值為 0x1000000000000,實際上大多數64位處理器和操作系統的內存可尋址范圍并不是64位,而是不超過48位,這是Golang一個內存分配和校驗邏輯;
(3)len>cap時,Golang編譯器會進行檢查 ,編譯失敗。
另外,在Golang源碼中,有個 runtime.makeslice64 函數,并沒有出現在編譯后的可執行程序中。在 Go 編譯器代碼中看到應該是和32位程序編譯相關。我們更關心64位程序,所以不再深究。
3. copy
代碼清單中第23行的copySlice函數編譯之后,對應的機器指令如下:

將其翻譯為Golang偽代碼,大意如下:
- func copySlice(dst []int, src []int) {
- n := len(dst)
- if n > len(src) {
- n = len(src)
- }
- if &dst[0] != &src[0] {
- runtime.memmove(&dst[0], &src[0], len(dst)*8)
- }
- PrintInterface(dst)
- }
仔細閱讀以上指令代碼,確定其邏輯與 runtime.slicecopy 函數相匹配,也就是說copy關鍵字編譯之后變成了runtime.slicecopy函數調用。但是編譯器對runtime.slicecopy函數進行了內聯優化,所以最終并不能看到直接的runtime.slicecopy函數調用。
在Golang中,copy關鍵字可以用于把 string 對象拷貝到[]byte對象中;因為字符串類型還沒有學習到,所以暫時擱置這種特殊情況。
4. make and copy
當make和copy兩個關鍵字一起使用時,又發生了新變化。
代碼清單中第35行的makeSliceCopy函數編譯之后,對應的機器指令如下:

可以清楚的看到,當make和copy兩個關鍵字一起使用時,被Golang編譯器合并成了 runtime.makeslicecopy 函數調用。該函數源代碼邏輯非常清晰,此處不再贅述。
5. append
代碼清單中第29行的growSlice函數對已經滿的 slice 進行 append 操作。
編譯之后,對應的機器指令如下:

以上代碼邏輯是:首先進行len(slice)+1和cap(slice)比較,對已經滿的 slice 進行 append 操作時,將觸發底層數組的長度擴增(分配新的數組),將其翻譯為Golang偽代碼,大意如下:
- func growSlice(slice []int) {
- if len(slice) + 1 > cap(slice) {
- slice = runtime.growslice(element_type_pointer, slice, 11)
- }
- // cap(slice) == 20
- slice[len(slice)] = 17
- PrintInterface(slice)
- }
runtime.growslice 函數的功能是:slice 進行append操作時,如果該slice已經滿了,調用該函數重新分配底層數組進行擴容。
在本例中,
- 原 slice 的容量是10,調用runtime.growslice函數之后,容量變為20。
- slice元素是 int 類型(element_type_pointer),關于該類型的分析可以閱讀內存中的整數 。
通過以上學習研究,對slice的各種操作有了本質上的了解,相信用起來更加得心應手。
本文轉載自微信公眾號「Golang In Memory」