淺析Golang map的實現原理


Golang中的map底層使用的數據結構是hash table,基本原理就和基礎的散列表一致,重點是Golang在設計中采用了分桶(Bucket),每個桶里面支持多個key-value元素的這種思路,具體可以參考下面的圖[圖片來源1]:

rOtNVCnkU4xZ0sIjt-KqTNp-v9HTDhe021gvSENCk-0

可以看到上面的B就是Bucket,每個桶中會存儲多組K/V,map的具體實現在Go源碼中src/runtime/map.go2實現,源文件的頭部已經對實現做了比較詳細的解釋,默認情況下map首先是指向一個桶的數組,每個桶中最多包含8個key-value對,對於輸入的key首先經過散列函數計算得出散列值,其實就是1個數字,大部分計算機都是64位的,所以通常這是一個uint64的值,這個哈希值的低位用於選擇桶,確定桶之后,哈希值的高位用於定位到桶中的條目,如果桶中的key-value滿了,則會標記當前桶溢出同時鏈接到額外的新桶,將元素放進去。

在map的源碼實現中,map底層是一個hmap的結構體:

jJwjwDltj9ObXHUT-x_UoXmwZPvJQO_ye7KUwl11MCo

注意到其中有一個元素B,這個B就表示要使用哈希值的低位的位數,用來計算對應的桶,比如使用低8位,那么這里B就等於8,那么桶的個數就是2的8次方,也就是256個桶,這樣直接通過與運算就可以將插入的元素定位到桶中去:

image-20220215213519165

看上面這個圖就一目了然了,在4個桶的情況下,直接用64為哈希值和桶的掩碼做與運算,也就是取低2位的數值,直接作為桶數組的下標在O(1)的情況下定位到桶。

然后可以看下每個桶中的元素是怎么存的,每個桶是由bmap結構體來表示:

image-20220215213621542

可以看到這里面有個tophash屬性,是一個uint8的數組,其中bucketCnt的值在源代碼最上面有定義,大小為8,也就是每個桶中可以放8個元素,這里uint8僅僅存放hash值的高8位,可以參考下面這個圖:

image-20220215213703905

如果兩個不同的key被定位到同一個桶中,其實就可以認為出現了哈希沖突,那么這種情況下就依次按照順序從前往后將hash值的高8位寫入到數組空閑的元素中,這里思路和鏈表法是一致的,之所以這么設計是為了提高哈希沖突時比較的速度,因為比較1個字節要比比較一個很長的key快,這時查找key的過程是先通過計算得到的哈希值定位到桶,然后依次遍歷tophash和計算hash值的高8位是否相等,如果相等則說明元素大概率是找到了,這個時候再詳細比較key是否完全一致即可,否則將繼續尋找,直到找到最后一個元素為止,如果都找不到說明要查找的key是不存在的,如果當前桶存儲滿了,則會繼續掛上新的存儲桶,也叫溢出桶,通過這種方式來進行擴展:

image-20220215213853951

這里每個bmap中存在一個overflow指針指向下一個Bucket,和之前一樣繼續向后存儲沖突的key/value,但是隨着桶的增多,搜索元素的速度也會下降,所以不會無限的增加桶,而是會在滿足某些條件的情況下進行擴容,具體在每個桶中完整的key和value都是連續存儲的,類似於下面這樣:

image-20220215214843989

這樣存儲相比key-value-key-value...的存儲方式好處就是可以避免內存填充對齊,從而減少空間的占用,所以上面我們看到的bmap結構體在運行時實際的結構是下面這樣的:

type bmap struct {
  topbits  [8]uint8
  keys     [8]keytype
  values   [8]valuetype
  pad      uintptr
  overflow uintptr
}

其中keys和values以數組方式分別進行key和value的連續存儲。

