插入新鍵值對
面對一個非 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 運作時層實作的示意圖:
和切片的運作時表示圖相比,map 的實作示意圖顯然要複雜得多,我們重點關注一個 map 變量在初始狀态、進行鍵值對操作後,以及在并發場景下的 Go 運作時層的實作原理。
1、初始狀态
從圖中我們可以看到,與文法層面 map 類型變量(m)一一對應的是 *runtime.hmap 的執行個體,即 runtime.hmap 類型的指針,也就是我們前面在講解 map 類型變量傳遞開銷時提到的 map 類型的描述符。hmap 類型是 map 類型的頭部結構(header),它存儲了後續 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 的位置。如下圖:
每個 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 運作時使用的方案記憶體利用效率很高,而 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 的位址,這個限制是在編譯期間就生效的。