Golang map 的底層實現

在開發過程中,map是必不可少的數據結構,在Golang中,使用map或多或少會遇到與其他語言不一樣的體驗,比如訪問不存在的元素會返回其類型的空值、map的大小究竟是多少,為什么會報"cannot take the address of"錯誤,遍歷map的隨機性等等。
本文希望通過研究map的底層實現,以解答這些疑惑。
基于Golang 1.8.3

1. 數據結構及內存管理

hashmap的定義位于 src/runtime/hashmap.go 中,首先我們看下hashmap和bucket的定義:

type hmap struct {
    count     int    // 元素的個數
    flags     uint8  // 狀態標志
    B         uint8  // 可以最多容納 6.5 * 2 ^ B 個元素,6.5為裝載因子
    noverflow uint16 // 溢出的個數
    hash0     uint32 // 哈希種子

    buckets    unsafe.Pointer // 桶的地址
    oldbuckets unsafe.Pointer // 舊桶的地址,用于擴容
    nevacuate  uintptr        // 搬遷進度,小于nevacuate的已經搬遷
    overflow *[2]*[]*bmap 
}

其中,overflow是一個指針,指向一個元素個數為2的數組,數組的類型是一個指針,指向一個slice,slice的元素是桶(bmap)的地址,這些桶都是溢出桶;為什么有兩個?因為Go map在hash沖突過多時,會發生擴容操作,為了不全量搬遷數據,使用了增量搬遷,[0]表示當前使用的溢出桶集合,[1]是在發生擴容時,保存了舊的溢出桶集合;overflow存在的意義在于防止溢出桶被gc。

// A bucket for a Go map.
type bmap struct {
    // 每個元素hash值的高8位,如果tophash[0] < minTopHash,表示這個桶的搬遷狀態
    tophash [bucketCnt]uint8
    // 接下來是8個key、8個value,但是我們不能直接看到;為了優化對齊,go采用了key放在一起,value放在一起的存儲方式,
    // 再接下來是hash沖突發生時,下一個溢出桶的地址
}

tophash的存在是為了快速試錯,畢竟只有8位,比較起來會快一點。

從定義可以看出,不同于STL中map以紅黑樹實現的方式,Golang采用了HashTable的實現,解決沖突采用的是鏈地址法。也就是說,使用數組+鏈表來實現map。特別的,對于一個key,幾個比較重要的計算公式為:

key hash hashtop bucket index
key hash := alg.hash(key, uintptr(h.hash0)) top := uint8(hash >> (sys.PtrSize*8 - 8)) bucket := hash & (uintptr(1)<<h.B - 1),即 hash % 2^B

例如,對于B = 3,當hash(key) = 4時, hashtop = 0, bucket = 4,當hash(key) = 20時,hashtop = 0, bucket = 4;這個例子我們在搬遷過程還會用到。

內存布局類似于這樣:


hashmap-buckets

2. 創建 - makemap

map的創建比較簡單,在參數校驗之后,需要找到合適的B來申請桶的內存空間,接著便是穿件hmap這個結構,以及對它的初始化。

makemap

3. 訪問 - mapaccess

對于給定的一個key,可以通過下面的操作找到它是否存在


image.png

方法定義為

// returns key, if not find, returns nil
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer 

// returns key and exist. if not find, returns nil, false
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool)

// returns both key and value. if not find, returns nil, nil
func mapaccessK(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, unsafe.Pointer)

可見在找不到對應key的情況下,會返回nil

4. 分配 - mapassign

為一個key分配空間的邏輯,大致與查找類似;但增加了寫保護和擴容的操作;注意,分配過程和刪除過程都沒有在oldbuckets中查找,這是因為首先要進行擴容判斷和操作;如下:


assign

擴容是整個hashmap的核心算法,我們放在第6部分重點研究。

新建一個溢出桶,并將其拼接在當前桶的尾部,實現了類似鏈表的操作:

// 獲取當前桶的溢出桶
func (b *bmap) overflow(t *maptype) *bmap {
    return *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-sys.PtrSize))
}