然后可以再簡單看下擴容,說到擴容在普通散列表中會有裝載因子的概念,即實際的元素數量/總的數組長度,當裝載因子不斷增大時,發生哈希沖突的可能性也會越大,所以這個時候就需要進行擴容操作,裝載因子的范圍通常在0~1之間,同樣在Go的map中也有裝載因子的概念,只是定義略有不同,這里裝載因子=元素個數/Bucket個數=元素個數/\(2^B\),最理想的情況下是每個桶中只有1個元素,這樣查找的復雜度是O(1),但是會帶來很大的空間浪費,空間利用率最好的情況是每個桶中都裝滿8個元素甚至會有比較多的溢出桶,但是這樣查找的效率會降低,所以需要尋找一個折中的負載因子閾值,對map進行擴容,可以看到在Go中裝載因子很容易就大於1,通常在1~8之間,實際上默認的裝載因子閾值為6.5,也就是說比較良好的情況下,平均每個桶中的元素不超過6.5個,是空間和時間上比較好的平衡,在源碼中可以看到擴容因子的定義:

image-20220215215053576

那么具體的擴容時機是什么樣的呢?在插入函數的源碼中有對應的邏輯:

image-20220215215114687

可以看出當滿足overLoadFactor或tooManyOverflowBuckets的情況下,且當前沒有在擴容狀態時,則開始執行擴容,具體overLoadFactor和tooManyOverflowBuckets的代碼如下:

image-20220215215149834

image-20220215215200056

這兩段代碼其實都比較簡單,overLoadFactor其實就是說當前的元素總數比較多,負載因子已經超過擴容因子6.5時,會進行擴容操作,具體的細節可以再看bucketShift函數:

image-20220215215229209

這里b傳入的就是B,也就是低位的位數,這里goarch.PtrSize具體在internal/goarch/goarch.go中定義如下:

image-20220215215250334

所以在32位系統上就是,64位系統上就是8,所以上面就是B和63進行與運算,正常B是不可能超過63的,所以bucketShift的返回值也就是\(2^B\),判斷的條件就變為:count > 13*(\(2^B\)/2),也就是元素個數大於桶個數*6.5時,會觸發擴容。

另外的tooManyOverflowBuckets看字面意思就是當溢出桶比較多時,也會出發擴容,具體的就是noverflow大於\(2^B\)時,也就是溢出桶的個數超過原始桶的個數時說明溢出桶非常多了,開始觸發擴容,當然B大於15時也是按15算,也就是說溢出桶永遠不能超過\(2^{15}\)也就是32768個。

上面就簡單說了一下擴容的操作,當然擴容也采用了漸進式的方式進行搬遷,而不是全量進行遷移,可以避免程序的阻塞,也就是說當執行插入、刪除等操作時都會嘗試進行搬遷操作,查看擴容操作可以發現,當分配新的bucket后,只是將老的bucket掛到新的oldbuckets指針上,並沒有真正的進行遷移。

根據上面的擴容原理,如果我們能提前預知到元素的數量可以在分配map時指定元素的個數,從而避免擴容操作,Go在makemap時,指定的大小時Bucket的個數,當我們的元素數量比較多的時候,為了節省空間,不需要分配這么多Bucket,總體上保證在擴容因子之下即可,所以假如我們有10萬個元素我們可以按照下面的方式分配:

// 分配元素個數/6或者元素個數/5,使負載因子占用始終在擴容閾值之下
m := make(map[uint64]string, 100000/6)

按照上面的方式分配可以使負載因子占用始終在擴容閾值之下,從而盡量的避免擴容帶來的開銷,當然如果哈希分布不均勻,也有可能出現溢出桶過多而擴容的情況,不過通常情況下分布還是較為均勻的,這樣可以節省很多的空間。

另外map的遍歷時按照Bucket的順序遍歷,每個Bucket按照內部的數組順序遍歷,所以很容易理解其實是無序的。

根據上面的hmap和bmap定義也可以知道,實際的map占用的空間可以按照下面的公式計算:

// 這里沒有考慮擴容時的備用桶、溢出桶以及內存對齊等占用
unsafe.Sizeof(hmap) + len(map)*8*(unsafe.Sizeof(key)+unsafe.Sizeof(value))

如果時內存要求比較嚴格的情況下,例如value盡量用數字類型,如果是切片類型可以用切片指針來引用,因為指針長度是8字節,而切片引用是24個字節,而如果是結構體類型盡量用結構體指針來引用,因為Go中結構體為值拷貝占用的空間會比較大,所以無論是切片還是結構體隨着元素個數增多消耗會更明顯,所以類型這塊要做好設計。

最后就是源碼中設定不同的負載因子所進行的一些統計如下:

image-20220215220009625

reference

1.部分圖片及內容參考

2.map源碼


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM