動態

詳情 返回 返回

Map 的源碼分析、內存分配、擴容機制-Golang 🔥 - 動態 詳情

Go 語言的 map 是內置的鍵值對(Key-Value)集合類型,是基於哈希表實現的高效數據結構,用於高效存儲和查找數據。其核心特性如下:

  • 無序性:map 中的鍵值對存儲順序不固定,無法通過索引訪問(區別於切片)。
  • 鍵唯一性:鍵(Key)必須唯一,重複插入同一鍵會覆蓋舊值。
  • 動態大小:map 會根據存儲的數據量自動擴容,無需手動管理內存。

通過深入理解 map 的源碼和內存分配,開發者可以更高效地管理內存,避免不必要的性能損耗,編寫出更優的 Go 代碼。
以下內容基於 golang 1.24.5 源碼。

1、 Map 底層數據結構

Map 的底層核心數據結構包括 hmap(哈希表頭)、bmap(哈希桶)和 mapextra(溢出桶元數據),設計目標是優化內存利用率和訪問效率。

1.1 hmap 結構體(哈希表頭)

hmap 是 map 的元數據頭,存儲全局狀態信息。

// go 1.24.5
// A header for a Go map.
type hmap struct {
    // Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
    // Make sure this stays in sync with the compiler's definition.
    count     int // # live cells == size of map.  Must be first (used by len() builtin)
    flags     uint8
    B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
    noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
    hash0     uint32 // hash seed

    buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
    oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
    nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)
    clearSeq   uint64

    extra *mapextra // optional fields
}

關鍵字段説明:

  • count:當前元素總數(len(map))。
  • flags:狀態標誌(如擴容中、寫入中)。
  • B:決定初始桶數量(2^B)。例如,B=5 時,初始桶數量為 32(2^5)。
  • noverflow:溢出桶近似數量(用於觸發等量擴容)
  • hash0:哈希種子(隨機化哈希計算)
  • buckets:指向當前活躍的桶數組,每個桶是一個 bmap 結構體。
  • oldbuckets:擴容時臨時存儲舊桶數組,遷移完成後釋放。
  • nevacuate:記錄漸進式遷移的進度,確保每次操作僅遷移少量舊桶(避免性能抖動)。

1.2 bmap 結構體(哈希桶)

每個桶(bmap)存儲最多 8 個鍵值對,以及哈希值的高 8 位(用於快速匹配鍵)。

// go 1.24.5
// A bucket for a Go map.
type bmap struct {
    // 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.
    tophash [abi.OldMapBucketCount]uint8
    // Followed by bucketCnt keys and then bucketCnt elems.
    // NOTE: packing all the keys together and then all the elems together makes the
    // code a bit more complicated than alternating key/elem/key/elem/... but it allows
    // us to eliminate padding which would be needed for, e.g., map[int64]int8.
    // Followed by an overflow pointer.
}

1.3 mapextra 結構體(溢出桶元數據)

mapextra 用於存儲溢出桶的切片信息,僅在存在溢出桶時分配。

// go 1.24.5
// mapextra holds fields that are not present on all maps.
type mapextra struct {
    // If both key and elem do not contain pointers and are inline, then we mark bucket
    // type as containing no pointers. This avoids scanning such maps.
    // However, bmap.overflow is a pointer. In order to keep overflow buckets
    // alive, we store pointers to all overflow buckets in hmap.extra.overflow and hmap.extra.oldoverflow.
    // overflow and oldoverflow are only used if key and elem do not contain pointers.
    // overflow contains overflow buckets for hmap.buckets.
    // oldoverflow contains overflow buckets for hmap.oldbuckets.
    // The indirection allows to store a pointer to the slice in hiter.
    overflow    *[]*bmap
    oldoverflow *[]*bmap

    // nextOverflow holds a pointer to a free overflow bucket.
    nextOverflow *bmap
}

關鍵字説明

  • overflow:快速訪問當前桶的所有溢出桶(通過切片遍歷)。
  • oldoverflow:擴容時臨時存儲舊溢出桶的切片,遷移完成後釋放。

2、Map 的內存分配規則

Map 的內存分配分為初始分配、擴容分配和溢出桶分配三種場景,核心是 hmap 結構體、桶數組(buckets)和溢出桶的動態管理。

2.1 初始分配(創建 map)

當通過 make(map[K]V, hint) 或字面量創建 Map 時,Go 會根據初始容量(hint)分配內存。
先看 make 源碼:

