徹底理解Golang Map

caspar 發表於 2022-01-24
Go

本文目錄如下,閱讀本文後,將一網打盡下面Golang Map相關面試題

徹底理解Golang Map

面試題

  1. map的底層實現原理
  2. 為什麼遍歷map是無序的?
  3. 如何實現有序遍歷map?
  4. 為什麼Go map是非執行緒安全的?
  5. 執行緒安全的map如何實現?
  6. Go sync.map 和原生 map 誰的效能好,為什麼?
  7. 為什麼 Go map 的負載因子是 6.5?
  8. map擴容策略是什麼?

實現原理

Go中的map是一個指標,佔用8個位元組,指向hmap結構體; 原始碼src/runtime/map.go中可以看到map的底層結構

每個map的底層結構是hmap,hmap包含若干個結構為bmap的bucket陣列。每個bucket底層都採用連結串列結構。接下來,我們來詳細看下map的結構

徹底理解Golang Map

hmap結構體

// A header for a Go map.
type hmap struct {
    count     int 
    // 代表雜湊表中的元素個數,呼叫len(map)時,返回的就是該欄位值。
    flags     uint8 
    // 狀態標誌,下文常量中會解釋四種狀態位含義。
    B         uint8  
    // buckets(桶)的對數log_2
    // 如果B=5,則buckets陣列的長度 = 2^5=32,意味著有32個桶
    noverflow uint16 
    // 溢位桶的大概數量
    hash0     uint32 
    // 雜湊種子

    buckets    unsafe.Pointer 
    // 指向buckets陣列的指標,陣列大小為2^B,如果元素個數為0,它為nil。
    oldbuckets unsafe.Pointer 
    // 如果發生擴容,oldbuckets是指向老的buckets陣列的指標,老的buckets陣列大小是新的buckets的1/2;非擴容狀態下,它為nil。
    nevacuate  uintptr        
    // 表示擴容進度,小於此地址的buckets代表已搬遷完成。

    extra *mapextra 
    // 這個欄位是為了優化GC掃描而設計的。當key和value均不包含指標,並且都可以inline時使用。extra是指向mapextra型別的指標。
 }

bmap結構體

bmap 就是我們常說的“桶”,一個桶裡面會最多裝 8 個 key,這些 key 之所以會落入同一個桶,是因為它們經過雜湊計算後,雜湊結果是“一類”的,關於key的定位我們在map的查詢和插入中詳細說明。在桶內,又會根據 key 計算出來的 hash 值的高 8 位來決定 key 到底落入桶內的哪個位置(一個桶內最多有8個位置)。

// A bucket for a Go map.
type bmap struct {
    tophash [bucketCnt]uint8        
    // len為8的陣列
    // 用來快速定位key是否在這個bmap中
    // 桶的槽位陣列,一個桶最多8個槽位,如果key所在的槽位在tophash中,則代表該key在這個桶中
}
//底層定義的常量 
const (
    bucketCntBits = 3
    bucketCnt     = 1 << bucketCntBits
    // 一個桶最多8個位置
)

但這只是表面(src/runtime/hashmap.go)的結構,編譯期間會給它加料,動態地建立一個新的結構:

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

bucket記憶體資料結構視覺化如下:

注意到 key 和 value 是各自放在一起的,並不是 key/value/key/value/... 這樣的形式。原始碼裡說明這樣的好處是在某些情況下可以省略掉 padding欄位,節省記憶體空間。

徹底理解Golang Map

mapextra結構體

當 map 的 key 和 value 都不是指標,並且 size 都小於 128 位元組的情況下,會把 bmap 標記為不含指標,這樣可以避免 gc 時掃描整個 hmap。但是,我們看 bmap 其實有一個 overflow 的欄位,是指標型別的,破壞了 bmap 不含指標的設想,這時會把 overflow 移動到 extra 欄位來。

