天天看點

Go語言複合類型map的基本操作

作者:jodkreaper

插入新鍵值對

面對一個非 nil 的 map 類型變量,我們可以在其中插入符合 map 類型定義的任意新鍵值對。插入新鍵值對的方式很簡單,我們隻需要把 value 指派給 map 中對應的 key 就可以了:

m := make(map[int]string)
m[1] = "value1"
m[2] = "value2"
m[3] = "value3"           

Go 運作時會負責 map 變量内部的記憶體管理,是以除非是系統記憶體耗盡,我們可以不用擔心向 map 中插入新資料的數量和執行結果。

如果我們插入新鍵值對的時候,某個 key 已經存在于 map 中了,那我們的插入操作就會用新值覆寫舊值:

m := map[string]int {
  "key1" : 1,
  "key2" : 2,
}

m["key1"] = 11 // 11會覆寫掉"key1"對應的舊值1
m["key3"] = 3  // 此時m為map[key1:11 key2:2 key3:3]           

擷取鍵值對數量

想知道目前 map 類型變量中已經建立了多少個鍵值對,那我們可以怎麼做呢?和切片一樣,map 類型也可以通過内置函數 len,擷取目前變量已經存儲的鍵值對數量:

m := map[string]int {
  "key1" : 1,
  "key2" : 2,
}

fmt.Println(len(m)) // 2
m["key3"] = 3  
fmt.Println(len(m)) // 3           

查找和資料讀取

和寫入相比,map 類型更多用在查找和資料讀取場合。

m := make(map[string]int)
v := m["key1"]           

第二行代碼在文法上好像并沒有什麼不當之處,但其實通過這行語句,我們還是無法确定鍵 key1 是否真實存在于 map 中。這是因為,當我們嘗試去擷取一個鍵對應的值的時候,如果這個鍵在 map 中并不存在,我們也會得到一個值,這個值是 value 元素類型的零值。

Go 語言的 map 類型支援通過用一種名為“comma ok”的慣用法,進行對某個 key 的查詢。接下來我們就用“comma ok”慣用法改造一下上面的代碼:

m := make(map[string]int)
v, ok := m["key1"]
if !ok {
    // "key1"不在map中
}

// "key1"在map中,v将被賦予"key1"鍵對應的value           

這裡我們通過了一個布爾類型變量 ok,來判斷鍵“key1”是否存在于 map 中。如果存在,變量 v 就會被正确地指派為鍵“key1”對應的 value。一定要記住:在 Go 語言中,請使用“comma ok”慣用法對 map 進行鍵查找和鍵值讀取操作。

删除資料

在 Go 中,我們需要借助内置函數 delete 來從 map 中删除資料。使用 delete 函數的情況下,傳入的第一個參數是我們的 map 類型變量,第二個參數就是我們想要删除的鍵。

m := map[string]int {
  "key1" : 1,
  "key2" : 2,
}

fmt.Println(m) // map[key1:1 key2:2]
delete(m, "key2") // 删除"key2"
fmt.Println(m) // map[key1:1]           

delete 函數是從 map 中删除鍵的唯一方法。即便傳給 delete 的鍵在 map 中并不存在,delete 函數的執行也不會失敗,更不會抛出運作時的異常。

周遊 map 中的鍵值資料

在 Go 中,周遊 map 的鍵值對隻有一種方法,那就是像對待切片那樣通過 for range 語句對 map 資料進行周遊。

package main
  
import "fmt"

func main() {
    m := map[int]int{
        1: 11,
        2: 12,
        3: 13,
    }

    fmt.Printf("{ ")
    for k, v := range m {
        fmt.Printf("[%d, %d] ", k, v)
    }
    fmt.Printf("}\n")
}           

通過 for range 周遊 map 變量 m,每次疊代都會傳回一個鍵值對,其中鍵存在于變量 k 中,它對應的值存儲在變量 v 中。

對同一 map 做多次周遊的時候,每次周遊元素的次序都不相同。這是 Go 語言 map 類型的一個重要特點,也是很容易讓 Go 初學者掉入坑中的一個地方。是以這裡你一定要記住:程式邏輯千萬不要依賴周遊 map 所得到的的元素次序。

map 的内部實作

Go 運作時使用一張哈希表來實作抽象的 map 類型。運作時實作了 map 類型操作的所有功能,包括查找、插入、删除等。在編譯階段,Go 編譯器會将 Go 文法層面的 map 操作,重寫成運作時對應的函數調用。大緻的對應關系是這樣的:

// 建立map類型變量執行個體
m := make(map[keyType]valType, capacityhint) → m := runtime.makemap(maptype, capacityhint, m)

// 插入新鍵值對或給鍵重新指派
m["key"] = "value" → v := runtime.mapassign(maptype, m, "key") v是用于後續存儲value的空間的位址

// 擷取某鍵的值 
v := m["key"]      → v := runtime.mapaccess1(maptype, m, "key")
v, ok := m["key"]  → v, ok := runtime.mapaccess2(maptype, m, "key")

// 删除某鍵
delete(m, "key")   → runtime.mapdelete(maptype, m, “key”)           

這是 map 類型在 Go 運作時層實作的示意圖:

Go語言複合類型map的基本操作

和切片的運作時表示圖相比,map 的實作示意圖顯然要複雜得多,我們重點關注一個 map 變量在初始狀态、進行鍵值對操作後,以及在并發場景下的 Go 運作時層的實作原理。

1、初始狀态

從圖中我們可以看到,與文法層面 map 類型變量(m)一一對應的是 *runtime.hmap 的執行個體,即 runtime.hmap 類型的指針,也就是我們前面在講解 map 類型變量傳遞開銷時提到的 map 類型的描述符。hmap 類型是 map 類型的頭部結構(header),它存儲了後續 map 類型操作所需的所有資訊,包括:

Go語言複合類型map的基本操作

真正用來存儲鍵值對資料的是桶,也就是 bucket,每個 bucket 中存儲的是 Hash 值低 bit 位數值相同的元素,預設的元素個數為 BUCKETSIZE(值為 8,Go 1.17 版本中在 $GOROOT/src/cmd/compile/internal/reflectdata/reflect.go 中定義,與 runtime/map.go 中常量 bucketCnt 保持一緻)。當某個 bucket(比如 buckets[0]) 的 8 個空槽 slot)都填滿了,且 map 尚未達到擴容的條件的情況下,運作時會建立 overflow bucket,并将這個 overflow bucket 挂在上面 bucket(如 buckets[0])末尾的 overflow 指針上,這樣兩個 buckets 形成了一個連結清單結構,直到下一次 map 擴容之前,這個結構都會一直存在。

從圖中我們可以看到,每個 bucket 由三部分組成,從上到下分别是 tophash 區域、key 存儲區域和 value 存儲區域。

tophash 區域:

當我們向 map 插入一條資料,或者是從 map 按 key 查詢資料的時候,運作時都會使用哈希函數對 key 做哈希運算,并獲得一個哈希值(hashcode)。這個 hashcode 非常關鍵,運作時會把 hashcode“一分為二”來看待,其中低位區的值用于標明 bucket,高位區的值用于在某個 bucket 中确定 key 的位置。如下圖:

Go語言複合類型map的基本操作

每個 bucket 的 tophash 區域其實是用來快速定位 key 位置的,這樣就避免了逐個 key 進行比較這種代價較大的操作。

key 存儲區域:

tophash 區域下面是一塊連續的記憶體區域,存儲的是這個 bucket 承載的所有 key 資料。運作時在配置設定 bucket 的時候需要知道 key 的 Size。那麼運作時是如何知道 key 的 size 的呢?當我們聲明一個 map 類型變量,比如 var m map[string]int 時,Go 運作時就會為這個變量對應的特定 map 類型,生成一個 runtime.maptype 執行個體。如果這個執行個體已經存在,就會直接複用。maptype 執行個體的結構是這樣的:

type maptype struct {
    typ        _type
    key        *_type
    elem       *_type
    bucket     *_type // internal type representing a hash bucket
    keysize    uint8  // size of key slot
    elemsize   uint8  // size of elem slot
    bucketsize uint16 // size of bucket
    flags      uint32
}            

這個執行個體包含了我們需要的 map 類型中的所有"元資訊"。我們前面提到過,編譯器會把文法層面的 map 操作重寫成運作時對應的函數調用,這些運作時函數都有一個共同的特點,那就是第一個參數都是 maptype 指針類型的參數。Go 運作時就是利用 maptype 參數中的資訊确定 key 的類型和大小的。map 所用的 hash 函數也存放在 maptype.key.alg.hash(key, hmap.hash0) 中。同時 maptype 的存在也讓 Go 中所有 map 類型都共享一套運作時 map 操作函數,而不是像 C++ 那樣為每種 map 類型建立一套 map 操作函數,這樣就節省了對最終二進制檔案空間的占用。

value 存儲區域:

這個區域存儲的是 key 對應的 value。和 key 一樣,這個區域的建立也是得到了 maptype 中資訊的幫助。Go 運作時采用了把 key 和 value 分開存儲的方式,而不是采用一個 kv 接着一個 kv 的 kv 緊鄰方式存儲,這帶來的其實是算法上的複雜性,但卻減少了因記憶體對齊帶來的記憶體浪費。

我們以 map[int8]int64 為例,看看下面的存儲空間使用率對比圖:

Go語言複合類型map的基本操作

目前 Go 運作時使用的方案記憶體利用效率很高,而 kv 緊鄰存儲的方案在 map[int8]int64 這樣的例子中記憶體浪費十分嚴重,它的記憶體使用率是 72/128=56.25%,有近一半的空間都浪費掉了。

如果 key 或 value 的資料長度大于一定數值,那麼運作時不會在 bucket 中直接存儲資料,而是會存儲 key 或 value 資料的指針。目前 Go 運作時定義的最大 key 和 value 的長度是這樣的:

// $GOROOT/src/runtime/map.go
const (
    maxKeySize  = 128
    maxElemSize = 128
)           

map 擴容

map 會對底層使用的記憶體進行自動管理。是以,在使用過程中,當插入元素個數超出一定數值後,map 一定會存在自動擴容的問題,也就是怎麼擴充 bucket 的數量,并重新在 bucket 間均衡配置設定資料的問題。

Go 運作時的 map 實作中引入了一個 LoadFactor(負載因子),當 count > LoadFactor * 2^B 或 overflow bucket 過多時,運作時會自動對 map 進行擴容。

這兩方面原因導緻的擴容,在運作時的操作其實是不一樣的。如果是因為 overflow bucket 過多導緻的“擴容”,實際上運作時會建立一個和現有規模一樣的 bucket 數組,然後在 assign 和 delete 時做排空和遷移。

map 與并發

從上面的實作原理來看,充當 map 描述符角色的 hmap 執行個體自身是有狀态的(hmap.flags),而且對狀态的讀寫是沒有并發保護的。是以說 map 執行個體不是并發寫安全的,也不支援并發讀寫。如果我們對 map 執行個體進行并發讀寫,程式運作時就會抛出異常。你可以看看下面這個并發讀寫 map 的例子:

package main

import (
    "fmt"
    "time"
)

func doIteration(m map[int]int) {
    for k, v := range m {
        _ = fmt.Sprintf("[%d, %d] ", k, v)
    }
}

func doWrite(m map[int]int) {
    for k, v := range m {
        m[k] = v + 1
    }
}

func main() {
    m := map[int]int{
        1: 11,
        2: 12,
        3: 13,
    }

    go func() {
        for i := 0; i < 1000; i++ {
            doIteration(m)
        }
    }()

    go func() {
        for i := 0; i < 1000; i++ {
            doWrite(m)
        }
    }()

    time.Sleep(5 * time.Second)
}           

我們僅僅是進行并發讀,map 是沒有問題的。而且,Go 1.9 版本中引入了支援并發寫安全的 sync.Map 類型,可以在并發讀寫的場景下替換掉 map。

考慮到 map 可以自動擴容,map 中資料元素的 value 位置可能在這一過程中發生變化,是以 Go 不允許擷取 map 中 value 的位址,這個限制是在編譯期間就生效的。