// go 1.24.5
// 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.
//
// makemap should be an internal detail,
// but widely used packages access it using linkname.
// Notable members of the hall of shame include:
//   - github.com/ugorji/go/codec
//
// Do not remove or change the type signature.
// See go.dev/issue/67401.
//
//go:linkname makemap
func makemap(t *maptype, hint int, h *hmap) *hmap {
    mem, overflow := math.MulUintptr(uintptr(hint), t.Bucket.Size_)
    if overflow || mem > maxAlloc {
        hint = 0
    }

    // initialize Hmap
    if h == nil {
        h = new(hmap)
    }
    h.hash0 = uint32(rand())

    // Find the size parameter B which will hold the requested # of elements.
    // For hint < 0 overLoadFactor returns false since hint < bucketCnt.
    B := uint8(0)
    for overLoadFactor(hint, B) {
        B++
    }
    h.B = B

    // allocate initial hash table
    // if B == 0, the buckets field is allocated lazily later (in mapassign)
    // If hint is large zeroing this memory could take a while.
    if h.B != 0 {
        var nextOverflow *bmap
        h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
        if nextOverflow != nil {
            h.extra = new(mapextra)
            h.extra.nextOverflow = nextOverflow
        }
    }

    return h
}
2.1.1 無初始容量(hint=0)
m := make(map[int]string) // hmap 分配在堆,buckets=nil(未初始化)
  • hmap 分配:首先在堆上分配 hmap 結構體(因 hmap 可能被全局引用或長期存活,逃逸到堆)。
  • 桶數組延遲初始化:初始時不分配桶數組(bucketsnil),直到首次插入鍵值對時觸發“延遲初始化”(分配初始桶數組)。
2.1.2 有初始容量(hint>0
m := make(map[int]string, 10) // hint=10,bucketCnt=16(2^4),B=4,分配 16 個桶
  • 計算桶數量:根據 hint 計算所需的最小桶數量(bucketCnt = 8,若 hint ≤ 8 則初始桶數為 1,否則 bucketCnt = 1 << ceil(log2(hint/8)))
  • 分配 hmap:堆上分配 hmap 結構體。
  • 預分配桶數組:分配 2^B 個桶(B = ceil(log2(bucketCnt))),並將 hmap.buckets 指向其地址。

2.2 溢出桶的分配

當單個桶的 8 個槽位填滿時(bucketCnt=8),Go 會動態創建溢出桶:

  • 檢查空閒桶緩存:從 runtime.bucketPool(空閒桶緩存)中獲取一個空閒桶(減少內存分配開銷)。
  • 新建溢出桶:若緩存為空,則在堆上新建一個 bmap 結構體作為溢出桶。
  • 鏈接溢出桶:將新溢出桶的指針賦值給當前桶的 overflow 字段,並將新桶添加到 mapextra.overflow 切片中。

2.3 內存分配的位置(棧 vs 堆)

  • hmap 結構體:始終分配在堆上。即使通過字面量創建局部 map(如 m := map[int]int{}),編譯器也會將其逃逸到堆(因 map 可能被外部引用)。
  • 桶數組(buckets):分配在堆上(由 hmap.buckets 指針指向)。
  • 溢出桶:分配在堆上(除非極端情況下被緩存複用,但本質仍屬於堆內存)。

3、Map 的擴容機制

擴容是 map 調整容量的核心操作,目的是在數據量增長時保持 O(1) 的查找/插入性能。Map 的擴容採用漸進式策略,避免一次性遷移數據導致的性能抖動。

3.1 擴容觸發條件

Go 的 map 擴容由兩個條件觸發。

3.1.1 負載因子超過閾值(主要條件)

負載因子(Load Factor)定義為【元素數量 / 桶數量】,即 count / 2^B
默認負載因子閾值為 6.5。當負載因子大於 6.5 時,説明桶的平均存儲量過高(每個桶平均存儲 6.5 個鍵值對),哈希衝突概率增大,此時觸發增量擴容(桶數量翻倍)。

3.1.2 溢出桶過多(次要條件)

當溢出桶數量(noverflow)過多時,即使負載因子未達閾值,也會觸發等量擴容(桶數量不變)。
溢出桶過多的判斷條件是:noverflow > 1 << (B-4)(即當 B ≥ 4 時,溢出桶數量超過 2^(B-4))。此時觸發等量擴容,目的是通過重新排列鍵值對,消除冗餘的溢出桶,提升內存利用率。

3.2 擴容類型與內存分配

根據觸發條件不同,擴容分為兩種類型,內存分配策略也不同。

3.2.1 增量擴容(Double Size Expansion)
  • 觸發條件:負載因子 > 6.5
  • 目標:將桶數量翻倍(B → B+1),總桶數從 2^B 變為 2^(B+1)
  • 內存分配步驟:

    1. 分配新桶數組:創建新的桶數組(大小為 2^(B+1)),並將 hmap.oldbuckets 指向舊桶數組(hmap.buckets)。
    2. 更新 hmap 元數據:hmap.B1hmap.nevacuate 初始化為 0(遷移進度)。
    3. 漸進式遷移:每次插入、刪除或修改操作時,遷移少量舊桶到新桶(通常是 1~2 個),直到所有舊桶遷移完成。
  • 內存變化示例:
    假設原 B=4(桶數量 16),觸發增量擴容後:
    新桶數量為 32(B=5)
    舊桶數組(16 個桶)被保存到 oldbuckets
    新桶數組(32 個桶)初始化為空,等待遷移數據。
3.2.2 等量擴容(Same Size Expansion)
  • 觸發條件:溢出桶過多(noverflow > 1 << (B-4))。
  • 目標:桶數量不變(B 不變),但重新排列鍵值對,消除冗餘的溢出桶。
  • 內存分配步驟:

    1. 分配新桶數組:創建與舊桶數組相同大小的新桶數組(2^B 個桶),並將 hmap.oldbuckets 指向舊桶數組。
    2. 更新 hmap 元數據:hmap.nevacuate 初始化為 0
    3. 漸進式遷移:將舊桶(包括溢出桶)中的鍵值對壓縮到新桶數組中,儘可能填滿空閒槽位,減少溢出桶的使用。
  • 內存變化示例:
    假設原 B=5(桶數量 32),溢出桶數量過多觸發等量擴容後:
    新桶數組仍為 32 個桶。
    舊桶數組(含溢出桶)被保存到 oldbuckets
    新桶數組通過重新哈希舊數據,減少溢出桶的使用。

3.3 漸進式遷移的具體實現

為避免一次性遷移所有數據導致的性能卡頓,map 採用漸進式遷移策略:

  • 遷移觸發時機:每次對 map 執行插入、刪除或修改操作時,遷移少量舊桶(通常是 1 個桶,若操作頻繁則增加)。
  • 遷移步驟:

    1. 檢查 hmap.oldbuckets 是否為 nil(無舊桶則無需遷移)。
    2. 計算當前需要遷移的桶索引(i := hmap.nevacuate)。
    3. 將舊桶 oldbuckets[i] 中的鍵值對重新哈希到新桶數組的對應位置(新桶索引為 ii + 2^B,因桶數量翻倍)。
    4. 更新 hmap.nevacuate(i++),標記該舊桶已遷移。
  • 當所有舊桶遷移完成(i == 2^B),將 hmap.oldbuckets 置為 nil,釋放舊桶內存(由 GC 回收)。

3.4 擴容對內存的影響

  • 內存佔用:增量擴容時,內存佔用翻倍(新舊桶數組同時存在);等量擴容時,內存佔用基本不變(新舊桶數組大小相同)。
  • GC 壓力:擴容期間新舊桶數組同時存在,GC 需掃描更多內存;遷移完成後,舊桶數組被釋放,GC 壓力降低。

4、Map 內存的生命週期與釋放

Map 的內存釋放依賴 Go 的垃圾回收(GC),核心規則如下:

4.1 hmap 結構體的釋放

Map 不再被任何變量引用時(引用計數為 0),hmap 結構體被 GC 標記為可回收。

4.2 桶數組與溢出桶的釋放

  • 正常情況:當 map 被回收時,hmap.buckets 和所有溢出桶(通過 overflow 鏈表和 mapextra.overflow 切片鏈接)被 GC 遞歸回收。
  • 擴容期間:舊桶數組(oldbuckets)在遷移完成後被置為 nil,失去引用,隨後被 GC 回收。

4.3 手動釋放內存的誤區

Go 沒有顯式的內存釋放機制(如 Cfree),無法手動釋放 map 的內存。若需強制釋放,可通過將 map 置為 nil,使其失去引用,等待 GC 回收。

5、驗證示例

5.1 觀察擴容過程(通過 runtime 包)

通過 runtime/pproftrace 工具觀察 map 擴容的內存變化:

package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    m := make(map[int]int, 1) // 初始容量 1(B=0,桶數量 1)
    for i := 0; i < 1000; i++ {
        m[i] = i
        if i%100 == 0 {
            runtime.GC() // 手動觸發 GC,觀察內存變化
            time.Sleep(100 * time.Millisecond)
        }
    }
}

