Golang 的引用類型底層實現

golang 有三個常用的高級類型 slice、map、channel, 它們都是引用類型. 掌握引用類型的底層原理,可以在寫程序時避免一些坑.

golang 的引用類型

golang 是一個值傳遞的語言,在函數調用時候傳遞的參數是拷貝的副本,這就意味著函數內部的變量值改變不影響原變量. 不過,熟悉 go 的人瞭解,對於 slice、map、channel 這幾個類型. 在傳遞給函數後,函數內部的變量操作依然會影響到原變量. 因為這幾個類型是引用類型.

golang 的引用類型包括 slice、map、channel、function、pointer 等. 它們在進行賦值時拷貝的是指針值,但拷貝後指針指向的地址是相同的.

本文將簡析 slice、map、channel 這三個引用類型. 從它們的底層實現上,探究在進行參數傳遞時的變量拷貝情況.


golang 的切片 slice

2.1 slice 底層結構

切片即動態數組,可以動態擴容改變數組的容量. golang 的 slice 底層結構如下所示,它是一個結構體,裡面包含了指向數組的地址,並通過 len、cap 保存數組的元素數、容量:

<code>type slice struct { 

array unsafe.Pointer // 指向數組的指針
len   int // 切片中元素的數量
cap   int // array 數組的總容量
}/<code>


2.2 切片 slice 賦值

切片 slice 的賦值操作是改變了 slice 內部結構的值. 所以賦值後改變的是指針 array 指向的地址、len 和 cap 值. 賦值操作的左、右倆個切片的 array 指向的是同一個數組,所以它們的數組中元素的值也會一起發生改變.


2.3 切片的拷貝

考慮到切片 slice 的結構,對於切片直接用 = 拷貝,實際上是淺拷貝,只是改變了指針的指向,並沒有改變數組中元素的值. 對於深度拷貝的需求,可以藉助 copy 內置函數完成. 兩種拷貝的方式如下:

  • 深度拷貝: copy(sliceA, sliceB)
  • 淺拷貝: sliceA = sliceB

切片之間的複製會拷貝數組指針、cap、len 值,但數組指針指向的是同一個地址. 如果想做深度拷貝,即將指針指向的數組內容而不是指針值進行拷貝. 可以使用內置的 copy 函數進行切片拷貝. 如下所示,使用 copy 進行復制,會改變 s2 地址的內存內的數組值.

<code>var s1 = []int{1, 2}        // 初始化一個切片
var s2 = make([]int, 2) // 初始化一個空的切片
,cap為2copy(s2, s1) // 將s1拷貝給s2s2[0] = 99 // 改變s2[0]fmt.Println(s1[0]) // 打印 1 而不是 99/<code>

2.3 切片 slice 函數傳遞

golang 函數的參數傳遞都是值傳遞,而 map、channel、slice 都是引用類型,會傳遞指針值. 但是,切片的結構及擴容機制特殊.

在切片進行復制時,會將切片的值(指針、cap、len)複製了一份. 在函數內部可以改變原切片的值.


但是,當涉及到 append 觸發擴容時,原來的指針指向的地址會發生變化,之後再對數組值進行更改,原切片將不受影響.

<code>//定義一個函數,給切片添加一個元素
func addOne(s []int) {
s[0] = 4  // 可以改變原切片值
s = append(s, 1)  // 擴容後分配了新的地址,原切片將不再受影響
s[0] = 8
}
var s1 = []int{2} // 初始化一個切片addOne(s1)
// 調用函數添加一個切片fmt.Println(s1)     // 輸出一個值 [4]/<code>

2.4 切片 slice 的擴容

當使用 append(slice,data) 時候,Golang 會檢查底層的數組的長度是否已經不夠,如果長度不夠,Golang 則會新建一個數組,把原數組的數據拷貝過去,再將 slice 中的指向數組的指針指向新的數組。

其中新數組的長度一般是老數組的倆倍,當然,如果一直是倆倍增加,那也會極大的浪費內存. 所以在老數組長度大於 1024 時候,將每次按照不小於 25% 的漲幅擴容.

slice 增加長度的源碼在 src/runtime/slice.go 的 growslice 函數中.


golang 字典 map

map 字典是 golang 中高級類型之一,它提供鍵值對形式的存儲. 它也是引用類型,參數傳遞時其內部的指針被複制,指向的還是同一個內存地址. 當對賦值後的左值進行修改時,是會影響到原 map 值的.

