【GoLang 那點事】深入 Go 的 Map 使用和實現原理

a_wei發表於2019-10-12

開篇語

Map是一種常用的kv資料結構,程式設計中經常使用,且作為一種最基礎的資料結構,很多程式語言本身提供的api都會有實現,Go也不例外,今天我們將從一下三個方面為大家分析Go中的Map。

  • 什麼是Map?
  • Go中如何使用Map?
  • 以及Go的Map實現機制是什麼樣?
    希望通過這幾個方面的講解,讓大家真正理解Go的Map使用和實現。

什麼是Map

key,value儲存

最通俗的話說Map是一種通過key來獲取value的一個資料結構,其底層儲存方式為陣列,在儲存時key不能重複,當key重複時,value進行覆蓋,我們通過key進行hash運算(可以簡單理解為把key轉化為一個整形數字)然後對陣列的長度取餘,得到key儲存在陣列的哪個下標位置,最後將key和value組裝為一個結構體,放入陣列下標處,看下圖:

  • length = len(array) = 4
    hashkey1 = hash(xiaoming) = 4
    index1  = hashkey1% length= 0
    hashkey2 = hash(xiaoli) = 6
    index2  = hashkey2% length= 2

【GoLang 那點事】深入 Go 的 Map 使用和實現原理

hash衝突

如上圖所示,陣列一個下標處只能儲存一個元素,也就是說一個陣列下標只能儲存一對key,value, hashkey(xiaoming)=4佔用了下標0的位置,假設我們遇到另一個key,hashkey(xiaowang)也是4,這就是hash衝突(不同的key經過hash之後得到的值一樣),那麼key=xiaowang的怎麼儲存?

hash衝突的常見解決方法

  • 開放定址法
    也就是說當我們儲存一個key,value時,發現hashkey(key)的下標已經被別key佔用,那我們在這個陣列中空間中重新找一個沒被佔用的儲存這個衝突的key,那麼沒被佔用的有很多,找哪個好呢?常見的有線性探測法,線性補償探測法,隨機探測法,這裡我們主要說一下線性探測法

    線性探測,字面意思就是按照順序來,從衝突的下標處開始往後探測,到達陣列末尾時,從陣列開始處探測,直到找到一個空位置儲存這個key,當陣列都找不到的情況下回擴容(事實上當陣列容量快滿的時候就會擴容了);查詢某一個key的時候,找到key對應的下標,比較key是否相等,如果相等直接取出來,否則按照順尋探測直到碰到一個空位置,說明key不存在。如下圖:
    【GoLang 那點事】深入 Go 的 Map 使用和實現原理
    首先儲存key=xiaoming在下標0處,當儲存key=xiaowang時,hash衝突了,按照線性探測,儲存在下標1處,(紅色的線是衝突或者下標已經被佔用了)
    再者key=xiaozhao儲存在下標4處,當儲存key=xiaoliu是,hash衝突了,按照線性探測,從頭開始,儲存在下標2處 (黃色的是衝突或者下標已經被佔用了)

  • 拉鍊法
    何為拉鍊,簡單理解為連結串列,當key的hash衝突時,我們在衝突位置的元素上形成一個連結串列,通過指標互連線,當查詢時,發現key衝突,順著連結串列一直往下找,直到連結串列的尾節點,找不到則返回空,如下圖:【GoLang 那點事】深入 Go 的 Map 使用和實現原理
  • 開放定址(線性探測)和拉鍊的優缺點
    1. 由上面可以看出拉鍊法比線性探測處理簡單
    2. 線性探測查詢時會被拉鍊法會更消耗時間
    3. 線性探測會更加容易導致擴容,而拉鍊不會
    4. 拉鍊儲存了指標,所以空間上會比線性探測佔用多一點
    5. 拉鍊是動態申請儲存空間的,所以更適合鏈長不確定的

Go中Map的使用

  • 直接用程式碼描述,直觀,簡單,易理解

  • //直接建立初始化一個mao
    var mapInit = map[string]string {"xiaoli":"湖南", "xiaoliu":"天津"}
    //宣告一個map型別變數,
    //map的key的型別是string,value的型別是string
    var mapTemp map[string]string
    //使用make函式初始化這個變數,並指定大小(也可以不指定)
    mapTemp = make(map[string]string,10)
    //儲存key ,value
    mapTemp["xiaoming"] = "北京"
    mapTemp["xiaowang"]= "河北"
    //根據key獲取value,
    //如果key存在,則ok是true,否則是flase
    //v1用來接收key對應的value,當ok是false時,v1是nil
    v1,ok := mapTemp["xiaoming"]
    fmt.Println(ok,v1)
    //當key=xiaowang存在時列印value
    if v2,ok := mapTemp["xiaowang"]; ok{
    fmt.Println(v2)
    }
    //遍歷map,列印key和value
    for k,v := range mapTemp{
    fmt.Println(k,v)
    }
    //刪除map中的key
    delete(mapTemp,"xiaoming")
    //獲取map的大小
    l := len(mapTemp)
    fmt.Println(l)
  • 看了上面的map建立,初始化,增刪改查等操作,我們發現go的api其實挺簡單易學的

Go中Map的實現原理