通過 go tool trace trace.out 可查看內存分配和 GC 事件,觀察到擴容時內存佔用翻倍(增量擴容),遷移完成後內存穩定。

5.2 驗證溢出桶分配(通過 go build -gcflags="-m"

通過逃逸分析驗證溢出桶的內存分配位置:

package main

func createMap() map[int]int {
    m := make(map[int]int) // 逃逸到堆
    for i := 0; i < 10; i++ {
        m[i] = i // 觸發溢出桶分配(堆上)
    }
    return m
}

func main() {
    _ = createMap()
}

編譯命令:

go build -gcflags="-m -l" main.go

輸出示例:

./main.go:4:11: &m escapes to heap
./main.go:3:6: moved to heap: m
./main.go:7:10: m[i] = i escapes to heap

輸出表明 m 及其溢出桶均分配在堆上。

6、注意事項

  • 初始化:顯式初始化 map,避免 nil map
  • 線程安全:併發訪問需加鎖或使用 sync.Map,高頻讀寫的話加分片鎖。
  • 內存與性能:預分配容量、避免頻繁擴容、選擇合適的鍵值類型。
  • 遍歷與修改:避免遍歷時修改插入或刪除,修改的話注意是否僅修改了副本。
user avatar wric 頭像 emanjusaka 頭像 gouguoyin 頭像 ansurfen 頭像 zengh 頭像 tyltr 頭像 lixingning 頭像 dadebinglin 頭像 youngcoding 頭像 changhao_flag 頭像 niandou 頭像 hanhoudeniupai 頭像
點贊 15 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.