// 設置當前桶的溢出桶
func (h *hmap) setoverflow(t *maptype, b, ovf *bmap) {
    h.incrnoverflow()
    if t.bucket.kind&kindNoPointers != 0 {
        h.createOverflow()
        //重點,這里講溢出桶append到overflow[0]的后面
        *h.overflow[0] = append(*h.overflow[0], ovf)
    }
    *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-sys.PtrSize)) = ovf
}

5. 刪除 - mapdelete

刪除某個key的操作與分配類似,由于hashmap的存儲結構是數組+鏈表,所以真正刪除key僅僅是將對應的slot設置為empty,并沒有減少內存;如下:


mapdelete

6. 擴容 - growWork

首先,判斷是否需要擴容的邏輯是

func (h *hmap) growing() bool {
    return h.oldbuckets != nil
}

何時h.oldbuckets不為nil呢?在分配assign邏輯中,當沒有位置給key使用,而且滿足測試條件(裝載因子>6.5或有太多溢出通)時,會觸發hashGrow邏輯:

func hashGrow(t *maptype, h *hmap) {
    //判斷是否需要sameSizeGrow,否則"真"擴
    bigger := uint8(1)
    if !overLoadFactor(int64(h.count), h.B) {
        bigger = 0
        h.flags |= sameSizeGrow
    }
        // 下面將buckets復制給oldbuckets
    oldbuckets := h.buckets
    newbuckets := newarray(t.bucket, 1<<(h.B+bigger))
    flags := h.flags &^ (iterator | oldIterator)
    if h.flags&iterator != 0 {
        flags |= oldIterator
    }
    // 更新hmap的變量
    h.B += bigger
    h.flags = flags
    h.oldbuckets = oldbuckets
    h.buckets = newbuckets
    h.nevacuate = 0
    h.noverflow = 0
        // 設置溢出桶
    if h.overflow != nil {
        if h.overflow[1] != nil {
            throw("overflow is not nil")
        }
// 交換溢出桶
        h.overflow[1] = h.overflow[0]
        h.overflow[0] = nil
    }
}

OK,下面正式進入重點,擴容階段;在assign和delete操作中,都會觸發擴容growWork:

func growWork(t *maptype, h *hmap, bucket uintptr) {
    // 搬遷舊桶,這樣assign和delete都直接在新桶集合中進行
    evacuate(t, h, bucket&h.oldbucketmask())
        //再搬遷一次搬遷過程中的桶
    if h.growing() {
        evacuate(t, h, h.nevacuate)
    }
}

6.1 搬遷過程

一般來說,新桶數組大小是原來的2倍(在!sameSizeGrow()條件下),新桶數組前半段可以"類比"為舊桶,對于一個key,搬遷后落入哪一個索引中呢?

假設舊桶數組大小為2^B, 新桶數組大小為2*2^B,對于某個hash值X
若 X & (2^B) == 0,說明 X < 2^B,那么它將落入與舊桶集合相同的索引xi中;
否則,它將落入xi + 2^B中。

例如,對于舊B = 3時,hash1 = 4,hash2 = 20,其搬遷結果類似這樣。

example.png

源碼中有些變量的命名比較簡單,容易擾亂思路,我們注明一下便于理解。

變量 釋義
x *bmap 桶x表示與在舊桶時相同的位置,即位于新桶前半段
y *bmap 桶y表示與在舊桶時相同的位置+舊桶數組大小,即位于新桶后半段
xi int 桶x的slot索引
yi int 桶y的slot索引
xk unsafe.Pointer 索引xi對應的key地址
yk unsafe.Pointer 索引yi對應的key地址
xv unsafe.Pointer 索引xi對應的value地址
yv unsafe.Pointer 索引yi對應的value地址

搬遷過程如下:


evacuate

總結

到目前為止,Golang的map實現細節已經分析完畢,但不包含迭代器相關操作。通過分析,我們了解了map是由數組+鏈表實現的HashTable,其大小和B息息相關,同時也了解了map的創建、查詢、分配、刪除以及擴容搬遷原理。總的來說,Golang通過hashtop快速試錯加快了查找過程,利用空間換時間的思想解決了擴容的問題,利用將8個key(8個value)依次放置減少了padding空間等等。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370