// mapextra holds fields that are not present on all maps.
type mapextra struct {
    // 如果 key 和 value 都不包含指標,並且可以被 inline(<=128 位元組)
    // 就使用 hmap的extra欄位 來儲存 overflow buckets,這樣可以避免 GC 掃描整個 map
    // 然而 bmap.overflow 也是個指標。這時候我們只能把這些 overflow 的指標
    // 都放在 hmap.extra.overflow 和 hmap.extra.oldoverflow 中了
    // overflow 包含的是 hmap.buckets 的 overflow 的 buckets
    // oldoverflow 包含擴容時的 hmap.oldbuckets 的 overflow 的 bucket
    overflow    *[]*bmap
    oldoverflow *[]*bmap

        nextOverflow *bmap    
    // 指向空閒的 overflow bucket 的指標
}

主要特性

引用型別

map是個指標,底層指向hmap,所以是個引用型別

golang 有三個常用的高階型別slice、map、channel, 它們都是引用型別,當引用型別作為函式引數時,可能會修改原內容資料。

golang 中沒有引用傳遞,只有值和指標傳遞。所以 map 作為函式實參傳遞時本質上也是值傳遞,只不過因為 map 底層資料結構是通過指標指向實際的元素儲存空間,在被調函式中修改 map,對呼叫者同樣可見,所以 map 作為函式實參傳遞時表現出了引用傳遞的效果。

因此,傳遞 map 時,如果想修改map的內容而不是map本身,函式形參無需使用指標

func TestSliceFn(t *testing.T) {
    m := map[string]int{}
    t.Log(m, len(m))
    // map[a:1]
    mapAppend(m, "b", 2)
    t.Log(m, len(m))
    // map[a:1 b:2] 2
}

func mapAppend(m map[string]int, key string, val int) {
    m[key] = val
}

共享儲存空間

map 底層資料結構是通過指標指向實際的元素儲存空間 ,這種情況下,對其中一個map的更改,會影響到其他map

func TestMapShareMemory(t *testing.T) {
    m1 := map[string]int{}
    m2 := m1
    m1["a"] = 1
    t.Log(m1, len(m1))
    // map[a:1] 1
    t.Log(m2, len(m2))
    // map[a:1]
}

遍歷順序隨機

map 在沒有被修改的情況下,使用 range 多次遍歷 map 時輸出的 key 和 value 的順序可能不同。這是 Go 語言的設計者們有意為之,在每次 range 時的順序被隨機化,旨在提示開發者們,Go 底層實現並不保證 map 遍歷順序穩定,請大家不要依賴 range 遍歷結果順序。

map 本身是無序的,且遍歷時順序還會被隨機化,如果想順序遍歷 map,需要對 map key 先排序,再按照 key 的順序遍歷 map。

func TestMapRange(t *testing.T) {
    m := map[int]string{1: "a", 2: "b", 3: "c"}
    t.Log("first range:")
    // 預設無序遍歷
    for i, v := range m {
        t.Logf("m[%v]=%v ", i, v)
    }
    t.Log("\nsecond range:")
    for i, v := range m {
        t.Logf("m[%v]=%v ", i, v)
    }

    // 實現有序遍歷
    var sl []int
    // 把 key 單獨取出放到切片
    for k := range m {
        sl = append(sl, k)
    }
    // 排序切片
    sort.Ints(sl)
    // 以切片中的 key 順序遍歷 map 就是有序的了
    for _, k := range sl {
        t.Log(k, m[k])
    }
}

非執行緒安全

map預設是併發不安全的,原因如下:

Go 官方在經過了長時間的討論後,認為 Go map 更應適配典型使用場景(不需要從多個 goroutine 中進行安全訪問),而不是為了小部分情況(併發訪問),導致大部分程式付出加鎖代價(效能),決定了不支援。

場景: 2個協程同時讀和寫,以下程式會出現致命錯誤:fatal error: concurrent map writes

func main() {
    
    m := make(map[int]int)
    go func() {
                    //開一個協程寫map
        for i := 0; i < 10000; i++ {
    
            m[i] = i
        }
    }()

    go func() {
                    //開一個協程讀map
        for i := 0; i < 10000; i++ {
    
            fmt.Println(m[i])
        }
    }()

    //time.Sleep(time.Second * 20)
    for {
    
        ;
    }
}

