大話圖解golang map


前言

網上分析golang中map的源碼的博客已經非常多了,隨便一搜就有,而且也非常詳細,所以如果我再來寫就有點畫蛇添足了(而且我也寫不好,手動滑稽)。但是我還是要寫,略略略,這篇博客的意義在於能從幾張圖片,然后用我最通俗的文字,讓沒看過源碼的人最快程度上了解golang中map是怎么樣的。

當然,因為簡單,所以不完美。有很多地方省略了細節問題,如果你覺得沒看夠,或者本來就想了解詳細情況的話在文末給出了一些非常不錯的博客,當然有能力還是自己去閱讀源碼比較靠譜。

那么下面我將從這幾個方面來說明,你先記住有下面幾個方向,這樣可以有一個大致的思路:

  • 基礎結構:golang中的map是什么樣子的,是由什么數據結構組成的?
  • 初始化:初始化之后map是怎么樣的?
  • get:如何獲取一個元素?
  • put:如何存放一個元素?
  • 擴容:當存放空間不夠的時候擴容是怎么擴的?

基礎結構

圖解


這個就是golang中map的結構,其實真的不復雜,我省略了其中一些和結構關系不大的字段,就只剩下這些了。

大話

大話來描述一些要點:

  • 最外面是hmap結構體,用buckets存放一些名字叫bmap的桶(數量不定,是2的指數倍)
  • bmap是一種有8個格子的桶(一定只有8個格子),每個格子存放一對key-value
  • bmap有一個overflow,用於連接下一個bmap(溢出桶)
  • hmap還有oldbuckets,用於存放老數據(用於擴容時)
  • mapextra用於存放非指針數據(用於優化存儲和訪問),內部的overflow和oldoverflow實際還是bmap的數組。

這就是map的結構,然后我們稍微對比總結一下。

我們常見的map如java中的map是直接拿數組,數組中直接對應出了key-value,而在golang中,做了多加中間一層,buckets;java中如果key的哈希相同會采用鏈表的方式連接下去,當達到一定程度會轉換紅黑樹,golang中直接類似鏈表連接下去,只不過連接下去的是buckets。

源碼一瞥

  • 下面附上源碼中它們的樣子,方便之后你自己閱讀的時候有個印象(注意源碼中的樣子和編譯之后是不同的喲,golang會根據map存放的類型不同來搞定它們實際的樣子)

那么看完結構你肯定會有疑問?為什么要多一層8個格子的bucket呢?我們怎么確定放在8個格子其中的哪個呢?帶着問題往下看。

初始化

源碼一瞥

初始化就不需要圖去說明了,因為初始化之后就是產生基礎的一個結構,根據map中存放的類型不同。這里主要說明一下,初始化的代碼放在什么位置。我也刪除了其中一些代碼,大致看看就好。

// makehmap_small implements Go map creation for make(map[k]v) and
// make(map[k]v, hint) when hint is known to be at most bucketCnt
// at compile time and the map needs to be allocated on the heap.
func makemap_small() *hmap {
    h := new(hmap)
    h.hash0 = fastrand()
    return h
}

// makemap implements Go map creation for make(map[k]v, hint).
// If the compiler has determined that the map or the first bucket
// can be created on the stack, h and/or bucket may be non-nil.
// If h != nil, the map can be created directly in h.
// If h.buckets != nil, bucket pointed to can be used as the first bucket.
func makemap(t *maptype, hint int, h *hmap) *hmap {
    .....

    // initialize Hmap
    if h == nil {
        h = (*hmap)(newobject(t.hmap))
    }
    h.hash0 = fastrand()

    // find size parameter which will hold the requested # of elements
    B := uint8(0)
    for overLoadFactor(hint, B) {
        B++
    }
    h.B = B
    
    ......
    return h
}

 

  

其中需要注意一個點:“B”,還記得剛才說名字叫bmap的桶數量是不確定的嗎?這個B一定程度上表示的就是桶的數量,當然不是說B是3桶的數量就是3,而是2的3次方,也就是8;當B為5,桶的數量就是32;記住這個B,后面會用到它。

其實你想嘛,初始化還能干什么,最重要的肯定就是確定一開始要有多少個桶,初始的大小還是很重要的,還有一些別的初始化哈希種子等等,問題不大。我們的重點還是要放在存/取上面。

GET

圖解

其實從結構上面來看,我們已經可以摸到一些門道了。先自己想一下,要從一個hashmap中獲取一個元素,那么一定是通過key的哈希值去定位到這個元素,那么想着這個大致方向,看下面一張流程圖來詳細理解golang中是如何實現的。

大話

下面說明要點:

  • 計算出key的hash
  • 用最后的“B”位來確定在哪個桶(“B”就是前面說的那個,B為4,就有16個桶,0101用十進制表示為5,所以在5號桶)
  • 根據key的前8位快速確定是在哪個格子(額外說明一下,在bmap中存放了每個key對應的tophash,是key的前8位)
  • 最終還是需要比對key完整的hash是否匹配,如果匹配則獲取對應value
  • 如果都沒有找到,就去下一個overflow找

總結一下:通過后B位確定桶,通過前8位確定格子,循環遍歷連着的所有桶全部找完為止。
那么為什么要有這個tophash呢?因為tophash可以快速確定key是否正確,你可以把它理解成一種緩存措施,如果前8位都不對了,后面就沒有必要比較了。

