Golang-Slice 內部實現原理解析

Golang - slice 內部實現原理解析

一.Go中的數組和slice的關係

1.數組

在幾乎所有的計算機語言中,數組的實現都是一段連續的內存空間,Go語言數組的實現也是如此,但是Go語言中的數組和C語言中數組還是有所不同的

  • C語言數組變量是指向數組第一個元素的指針
  • Go語言的數組是一個值,一個數組變量就代表整個數組,意味着Go語言的數組在傳遞的時候,傳遞是是原數組的拷貝!

這也就意味着數組在傳遞的時候,對大數組來說,內存代價會非常大,影響性能,傳遞數組指針可以解決這個問題,但是數組指針也有一個弊端:

  • 原數組的指針指向改變了,那函數裏面的指針指向也會跟着改變,某些情況下,可能會產生意想不到的bug

slice的出現,便是爲了解決這個問題

2.slice

先來看一張圖,上圖中,ptr就是指向底層數組的指針,len是指slice的長度,cap是指slice的容量

  • slice本身並不是動態數組或者數組指針,它的內部實現是通過指針引用底層數組,設置相關的屬性,將數據的讀寫操作限定在指定的區域內
  • slice本身是一個只讀讀寫,你修改的是底層數組,而不是slice本身,其工作機制類似於數組指針的一種封裝
  • slice是對數組中一個連續片段的引用,所以slice是一個引用類型

當然從宏觀和使用上來說,你可以將slice當做一個長度可變的數組,類似C++的Vector。

二.slice的初始化方式

方式1:字面量

    s = s[2:4]

指針指向s[2],容量是3,長度是2

需要注意的是,儘量不要採用字面量這種方式初始化slice,除非情況特殊,因爲一個字面量數組可以初始化很多個slice,修改一個slice,會影響另一個slice的值,因爲引用的都是同一個底層數組

比如下圖

sliceA和sliceB都是同一個底層數組,並且有重疊的部分!Array[2],30

方式2:make

    s := make([]byte, 5)

最爲安全的slice初始化方式,推薦使用,除非業務特殊你實在想讓你的slice共用同一個底層數組,再補充一個圖,來說明slice長度和容量的區別

長度4,代表此時4個元素,容量6,代表總共可以裝6個元素,還有兩個位置空閒

三.slice的擴容規則

slice可以理解爲動態數組,既然是動態數組,那必然需要進行擴容,slice擴容遵循以下規則:

  • slice容量小於1024個元素,則擴容後容量直接翻倍,
  • slice容量不小於1024個元素,則每次增加原來容量是四分之一
  • 如果擴容後,還是比底層數組的容量小,那麼slice的指針還是指向原來的底層數組。
  • 如果擴容後,超過了底層數組的容量,那麼會開闢一塊新內存,並將原來的值拷貝過來,這種情況,slice的任何操作都不會影響原底層數組

四. slice的拷貝

1.淺拷貝情況

  • 淺拷貝,拷貝的是地址,只是複製指向對象的指針
  • slice是引用類型數據,默認引用類型數據,全部都是淺拷貝,slice,Map等
    slice2 := slice1
  • slice1和slice2指向的都是同一個底層數組,任何一個數組元素被改變,都可能會影響兩個slice
  • 在slice觸發擴容操作前,slice1和slice2指向的都是相同數組,但在觸發擴容操作後,二者指向的就不一定是相同的底層數組了,具體可參考上訴slice的擴容規則

2. 深拷貝情況

  • 深拷貝,拷貝的是數據本身,會創建一個新對象
    copy(slice2, slice1)  
  • 新對象和原對象不共享內存,在新建對象的內存中開闢一個新的內存地址,新對象的值修改不會影響原對象值,既然內存地址不同,釋放內存地址時,可以分別釋放

五. slice內存泄露情況

當slice的底層數組很大,但slice所取元素數量很小時,底層數組佔據的大部分空間都是被浪費的

  • 比如b數組很大,slice a只引用了b很小的一部分,只要slice a還在,b數組就永遠不會被回收,就是造成了內存泄露!
var a []int

func test(b []int) {
    a = b[:1] // 和b共用一個底層數組
    return
}

解決方法:

  • 不再引用b數組,將需要的數據複製到一個新的slice中,這樣新slice的底層數組,就和b數組無任何關係了
var a []int

func test(b []int) {
    a = make([]int, 1)
    copy(a, b[:0])
    return
}

六. slice 非併發安全

slice不是併發安全的,要併發安全,有兩種方法:

  • 加鎖
  • channle

1.加鎖

適合於對性能要求不高的場景,畢竟鎖的粒度太大,這種方式屬於通過共享內存來實現通信

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
}

2.channle

適合於對性能要求大的場景,channle就是專用於goroutine間通信的,這種方式屬於通過通信來實現共享內存,而Go的箴言便是:儘量通過通信來實現內存共享,而不是通過共享內存來實現通信,推薦此方法!

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
}

七. 小結

根據上述內容,可以總結出以下幾點:

  • 創建slice時應根據實際需要預分配容量,避免追加過程中頻繁擴容,有助於性能提升
  • slice是非併發安全的,如要實現併發安全,請採用鎖或channle
  • 大數組作爲函數參數時,會複製整個數組,消耗過多內存,建議採用slice或指針
  • 如果只用到大的slice或數組的一部分,建議將需要部分複製到新的slice中取,減少內存佔用
  • 多個slice指向相同的底層數組時,修改其中一個slice,可能會影響其他slice的值
  • slice作爲參數傳遞時,比數組更爲高效,因爲slice本身的結構就比較小!所以你參數傳遞時,傳slice和傳slice的引用,其實開銷區別不大
  • slice在擴容時,可能會發生底層數組的變更和內存拷貝

參考:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章