如果想實現map執行緒安全,有兩種方式:

方式一:使用讀寫鎖 map + sync.RWMutex

func BenchmarkMapConcurrencySafeByMutex(b *testing.B) {
    var lock sync.Mutex //互斥鎖
    m := make(map[int]int, 0)
    var wg sync.WaitGroup
    for i := 0; i < b.N; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            lock.Lock()
            defer lock.Unlock()
            m[i] = i
        }(i)
    }
    wg.Wait()
    b.Log(len(m), b.N)
}

方式二:使用golang提供的 sync.Map

sync.map是用讀寫分離實現的,其思想是空間換時間。和map+RWLock的實現方式相比,它做了一些優化:可以無鎖訪問read map,而且會優先操作read map,倘若只操作read map就可以滿足要求(增刪改查遍歷),那就不用去操作write map(它的讀寫都要加鎖),所以在某些特定場景中它發生鎖競爭的頻率會遠遠小於map+RWLock的實現方式。

func BenchmarkMapConcurrencySafeBySyncMap(b *testing.B) {    var m sync.Map    var wg sync.WaitGroup    for i := 0; i < b.N; i++ {        wg.Add(1)        go func(i int) {            defer wg.Done()            m.Store(i, i)        }(i)    }    wg.Wait()    b.Log(b.N)}

雜湊衝突

golang中map是一個kv對集合。底層使用hash table,用連結串列來解決衝突 ,出現衝突時,不是每一個key都申請一個結構通過連結串列串起來,而是以bmap為最小粒度掛載,一個bmap可以放8個kv。在雜湊函式的選擇上,會在程式啟動時,檢測 cpu 是否支援 aes,如果支援,則使用 aes hash,否則使用 memhash。

常用操作

建立

map有3鍾初始化方式,一般通過make方式建立

func TestMapInit(t *testing.T) {    // 初始化方式1:直接宣告    // var m1 map[string]int    // m1["a"] = 1    // t.Log(m1, unsafe.Sizeof(m1))    // panic: assignment to entry in nil map    // 向 map 寫入要非常小心,因為向未初始化的 map(值為 nil)寫入會引發 panic,所以向 map 寫入時需先進行判空操作    // 初始化方式2:使用字面量    m2 := map[string]int{}    m2["a"] = 2    t.Log(m2, unsafe.Sizeof(m2))    // map[a:2] 8    // 初始化方式3:使用make建立    m3 := make(map[string]int)    m3["a"] = 3    t.Log(m3, unsafe.Sizeof(m3))    // map[a:3] 8}

map的建立通過生成彙編碼可以知道,make建立map時呼叫的底層函式是runtime.makemap。如果你的map初始容量小於等於8會發現走的是runtime.fastrand是因為容量小於8時不需要生成多個桶,一個桶的容量就可以滿足

建立流程

徹底理解Golang Map

makemap函式會通過 fastrand 建立一個隨機的雜湊種子,然後根據傳入的 hint 計算出需要的最小需要的桶的數量,最後再使用 makeBucketArray建立用於儲存桶的陣列,這個方法其實就是根據傳入的 B 計算出的需要建立的桶數量在記憶體中分配一片連續的空間用於儲存資料,在建立桶的過程中還會額外建立一些用於儲存溢位資料的桶,數量是 2^(B-4) 個。初始化完成返回hmap指標。

計算B的初始值

找到一個 B,使得 map 的裝載因子在正常範圍內

B := uint8(0)for overLoadFactor(hint, B) {    B++}h.B = B// overLoadFactor reports whether count items placed in 1<<B buckets is over loadFactor.func overLoadFactor(count int, B uint8) bool {    return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)}

查詢

Go 語言中讀取 map 有兩種語法:帶 comma 和 不帶 comma。當要查詢的 key 不在 map 裡,帶 comma 的用法會返回一個 bool 型變數提示 key 是否在 map 中;而不帶 comma 的語句則會返回一個 value 型別的零值。如果 value 是 int 型就會返回 0,如果 value 是 string 型別,就會返回空字串。

// 不帶 comma 用法value := m["name"]fmt.Printf("value:%s", value)// 帶 comma 用法value, ok := m["name"]if ok {    fmt.Printf("value:%s", value)}

map的查詢通過生成彙編碼可以知道,根據 key 的不同型別,編譯器會將查詢函式用更具體的函式替換,以優化效率:

key 型別查詢
uint32mapaccess1_fast32(t maptype, h hmap, key uint32) unsafe.Pointer
uint32mapaccess2_fast32(t maptype, h hmap, key uint32) (unsafe.Pointer, bool)
uint64mapaccess1_fast64(t maptype, h hmap, key uint64) unsafe.Pointer
uint64mapaccess2_fast64(t maptype, h hmap, key uint64) (unsafe.Pointer, bool)
stringmapaccess1_faststr(t maptype, h hmap, ky string) unsafe.Pointer
stringmapaccess2_faststr(t maptype, h hmap, ky string) (unsafe.Pointer, bool)

查詢流程

徹底理解Golang Map

防寫監測

函式首先會檢查 map 的標誌位 flags。如果 flags 的寫標誌位此時被置 1 了,說明有其他協程在執行“寫”操作,進而導致程式 panic。這也說明了 map 對協程是不安全的。

if h.flags&hashWriting != 0 {    throw("concurrent map read and map write")}

計算hash值

hash := t.hasher(noescape(unsafe.Pointer(&ky)), uintptr(h.hash0))

key經過雜湊函式計算後,得到的雜湊值如下(主流64位機下共 64 個 bit 位):

 10010111 | 000011110110110010001111001010100010010110010101010 │ 01010

找到hash對應的bucket

m: 桶的個數

從buckets 通過 hash & m 得到對應的bucket,如果bucket正在擴容,並且沒有擴容完成,則從oldbuckets得到對應的bucket

m := bucketMask(h.B)b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))// m個桶對應B個位if c := h.oldbuckets; c != nil {  if !h.sameSizeGrow() {      // 擴容前m是之前的一半      m >>= 1  }  oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))  if !evacuated(oldb) {      b = oldb    }}