源碼一瞥


其中紅色的字標出的地方說明了上面的關鍵點,最后有關key和value具體的存放方式和取出的定位不做深究,有興趣可以看最后的參考博客。

PUT

其實當你知道了如何GET,那么PUT就沒有什么難度了,因為本質是一樣的。PUT的時候一樣的方式去定位key的位置:

  • 通過key的后“B”位確定是哪一個桶
  • 通過key的前8位快速確定是否已經存在
  • 最終確定存放位置,如果8個格子已經滿了,沒地方放了,那么就重新創建一個bmap作為溢出桶連接在overflow

圖解


這里主要圖解說明一下,如果新來的key發現前面有一個格子空着(這個情況是刪除造成的),就會記錄這個位置,當全部掃描完成之后發現自己確實是新來的,那么就會放前面那個空着的,而不會放最后(我把這個稱為緊湊原則,盡可能保證數據存放緊湊,這樣下次掃描會快)

代碼位置

go/src/runtime/hashmap.go的mapassign函數就是map的put方法,因為代碼很長這里就不多贅述了。

擴容

這個就是最復雜的地方了,但是呢?Don't worry我這里還是會省略其中某些部分,將最重要的地方拎出來。

擴容的方式

  1. 相同容量擴容
  2. 2倍容量擴容
    啥意思呢?第一種出現的情況是:因為map不斷的put和delete,出現了很多空格,這些空格會導致bmap很長,但是中間有很多空的地方,掃描時間變長。所以第一種擴容實際是一種整理,將數據整理到前面一起。第二種呢:就是真的不夠用了,擴容兩倍。

擴容的條件

裝載因子

如果你看過Java的HashMap實現,就知道有個裝載因子,同樣的在golang中也有,但是不一樣哦。裝載因子的定義是這個樣子:
loadFactor := count / (2B)
其中count為map中元素的個數,B就是之前個那個“B”
翻譯一下就是裝載因子 = (map中元素的個數)/(map當前桶的個數)

擴容條件1

裝載因子 > 6.5(這個值是源碼中寫的)
其實意思就是,桶只有那么幾個,但是元素很多,證明有很多溢出桶的存在(可以想成鏈表拉的太長了),那么掃描速度會很慢,就要擴容。

擴容條件2

overflow 的 bucket 數量過多:當 B 小於 15,如果 overflow 的 bucket 數量超過 2B ;當 B >= 15,如果 overflow 的 bucket 數量超過 215
其實意思就是,可能有一個單獨的一條鏈拉的很長,溢出桶太多了,說白了就是,加入的key不巧,后B位都一樣,一直落在同一個桶里面,這個桶一直放,雖然裝載因子不高,但是掃描速度就很慢。

擴容條件3

當前不能正在擴容

圖解


這張圖表示的就是相同容量的擴容,實際上就是一種整理,將分散的數據集合到一起,提高掃描效率。(上面表示擴容之前,下面表示擴容之后)


這張圖表示的是就是2倍的擴容(上面表示擴容之前,下面表示擴容之后),如果有兩個key后三位分別是001和101,當B=2時,只有4個桶,只看最后兩位,這兩個key后兩位都是01所以在一個桶里面;擴容之后B=3,就會有8個桶,看后面三位,於是它們就分到了不同的桶里面。

大話

下面說一些擴容時的細節:

  • 擴容不是一次性完成的,還記的我們hmap一開始有一個oldbuckets嗎?是先將老數據存到這個里面
  • 每次搬運1到2個bucket,當插入或修改、刪除key觸發
  • 擴容之后肯定會影響到get和put,遍歷的時候肯定會先從oldbuckets拿,put肯定也要考慮是否要放到新產生的桶里面去

源碼一瞥


擴容的三個條件,看到了嗎?這個地方在mapassign方法中。


這里可以看到,注釋也寫的很清楚,如果是加載因子超出了,那么就2倍擴容,如果不是那么就是因為太多溢出桶了,sameSizeGrow表示就是相同容量擴容


evacuate是搬運方法,這邊可以看到,每次搬運是1到2個

evacuate實在是太長了,也非常復雜,但是情況就是圖上描述的那樣,有興趣的可以詳細去看,這里不截圖說明了。

總結和小問題

至此你應該對於golang中的map有一個基本的認識了,你還可以去看看刪除,你還可以去看看遍歷等等,相信有了上面的基本認識那么應該不會難到你。下面有幾個小問題:

  1. 是否線程安全?否,而且並發操作會拋出異常。
  2. 源碼位置:src/runtime/hashmap.go
  3. 每次遍歷map順序是否一致?不一致,每次遍歷會隨機個數,通過隨機數來決定從哪個元素開始。

寫的倉促,難免疏漏,有問題的地方還請批評指正。

參考資料

如果你希望看到源碼的各種細節講解,下面這幾篇是我學習的時候看的,供你參考,希望對你有幫助
https://github.com/qcrao/Go-Questions/tree/master/map
https://github.com/cch123/golang-notes/blob/master/map.md
https://draveness.me/golang-hashmap
https://lukechampine.com/hackmap.html

 

 

作者:LinkinStar

未經允許,不得轉載

 


免責聲明!

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



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