知其然,更得知其所以然,會使用map了,多問問為什麼,go底層map到底怎麼儲存呢?接下來我們一探究竟。
map的原始碼位於 src/runtime/map.go中 筆者go的版本是1.12
在go中,map同樣也是陣列儲存的的,每個陣列下標處儲存的是一個bucket,這個bucket的型別見下面程式碼,每個bucket中可以儲存8個kv鍵值對,當每個bucket儲存的kv對到達8個之後,會通過overflow指標指向一個新的bucket,從而形成一個連結串列,看bmap的結構,我想大家應該很納悶,沒看見kv的結構和overflow指標啊,事實上,這兩個結構體並沒有顯示定義,是通過指標運算進行訪問的。

  • //bucket結構體定義 b就是bucket
    type bmap{
    // tophash generally contains the top byte of the hash value for each key  in this bucket. If tophash[0] < minTopHash, tophash[0] is a bucket  evacuation state instead.
    //翻譯:top hash通常包含該bucket中每個鍵的hash值的高八位。如果tophash[0]小於mintophash,則tophash[0]為桶疏散狀態
    //bucketCnt 的初始值是8
    tophash [bucketCnt]uint8
    // Followed by bucketCnt keys and then bucketCnt values.  NOTE: packing all the keys together and then all the values together makes the code a bit more complicated than alternating key/value/key/value/... but it allows us to eliminate padding which would be needed for, e.g., map[int64]int8. Followed by an overflow pointer.
    //翻譯:接下來是bucketcnt鍵,然後是bucketcnt值。注意:將所有鍵打包在一起,然後將所有值打包在一起,使得//程式碼比交替鍵/值/鍵/值/更復雜。但它允許//我們消除可能需要的填充,例如map[int64]int8./後面跟一個溢位指標
    }

看上面程式碼以及註釋,我們能得到bucket中儲存的kv是這樣的,tophash用來快速查詢key值是否在該bucket中,而不同每次都通過真值進行比較;還有kv的存放,為什麼不是k1v1,k2v2..... 而是k1k2...v1v2...,我們看上面的註釋說的 map[int64]int8,key是int64(8個位元組),value是int8(一個位元組),kv的長度不同,如果按照kv格式存放,則考慮記憶體對齊v也會佔用int64,而按照後者儲存時,8個v剛好佔用一個int64,從這個就可以看出go的map設計之巧妙。

Golang

最後我們分析一下go的整體記憶體結構,閱讀一下map儲存的原始碼,如下圖所示,當往map中儲存一個kv對時,通過k獲取hash值,hash值的低八位和bucket陣列長度取餘,定位到在陣列中的那個下標,hash值的高八位儲存在bucket中的tophash中,用來快速判斷key是否存在,key和value的具體值則通過指標運算儲存,當一個bucket滿時,通過overfolw指標連結到下一個bucket。

Golang

go的map儲存原始碼如下,省略了一些無關緊要的程式碼

  • func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    //獲取hash演算法
    alg := t.key.alg
    //計算hash值
    hash := alg.hash(key, uintptr(h.hash0))
    //如果bucket陣列一開始為空,則初始化
    if h.buckets == nil {
        // newarray(t.bucket, 1)
        h.buckets = newobject(t.bucket) 
    }
    again:
    // 定位儲存在哪一個bucket中
    bucket := hash & bucketMask(h.B)
    //得到bucket的結構體
    b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) +bucket*uintptr(t.bucketsize)))
    //獲取高八位hash值
    top := tophash(hash)
    var inserti *uint8
    var insertk unsafe.Pointer
    var val unsafe.Pointer
    bucketloop:
    //死迴圈
    for {
        //迴圈bucket中的tophash陣列
        for i := uintptr(0); i < bucketCnt; i++ {
            //如果hash不相等
            if b.tophash[i] != top {
             //判斷是否為空,為空則插入
                if isEmpty(b.tophash[i]) && inserti == nil {
                    inserti = &b.tophash[i]
                    insertk = add(指標運算)
                    val = add(指標運算)
                }
              //插入成功,終止最外層迴圈
                if b.tophash[i] == emptyRest {
                    break bucketloop
                }
                continue
            }
            //到這裡說明高八位hash一樣,獲取已存在的key
            k := add(指標運算)
            if t.indirectkey() {
                k = *((*unsafe.Pointer)(k))
            }
            //判斷兩個key是否相等,不相等就迴圈下一個
            if !alg.equal(key, k) {
                continue
            }
            // 如果相等則更新
            if t.needkeyupdate() {
                typedmemmove(t.key, k, key)
            }
            //獲取已存在的value
            val = add(指標運算)
            goto done
        }
        //如果上一個bucket沒能插入,則通過overflow獲取連結串列上的下一個bucket
        ovf := b.overflow(t)
        if ovf == nil {
            break
        }
        b = ovf
    }
    if inserti == nil {
        // all current buckets are full, allocate a new one.
        newb := h.newoverflow(t, b)
        inserti = &newb.tophash[0]
        insertk = add(指標運算)
    }
    
    // store new key/value at insert position
    if t.indirectkey() {
        kmem := newobject(t.key)
        *(*unsafe.Pointer)(insertk) = kmem
        insertk = kmem
    }
    if t.indirectvalue() {
        vmem := newobject(t.elem)
        *(*unsafe.Pointer)(val) = vmem
    }
    typedmemmove(t.key, insertk, key)
    //將高八位hash值儲存
    *inserti = top
    h.count++
    return val
    }

歡迎大家關注微信公眾號:“golang那點事”,更多精彩期待你的到來

Golang

那小子阿偉

相關文章