計算hash所在桶編號:

用上一步雜湊值最後的 5 個 bit 位,也就是 01010,值為 10,也就是 10 號桶(範圍是0~31號桶)

遍歷bucket

計算hash所在的槽位:

top := tophash(hash)func tophash(hash uintptr) uint8 {    top := uint8(hash >> (goarch.PtrSize*8 - 8))    if top < minTopHash {        top += minTopHash    }    return top}

用上一步雜湊值雜湊值的高8個bit 位,也就是10010111,轉化為十進位制,也就是151,在 10 號 bucket 中尋找 tophash 值(HOB hash)為 151* 的 槽位,即為key所在位置,找到了 2 號槽位,這樣整個查詢過程就結束了。

img

如果在 bucket 中沒找到,並且 overflow 不為空,還要繼續去 overflow bucket 中尋找,直到找到或是所有的 key 槽位都找遍了,包括所有的 overflow bucket。

返回key對應的指標

通過上面找到了對應的槽位,這裡我們再詳細分析下key/value值是如何獲取的:

// key 定位公式k :=add(unsafe.Pointer(b),dataOffset+i*uintptr(t.keysize))// value 定位公式v:= add(unsafe.Pointer(b),dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))//對於 bmap 起始地址的偏移:dataOffset = unsafe.Offsetof(struct{  b bmap  v int64}{}.v)

bucket 裡 key 的起始地址就是 unsafe.Pointer(b)+dataOffset。第 i 個 key 的地址就要在此基礎上跨過 i 個 key 的大小;而我們又知道,value 的地址是在所有 key 之後,因此第 i 個 value 的地址還需要加上所有 key 的偏移。

賦值

通過組合語言可以看到,向 map 中插入或者修改 key,最終呼叫的是 mapassign 函式。

實際上插入或修改 key 的語法是一樣的,只不過前者操作的 key 在 map 中不存在,而後者操作的 key 存在 map 中。

mapassign 有一個系列的函式,根據 key 型別的不同,編譯器會將其優化為相應的“快速函式”。

key 型別插入
uint32mapassign_fast32(t maptype, h hmap, key uint32) unsafe.Pointer
uint64mapassign_fast64(t maptype, h hmap, key uint64) unsafe.Pointer
stringmapassign_faststr(t maptype, h hmap, ky string) unsafe.Pointer

我們只用研究最一般的賦值函式 mapassign

賦值流程

徹底理解Golang Map

map的賦值會附帶著map的擴容和遷移,map的擴容只是將底層陣列擴大了一倍,並沒有進行資料的轉移,資料的轉移是在擴容後逐步進行的,在遷移的過程中每進行一次賦值(access或者delete)會至少做一次遷移工作。

校驗和初始化

1.判斷map是否為nil

  1. 判斷是否併發讀寫 map,若是則丟擲異常
  2. 判斷 buckets 是否為 nil,若是則呼叫 newobject 根據當前 bucket 大小進行分配

遷移

每一次進行賦值/刪除操作時,只要oldbuckets != nil 則認為正在擴容,會做一次遷移工作,下面會詳細說下遷移過程

查詢&更新

根據上面查詢過程,查詢key所在位置,如果找到則更新,沒找到則找空位插入即可

擴容

經過前面迭代尋找動作,若沒有找到可插入的位置,意味著需要擴容進行插入,下面會詳細說下擴容過程

刪除

通過組合語言可以看到,向 map 中刪除 key,最終呼叫的是 mapdelete 函式

func mapdelete(t \*maptype, h _hmap, key unsafe.Pointer)

刪除的邏輯相對比較簡單,大多函式在賦值操作中已經用到過,核心還是找到 key 的具體位置。尋找過程都是類似的,在 bucket 中挨個 cell 尋找。找到對應位置後,對 key 或者 value 進行“清零”操作,將 count 值減 1,將對應位置的 tophash 值置成 Empty

e := add(unsafe.Pointer(b), dataOffset+bucketCnt*2*goarch.PtrSize+i*uintptr(t.elemsize))if t.elem.ptrdata != 0 {    memclrHasPointers(e, t.elem.size)} else {    memclrNoHeapPointers(e, t.elem.size)}b.tophash[i] = emptyOne

擴容

擴容時機

再來說觸發 map 擴容的時機:在向 map 插入新 key 的時候,會進行條件檢測,符合下面這 2 個條件,就會觸發擴容:

if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {        hashGrow(t, h)        goto again // Growing the table invalidates everything, so try again    }

1、裝載因子超過閾值

原始碼裡定義的閾值是 6.5 (loadFactorNum/loadFactorDen),是經過測試後取出的一個比較合理的因子

我們知道,每個 bucket 有 8 個空位,在沒有溢位,且所有的桶都裝滿了的情況下,裝載因子算出來的結果是 8。因此當裝載因子超過 6.5 時,表明很多 bucket 都快要裝滿了,查詢效率和插入效率都變低了。在這個時候進行擴容是有必要的。

對於條件 1,元素太多,而 bucket 數量太少,很簡單:將 B 加 1,bucket 最大數量(2^B)直接變成原來 bucket 數量的 2 倍。於是,就有新老 bucket 了。注意,這時候元素都在老 bucket 裡,還沒遷移到新的 bucket 來。新 bucket 只是最大數量變為原來最大數量的 2 倍(2^B * 2) 。

2、overflow 的 bucket 數量過多

在裝載因子比較小的情況下,這時候 map 的查詢和插入效率也很低,而第 1 點識別不出來這種情況。表面現象就是計算裝載因子的分子比較小,即 map 裡元素總數少,但是 bucket 數量多(真實分配的 bucket 數量多,包括大量的 overflow bucket)

不難想像造成這種情況的原因:不停地插入、刪除元素。先插入很多元素,導致建立了很多 bucket,但是裝載因子達不到第 1 點的臨界值,未觸發擴容來緩解這種情況。之後,刪除元素降低元素總數量,再插入很多元素,導致建立很多的 overflow bucket,但就是不會觸發第 1 點的規定,你能拿我怎麼辦?overflow bucket 數量太多,導致 key 會很分散,查詢插入效率低得嚇人,因此出臺第 2 點規定。這就像是一座空城,房子很多,但是住戶很少,都分散了,找起人來很困難

對於條件 2,其實元素沒那麼多,但是 overflow bucket 數特別多,說明很多 bucket 都沒裝滿。解決辦法就是開闢一個新 bucket 空間,將老 bucket 中的元素移動到新 bucket,使得同一個 bucket 中的 key 排列地更緊密。這樣,原來,在 overflow bucket 中的 key 可以移動到 bucket 中來。結果是節省空間,提高 bucket 利用率,map 的查詢和插入效率自然就會提升。

擴容函式

func hashGrow(t *maptype, h *hmap) {    bigger := uint8(1)    if !overLoadFactor(h.count+1, h.B) {        bigger = 0        h.flags |= sameSizeGrow    }    oldbuckets := h.buckets    newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)    flags := h.flags &^ (iterator | oldIterator)    if h.flags&iterator != 0 {        flags |= oldIterator    }    // commit the grow (atomic wrt gc)    h.B += bigger    h.flags = flags    h.oldbuckets = oldbuckets    h.buckets = newbuckets    h.nevacuate = 0    h.noverflow = 0    if h.extra != nil && h.extra.overflow != nil {        // Promote current overflow buckets to the old generation.        if h.extra.oldoverflow != nil {            throw("oldoverflow is not nil")        }        h.extra.oldoverflow = h.extra.overflow        h.extra.overflow = nil    }    if nextOverflow != nil {        if h.extra == nil {            h.extra = new(mapextra)        }        h.extra.nextOverflow = nextOverflow    }    // the actual copying of the hash table data is done incrementally    // by growWork() and evacuate().}

由於 map 擴容需要將原有的 key/value 重新搬遷到新的記憶體地址,如果有大量的 key/value 需要搬遷,會非常影響效能。因此 Go map 的擴容採取了一種稱為“漸進式”的方式,原有的 key 並不會一次性搬遷完畢,每次最多隻會搬遷 2 個 bucket。

上面說的 hashGrow() 函式實際上並沒有真正地“搬遷”,它只是分配好了新的 buckets,並將老的 buckets 掛到了 oldbuckets 欄位上。真正搬遷 buckets 的動作在 growWork() 函式中,而呼叫 growWork() 函式的動作是在 mapassign 和 mapdelete 函式中。也就是插入或修改、刪除 key 的時候,都會嘗試進行搬遷 buckets 的工作。先檢查 oldbuckets 是否搬遷完畢,具體來說就是檢查 oldbuckets 是否為 nil。

遷移

遷移時機

如果未遷移完畢,賦值/刪除的時候,擴容完畢後(預分配記憶體),不會馬上就進行遷移。而是採取增量擴容的方式,當有訪問到具體 bukcet 時,才會逐漸的進行遷移(將 oldbucket 遷移到 bucket)

if h.growing() {        growWork(t, h, bucket)}

遷移函式

func growWork(t *maptype, h *hmap, bucket uintptr) {    // 首先把需要操作的bucket 搬遷    evacuate(t, h, bucket&h.oldbucketmask())     // 再順帶搬遷一個bucket    if h.growing() {        evacuate(t, h, h.nevacuate)    }}

nevacuate 標識的是當前的進度,如果都搬遷完,應該和2^B的長度是一樣的

在evacuate 方法實現是把這個位置對應的bucket,以及其衝突鏈上的資料都轉移到新的buckets上。

  1. 先要判斷當前bucket是不是已經轉移。 (oldbucket 標識需要搬遷的bucket 對應的位置)
b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))// 判斷if !evacuated(b) {  // 做轉移操作}

轉移的判斷直接通過tophash 就可以,判斷tophash中第一個hash值即可

func evacuated(b *bmap) bool {  h := b.tophash[0]  // 這個區間的flag 均是已被轉移  return h > emptyOne && h < minTopHash // 1 ~ 5}
  1. 如果沒有被轉移,那就要遷移資料了。資料遷移時,可能是遷移到大小相同的buckets上,也可能遷移到2倍大的buckets上。這裡xy 都是標記目標遷移位置的標記:x 標識的是遷移到相同的位置,y 標識的是遷移到2倍大的位置上。我們先看下目標位置的確定:
var xy [2]evacDstx := &xy[0]x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))x.k = add(unsafe.Pointer(x.b), dataOffset)x.v = add(x.k, bucketCnt*uintptr(t.keysize))if !h.sameSizeGrow() {  // 如果是2倍的大小,就得算一次 y 的值  y := &xy[1]  y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))  y.k = add(unsafe.Pointer(y.b), dataOffset)  y.v = add(y.k, bucketCnt*uintptr(t.keysize))}
  1. 確定bucket位置後,需要按照kv 一條一條做遷移。
  2. 如果當前搬遷的bucket 和 總體搬遷的bucket的位置是一樣的,我們需要更新總體進度的標記 nevacuate
// newbit 是oldbuckets 的長度,也是nevacuate 的重點func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) {  // 首先更新標記  h.nevacuate++  // 最多檢視2^10 個bucket  stop := h.nevacuate + 1024  if stop > newbit {    stop = newbit  }  // 如果沒有搬遷就停止了,等下次搬遷  for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {    h.nevacuate++  }  // 如果都已經搬遷完了,oldbukets 完全搬遷成功,清空oldbuckets  if h.nevacuate == newbit {    h.oldbuckets = nil    if h.extra != nil {      h.extra.oldoverflow = nil    }    h.flags &^= sameSizeGrow  }

遍歷

遍歷的過程,就是按順序遍歷 bucket,同時按順序遍歷 bucket 中的 key。

map遍歷是無序的,如果想實現有序遍歷,可以先對key進行排序

為什麼遍歷 map 是無序的?

如果發生過遷移,key 的位置發生了重大的變化,有些 key 飛上高枝,有些 key 則原地不動。這樣,遍歷 map 的結果就不可能按原來的順序了。

如果就一個寫死的 map,不會向 map 進行插入刪除的操作,按理說每次遍歷這樣的 map 都會返回一個固定順序的 key/value 序列吧。但是 Go 杜絕了這種做法,因為這樣會給新手程式設計師帶來誤解,以為這是一定會發生的事情,在某些情況下,可能會釀成大錯。

Go 做得更絕,當我們在遍歷 map 時,並不是固定地從 0 號 bucket 開始遍歷,每次都是從一個隨機值序號的 bucket 開始遍歷,並且是從這個 bucket 的一個隨機序號的 cell 開始遍歷。這樣,即使你是一個寫死的 map,僅僅只是遍歷它,也不太可能會返回一個固定序列的 key/value 對了。

//runtime.mapiterinit 遍歷時選用初始桶的函式func mapiterinit(t *maptype, h *hmap, it *hiter) {  ...  it.t = t  it.h = h  it.B = h.B  it.buckets = h.buckets  if t.bucket.kind&kindNoPointers != 0 {    h.createOverflow()    it.overflow = h.extra.overflow    it.oldoverflow = h.extra.oldoverflow  }  r := uintptr(fastrand())  if h.B > 31-bucketCntBits {    r += uintptr(fastrand()) << 31  }  it.startBucket = r & bucketMask(h.B)  it.offset = uint8(r >> h.B & (bucketCnt - 1))  it.bucket = it.startBucket    ...  mapiternext(it)}

總結

  1. map是引用型別
  2. map遍歷是無序的
  3. map是非執行緒安全的
  4. map的雜湊衝突解決方式是連結串列法
  5. map的擴容不是一定會新增空間,也有可能是隻是做了記憶體整理
  6. map的遷移是逐步進行的,在每次賦值時,會做至少一次遷移工作
  7. map中刪除key,有可能導致出現很多空的kv,這會導致遷移操作,如果可以避免,儘量避免

    本文由部落格一文多發平臺 OpenWrite 釋出!