map 的底層本質上是實現散列表,它解決碰撞的方式是拉鍊法. map 在進行擴容時不會立即替換原內存,而是慢慢的通過 GC 方式釋放.


3.1 hmap 結構

以下是 map 的底層結構,其源碼位於 src/runtime/map.go 中,結構體主要是 hmap .

<code>// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)

extra *mapextra // optional fields
}/<code>

上述代碼中 buckets、oldbuckets 是指向存儲鍵值的內存地址, 其中 oldbuckets 用於在擴容時候,指向舊的 bucket 地址,再下次訪問時不斷的將 oldbuckets 值轉移到 buckets 中. oldbuckets 並不直接釋放內存,而是通過不引用,交由 gc 釋放內存

.


3.2 散列表和 bucket ( a bucket for a go map)

hmap 中核心的結構是 buckets,它是 bucket 數組,其中每個 bucket 是一個鏈表. 這個結構其實就是散列表的實現,通過拉鍊法消除 hash 衝突. 使得散列表能夠存儲更多的元素,同時避免過大的連續內存申請. 如下圖 1,是 golang buckets 數組在內存中的形式,buckets 數組的每個元素是鏈表的頭節點.

Golang 的引用類型底層實現

圖 1 內存


在哈希表結構中有一個加載因子(即 loadFactor), 它一般是散列包含的元素數除以位置總數. 加載因子越高,衝突產生的概率越高. 當達到一定閾值時,就該為哈希表進行擴容了,否則查詢效率將會很低.


當 golang map 的加載因子大於閾值時,len(map) / 2 ^ B > 6.5 時 ,就會對 map 對象進行擴容. 擴容不會立刻釋放掉原來的 bucket 內存,而是由 oldbucket 指向,併產生新的 buckets 數組並由指針 buckets 指向. 在再次訪問原數據時,再依次將老的 bucket 移到新的 buckets 數組中. 同時解除對老的 bucket 的引用,GC 會統一釋放掉這些內存.


3.3 哈希函數

哈希函數是哈希表的特點之一,通過 key 值計算哈希,快速映射到數據的地址. golang 的 map 進行哈希計算後,將結果分為高位值和低位值,其中低位值用於定位 buckets 數組中的具體 bucket,而高位值用於定位這個 bucket 鏈表中具體的 key .


通道channel

4.1 hchan 結構

chan 源碼位於 src/runtime/chan.go 中,其結構體為 hchan,其中主要包括 buf、sendx、recvx、sendq、recvq 等.

<code>ch0 := make(chan int)   // 無緩衝通道
ch1 := make(chan int, 3)  // 有緩衝通道/<code>


主要結構的作用:

  • buf: 有緩衝通道用於存儲緩存數據的空間, 它是一個循環鏈表.
  • sendx 和 recvx: 用於記錄循環鏈表 buf 中的發送或者接受的 index.
  • sendq 和 recvq: 是倆個雙向隊列,分別是發送、接受的 goroutine 抽象出來的 sudog 結構體的隊列.
  • lock: 互斥鎖. 在 send 和 recv 操作時鎖住 hchan.


4.2 make 創建通道

使用 make 可以創建通道,如下示例:

<code>ch0 := make(chan int)   // 無緩衝通道
ch1 := make(chan int, 3)  // 有緩衝通道/<code>

創建通道實際上是創建了一個 hchan 結構體,返回指針 ch0 . chan 在 go 語言中是引用類型, 在參數傳遞過程是複製的是這個指針值,.


4.3 send 和 recv

首先會使用 lock 鎖住 hchan. 然後以 sendx 或 recvx 序號,在循環鏈表 buf 指定的位置上找到數據,將數據 copy 到 goroutine 或者時從 goroutine copy 的 buf 上. 然後釋放鎖.



1. Golang 切片Slice原理詳解和使用技巧[https://roperluo.cn/index.php/archives/22/]

2. Golang:值類型與引用類型[https://www.jianshu.com/p/18d3bde7d835]

3. Golang map的底層實現[https://www.cnblogs.com/maji233/p/11070853.html]

4. 圖解Golang的channel底層原理[https://juejin.im/post/5cb3445f6fb9a068b748ab75]

5. Golang 中 slice cap 增長模式小記[https://h3l.github.io/posts/slice-append-grow-analyze/]


分享到:


相關文章: