1. 程式人生 > >golang 的 map 實現(一)

golang 的 map 實現(一)

概述

雜湊表是工程中常用到的資料型別,能提供快速的檢索和更新。複雜度一般為 O(1)

本篇博文分 兩部分寫,第一部分是原始碼學習,第二部分是一些內部實現,以及覺著有意思的一些地方,以及個人思考

理論

雜湊表需要解決的問題有兩個

  • 位置索引
  • 資料碰撞

索引交給 hash function 雜湊演算法,常用就是模運算

解決碰撞主要有以下三種方式

  1. 分離連結,也就是利用連結串列性質儲存衝突的 key,然後通過遍歷來區分(單獨的儲存層面)
  2. 開放定址
    1. 線性探測(儲存層面以及演算法層面都有所調整)
    2. 平方探測(同上,只是演算法層面小改動而已)
    3. 雙雜湊
  3. 再雜湊 (擴容以及資料遷移)

可擴雜湊是用來解決資料太大而無法裝進記憶體的場景,此處不討論

雜湊表的效率load factor 裝填因子有關,用來估量其平均複雜度。含義就是一個其計算方式一般就是使用 已經儲存的資料量 / 可索引地址的數量。 或者說,單個索引地址的平均長度

碰撞的解決

理想情況下,沒有碰撞的時候,使用一個數組,與一個雜湊演算法就可以實現雜湊結構。但是碰撞無法完全避免,那麼就有了以下幾種方式來解決。

分離連結

分離連結的核心是通過使用連結串列來處理碰撞問題。陣列用來做索引,內部儲存連結串列,連結串列儲存的是雜湊碰撞的 key 以及 value,儲存 key 是為了在衝突的時候,仍舊可以通過比對來實現定位。

開放定址

連結串列的問題是節點申請,會造成記憶體的頻繁操作。如果在資料量不是特別大的時候,可以考慮開放定址的方式。其仍舊使用一個比較大的陣列。只是在發生碰撞的時候,可以通過向固定方向進行偏移來進行儲存,從而解決碰撞問題。
線性探測和平方探測就在於偏移量選擇上。雙雜湊(略)

再雜湊

碰撞某種程度上可以說是儲存空間較小造成的。那麼 rehash 的思想就是,申請更大的空間,然後將資料重新計算,重新定位。

Golang 的 map 實現

golang 中的 map 是一個雜湊表,其實現方式使用到了連結串列以及 rehash。

連結串列是在用在較小層面碰撞,rehash 則是當 load factor 較大的時候使用的方式。

注意:本篇記錄是基於 go 1.9.2 版本記錄的。

資料結構

golang 的 map 內並沒有直接儲存傳遞進來的 keyvalue,而是使用了其引用,以及 key 的 hash 值的高位(後面再說)。

下面是 map 資料結構的部分,選取了主要是跟儲存相關的域。

type hmap struct {
    B          uint8
    buckets    unsafe.Pointer
    oldbuckets unsafe.Pointer
    extra      *mapextra
}

buckets 與 oldbuckets 是指向一段連續地址的指標地址。主要是用來儲存 key 和 value 的引用地址,暫時理解成資料部分好了。其中oldbuckets 只有在擴容的時候才會用到。兩者與前面『分離連結』實現中的陣列功能類似,供初步索引使用。

type mapextra struct {
    overflow [2]*[]*bmap
    nextOverflow *bmap
}

暫時只關注 nextOverflow 就好,指向的也是一個類似於 buckets 的連續地址(最後一個 bucket 的最後維護的是一個地址。 buckets 和 oldbuckets 中沒有這個),從名字上就能看出來是在空間不夠時(但是又不足以觸發 rehash 邏輯)從系統中申請記憶體臨時使用的空間做 緩衝。

type bmap struct {
    tophash [bucketCnt]uint8
}

bmap 是 bucket 資料結構的部分結構。 其功能是來大致確認這個連結串列的地址。是一個空間為 8 的陣列。其值是 key 的 hash 值的高位。當傳遞進來一個 key 的時候,會做比對,然後確定這個陣列下標,這個下標,與這個 key 所儲存的連結串列頭部有關。

記憶體結構

下面的結構都是通過原始碼個人理解畫出來的,可能有所偏差。其實應該放在 map 的操作之後。但是為了有助於理解後面的操作,所以就放在了前面。

先不考慮擴容場景,map 儲存資料會先使用 buckets,當空間不夠(先這麼說)才會去使用 overflow 區域。所以下面就放了這兩個的結構。

bucket

bmap
|    資料對齊
|    |
|    |  |    key field  |  value field  | 指向下一個 bucket 的指標
|____|__|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|

bmap 為 8 個 uint8 的連續空間,用來儲存 tophash 。後面跟的資料對齊是記憶體層面的操作。 key field 和 value field 是用來儲存 key 引用以及 value 引用,兩者都是 8 個空間,可以通過 tophash 的偏移量來進行計算每個引用自身的儲存位置,從而獲取到 key 以及 value。

nextOverFlow

|_|_|_|_|_|_|_-|

每兩個豎線之間都是一個 bucket

nextoverflow 指向的為一組 bucket 大小的連續空間,功能與上面的 bucket 一樣。不過 nextoverflow 最後一個 bucket,也就是上面 |----| 是特殊使用,不是用來儲存資料,而是一個結尾符。用來告知該緩衝區已經結束。實現方式就是在最後一個引用大小的空間,儲存了 hmap 結構中的 buckets 的頭地址。

注意:在需要建立的 bucket 超過 8 個的時候,golang 預先申請了 nextoverflow 的空間,減少記憶體操作(細節贊),那麼此時 buckets 和 nextoverflow 在記憶體上就是連續的了。結構就會如下圖。

                                          |     | ----> 最後一個 bucketSize 大小的空間
buckets 頭部                 nextOverFlow  |   儲存了 buckets 頭部地址
|                           |             |  |  |
|                           |             |  |  |
|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|-| 最後 '-' 為 sys.PtrSize,不知道 ptrSeize 和 bucketSize

這個圖是最早畫的,不捨得刪

介面實現

map 的介面實現除了上面的記憶體結構,還有一些沒有畫出來的域有關,比如資料競爭,裝載因子大小,擴容時候使用的資料結構都有關。

建立

建立其實就是完成記憶體的申請,以及一些初始值的設定。那麼這裡假設建立的空間較大,也就是說將 overflow 區域的初始化,也一併放在這裡記錄。

makemap 是完成 map 結構的函式。下面是原生程式碼抽出來,並方便閱讀改了一些的 『偽虛擬碼』

// hint 代表的 capacity
func makemap(t *maptype, hint int64) *hmap  {
    // 條件檢查
    t.keysize = sys.PtrSize = t.key.size
    t.valuesize = sys.PtrSize = t.elem.size

    // 通過 hint 確定 hmap 中最小的 B 應該是多大。
    // B 與後面的記憶體空間申請,以及未來可能的擴容都有關。B 是一個基數。
    // overLoadFactor 考慮了裝載因子。golang 將其初始設定為 0.65
    B := uint8(0)
    for ; overLoadFactor(hint, B); B++ {}

    // golang 是 lazy 形式申請記憶體
        if B != 0 {
        var nextOverflow *bmap
        buckets, nextOverflow = makeBucketArray(t, B)
        if nextOverflow != nil {
            extra = new(mapextra)
            extra.nextOverflow = nextOverflow
        }
    }

    // 後面就是將記憶體地址關聯到 hmap 結構,並返回例項
    h.count = 0  // 記錄儲存的 k/v pari 數量。擴容時候會用到
    h.B = B  // 記錄基數
    h.flags = 0 // 與狀態有關。包含併發控制,以及擴容。

    ...
}

// makeBucketArray 會根據情況判斷是否要申請 nextOverflow 。
func makeBucketArray(t *maptype, b uint8) (buckets unsafe.Pointer, nextOverflow *bmap) {
    base := uintptr(1 << b)
    nbuckets := base
    if b >= 4 {
        // 向上調整 nbuckets
    }

    // 注意,是按照 nbuckets 申請記憶體的
    buckets = newarray(t.bucket, int(nbuckets))

    // 處理 overflow 情況,
    if base != nbuckets {
        // 移動到 資料段 的末尾
        nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))

        // 設定末尾地址,參考上面記憶體圖中 nextoverflow 最後的那個指標位。用來做末尾檢測
        last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
        last.setoverflow(t, (*bmap)(buckets))
    }
    return buckets, nextOverflow
}

配合看記憶體圖的第三個,效果更佳。便於有一個整體印象。

讀取

讀取有 mapaccess1mapaccess2 兩個,前者返回指標,後者返回指標和一個 bool,用於判斷 key 是否存在。這裡只說 mapaccess1。 指標是 value field中儲存的地址

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// 如果為空或者長度為 0,那麼就返回一個 0 值

// 如果正在被寫入,那麼丟擲異常

// 獲取 key 的 hash 值

// 確認該 key 所在的 bucket 位置 (可能是在 buckets 也有可能在 oldbuckets 中)
// 使用模計算,先計算出如果在 buckets 中,則是在哪個 bucket
// 檢測 oldbucket 是否為空,如果不為空,則用上面同樣的方式得出在 oldbuckets 的位置
// 並檢測該 bucket 是否已經被 evacuate ,如果已經被 evacuate 則使用 buckets, 否則使用 oldbuckets 中的位置
    m := uintptr(1)<<h.B - 1
    b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))  // buckets 結構
    if c := h.oldbuckets; c != nil {
        if !h.sameSizeGrow() {  // 上次擴容是等量還是雙倍擴容, 會有影響
            // There used to be half as many buckets; mask down one more power of two.
            m >>= 1
        }
        oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
        if !evacuated(oldb) {
            b = oldb
        }
    }

// 得到 bucket 以後,通過 tophash 來再次定位,如果定位不到,則遞迴該 bucket 的 overflow 域,迴圈查詢。
// 以上步驟有兩個結果。
// 1 遍歷到最後,都沒有找到命中的 tophash ,此時則返回一個零值。
// 2 命中 tophash 則進行 key 比對,相同則返回對應的 val 位置,不同則通過 overflow 繼續獲取,否則返回一個零值

寫入

這裡用『寫入』並不是很嚴格。 因為最後返回的是一個指標地址,用來儲存 value。 即通過輸入的 key 來確認要寫入 value 引用的地址 (考慮 bucket 結構中的 value field

在更進一步講寫入的操作之前,說一下有關擴容的事情。隨著寫入量的增加,擴容不可避免。如果擴容,那麼涉及新空間的申請,然後是老空間資料的遷移,以及最後老空間的回收。 資料遷移部分可以一次性完成,但是這樣可能會導致某次操作特別慢,所以 golang 在遷移時,使用了 lazy 的方式,只有當要變更一個 oldbucket 內元素的時候,會安靜該 oldbucket 重新 hash 寫入到 buckets 中去,並將該 oldbucket 刪除引用,交由 gc 進行空間回收。

更多的 grow 相關操作會在 『內部』細說,這裡更多的是整體流程。

mapassign 是完成該操作的主體函式。(寫到這裡,突然不想寫了…心好累)

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    // 併發檢查
    // 計算 hash 值
    // 更新狀態為 寫入

    // 下面是一個三層的迴圈巢狀。從裡向外說
    // 第三層是為了在一個 bucket 中,定位到一個 key 的位置
        // 如果成功(更新操作),則直接可以計算出 val 的位置,跳轉到結束階段
        // 定位失敗,則第二層迴圈開始工作
    // 第二層迴圈是遞迴該 bucket 的 overflow 區域,持續獲取新的bucket位置
        // 成功,則執行第三層迴圈
        // 失敗(沒有 overflow 區域了,即插入操作)跳回到第一層
    // 第一層是為了獲取空間來執行寫入操作(如果是插入操作,則 h.count++,記錄 map 內 key 的數量)
        // 要麼 hashGrow (後面講),然後接著跳轉到三層迴圈,繼續執行
        // 要麼 overflow, 本層直接執行插入操作
        // 操作完成

    // 最後返回 val 地址
}

刪除

mapdelete 是負責刪除的的主體函式

func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
    // 同 access 定位到位置,沒定位到就啥都不幹
    // 定位到以後,會先刪除 value 裡面的引用,後面由 gc 進行進行空間回收
    // 將 tophash 中對應位置設定為 empty (有意思的也就是這裡)
    // h.count--
}