這篇文章基於我在日本東京 [GoCon Spring 2018](https://gocon.connpass.com/event/82515/) 上的演講討論了,Go 語言中的 map 是如何?的。## 什麼是映射函數要明白 map 是如何工作的的,我們需要先討論一下 *map 函數*。一個 map 函數用以將一個值對應到另一個值。給定一個值,我們叫 *key*,它就會返回另外一個值,稱為 *value*。```map(key) → value```現在,map 還沒什麼用,除非我們放入一些資料。我們需要一個函數來將資料添加到 map 中```insert(map, key, value)```和一個函數從 map 中移除資料```delete(map, key)```在實現上還有一些有趣的點比如查詢某個 key 當前在 map 中是否存在,但這已經超出了我們今天要討論的範圍。相反我們今天只專註於這幾個點;插入,刪除和如何將 key 映射到 value。## Go 中的 map 是一個 hashmapHashmap 是我要討論的的 map 的一種特定實現,因為這也是 Go runtime 中所採用的實現方式。Hashmap 是一種經典的資料結構,提供了平均 O(1) 的查詢時間複雜度,即使在最糟的情況下也有 O(n) 的複雜度。也就是說,正常情況下,執行 map 函數的時間是個常量。這個常量的大小部分取決於 hashmap 的設計方式,而 map 存取時間從 O(1) 到 O(n) 的變化則取決於它的 *hash 函數*。### hash 函數什麼是 *hash 函數* ?一個 hash 函數用以接收一個未知長度的 key 然後返回一個固定長度的 value。```hash(key) → integer```這個 *hash value* 大多數情況下都是一個整數,原因我們後邊會說到。Hash 函數和映射函數是相似的。它們都接收一個 key 然後返回一個 value。然而 hash 函數的不同之處在於,它返回的 value 來源於 key,而不是關聯於 key。### hash 函數的重要特點很有必要討論一下一個好的 hash 函數的特點,因為 hash 函數的品質決定了其 map 函數運行複雜度是否接近於 O(1)。Hashmap 的使用方面有兩個重要的特點。第一個是*穩定性*。Hash 函數必須是穩定的。給定相同的 key,你的 hash 函數必須返回相同的值。否則你無法尋找到你放入 map 中的資料。第二個特點是*良好的分布*。給定兩個相類似的 key,結果應該是極其不同的。這很重要,因為有兩點原因。第一,跟我們稍後會看到的一樣,hashmap 中的 value 值應當均勻地分佈於 buckets 之間,否則存取的複雜度不會是 O(1)。第二,由於使用者一定程度上可以控制 hash 函數的輸入,它們也就能控制 hash 函數的輸出。這就會導致糟糕的分布,在某些語言中是 DDoS 攻擊的一種方式。這項特點也被叫做 *碰撞抵抗性(collision resistance)*。### hashmap 的資料結構關於 hashmap 的第二部分來說說資料是如何儲存的。經典的 hashmap 結構是一個 bucket 數組,其中的每項包含一個指標指向一個 key/value entry 數組。在當前例子中我們的 hashmap 中有 8 個 bucket(Go 語言即如此實現),並且每個 bucket 最多持有 8 個 key/value entry(同樣也是 Go 語言的實現)。使用 2 的次方便於做位元遮罩和移位,而不必做昂貴的除法操作。因為 entry 被添加到 map 中,假定有一個良好分布的 hash 函數,那麼 buckets 大致會被均勻地填充。一旦 bucket 中的 entry 數量超過總數的某個百分比,也就是所說的 *負載因子(load factor)*,那麼 map 就會翻倍 bucket 的數量並重新分配原先的 entry。記住這個資料結構。假設我們現在有一個 map 用以儲存項目名和對應的 Github star 數目,那麼我們要如何往 map 中插入一個 value 呢?我們從 key 開始,把它傳入 hash 函數,然後做掩碼操作只取最低的幾位來擷取到 bucket 數組正確位置的位移量。這也是要放入的 entry 所在的 bucket,它的 hash 值以 3(二進位 011) 結束。最終我們遍曆這個 bucket 的 entry 列表直到我們找到一個空的位置,然後插入我們的 key 和 value。如果 key 已經存在了,我們就覆蓋 value。現在,我們仍然用這個來從 map 中尋找 value。過程很相似。我們先將 key 做 hash 操作。因為我們的 bucket 數組包含 8 個元素,所以我們取最低 3 位,也就是第 5 號 bucket (二進位 101)。如果我們的 hash 函數是正確的,那麼字串 "moby/moby" 做 hash 操作之後得到的值永遠是相同的。所以我們知道 key 不會存在於其他 bucket 中。現在我們再從 bucket 的 entry 列表中通過比較 key 做一次線性尋找就能得到結果了。### hashmap 的四個要點這是個經典 hashmap 結構的比較高層的解釋。我們已經看到了,要實現一個 hashmap 有四個要點;1. 你需要一個給 key 做計算的 hash 函數。2. 你需要一個判斷 key 相等的演算法。3. 你需要知道 key 的大小。4. 你需要知道 value 的大小,因為這同樣影響了 bucket 結構的大小。編譯器需要知道 bucket 結構的大小,這決定了當你遍曆或者新增資料時記憶體中的步進值。## 其他語言中的 hashmap在討論 Go 語言對於 hashmap 的實現之前,我想先簡單介紹一下其他兩個程式設計語言中是如何? hashmap 的。我選擇了這兩門語言,因為它們都提供了獨立的 map 類型來適應各種不同的 key 和 value 類型。### C++我們要討論的第一個語言是 C++。C++ 標準模版庫(STL)提供了 `std::unordered_map` 通常作為 hashmap 的實現來使用。這是 `std::unordered_map` 的的定義。這是一個模版,所以參數實際的值取決於模版是如何初始化的。```c++template< class Key, // the type of the key class T, // the type of the value class Hash = std::hash<Key>, // the hash function class KeyEqual = std::equal_to<Key>, // the key equality function class Allocator = std::allocator< std::pair<const Key, T> >> class unordered_map;```可以講的有很多,但比較重要的有以下幾點:* 模版接收了 key 和 value 的類型作為參數,所以知道它們的大小。* 模版有一個 key 類型的 `std::hash` 函數,所以它知道如何 hash 傳給它的 key 值。* 模版還有一個 key 類型的 `std::equal_to` 函數,所以知道怎麼比較兩個 key 值。現在我們知道了在 C++ 的 `std::unordered_map` 中 hashmap 的四個要點是如何傳達給編譯器的了,所以我們來看一下它是如何實際工作的。首先我們將 key 傳給 `std::hash` 函數以得到 key 的 hash 值。然後做掩碼並取到 bucket 數組中的序號,接著再遍曆對應 bucket 的 entry 列表並用 `std::equal_to` 函數來比較 key。### Java我們要討論的第二個語言是 Java。不出所料,在 Java 中 hashmap 類型就叫做 `java.util.Hashmap`。在 Java 中,`java.util.Hashmap` 只能操作對象,因為在 Java 中幾乎所有的東西都是 `java.lang.Object` 的子類。由於在 Java 中所有對象都起源於 `java.lang.Object`,所以可以繼承或者重寫 `hashCode` 和 `equals` 方法。然而你不能直接儲存 8 個基本類型;`boolean`,`int`,`short`,`long`,`byte`,`char`,`float` 和 `double`,因為它們不是 `java.lang.Object` 的子類。你既不能將它們作為 key,也不能將它們作為 value 來儲存。為了突破這種限制,它們會被隱式地轉換為代表它們各自的對象。也叫做裝箱。先把這種限制放一邊,讓我們來看一下在 Java 的 hashmap 中尋找是怎樣的。首先我們調用 key 的 `hashCode` 方法來擷取它的 hash 值。然後做掩碼操作,擷取到 bucket 數組中的對應位置,裡面存放了一個指向 `Entry` 的指標。`Entry` 中有一個 key,一個 value,還有一個指向下一個 `Entry` 的指標,形成了一個 linked list。## 權衡現在我們知道 C++ 和 Java 是如何? hashmap 的了,讓我們來比較一下它們各自的優缺點。### C++ templatedstd::unordered_map#### 優點* key 和 value 類型的大小在編譯期間就確定了。* 資料結構的大小總是確定的,不需要裝箱操作。* 由於代碼在編譯期間就定下來了,所以其他編譯最佳化操作例如內聯,常數摺疊和無作用程式碼刪除就可以介入了。總之,C++ 中的 map 和自己手寫的為每種 key/value 類型組合定製的 map 一樣快速高效,因為它其實就是這樣的。#### 缺點* 代碼膨脹。每個不同的 map 都是不同類型的。如果你的代碼中有 N 個 map 類型,在你的程式碼程式庫中你也就需要有 N 份 map 代碼的拷貝。* 編譯時間膨脹。由於標頭檔和模版的工作方式,每個使用了 `std::unordered_map` 代碼的檔案中其實現都需要被產生,編譯和最佳化。### Java util Hashmap#### 優點* 一份 map 代碼的實現可以服務於任何 java.util.Object 的子類。只需要編譯一份 java.util.Object,在每個 class 檔案中就都可以引用了。#### 缺點* 所有東西必須是對象,即使它不是。這意味著基本類型的 map 必須用通過裝箱操作轉化為對象。裝箱操作會增加記憶體回收的壓力,並且額外的指標引用會增加緩衝壓力(每個對象都必須通過另外的指標來尋找)。* Buckets 是以 linked lists 而不是順序數組的方式儲存的。這會導致在對象比較期間產生大量的指標追蹤操作。* Hash 和 equals 函數需要代碼編寫者來實現。不正確的 hash 和 equals 函數會減慢 map 的運行速度,甚至導致 map 的行為錯誤。## Go 中 hashmap 的實現現在,我們來討論一下 Go 中 map 的實現。它保留了許多我們剛才討論的實現中的優點,卻沒有那些缺點。和 C++ 和 Java 一樣, Go 中的 hashmap 是使用 Go 語言編寫的。但是 Go 不支援範型,所以我們要如何來編寫一個 hashmap 能夠服務於(幾乎)任何類型呢?### Go runtime 使用了 interface{} 嗎?不,Go runtime 並沒有使用 interface{} 來實現 hashmap。雖然像 `container/{list,heap}` 這些包中使用了 interface{},但 runtime 的 map 卻沒有使用。### 編譯器是否使用了代碼產生?不,在 Go 語言可執行檔中只有一份 map 的實現。和 Java 不同,它並沒有對 `interface{}` 做裝箱操作。所以它是怎麼工作的呢?這要分成兩部分來回答。它需要編譯器和 runtime (運行時)之間的相互協作。### 編譯時間重寫第一部分我們需要先理解 runtime 包中對於 map 的實現是如何做尋找,插入和刪除操作的。在編譯期間 map 的操作被重寫去調用了 runtime。例如。```v := m["key"] → runtime.mapaccess1(m, "key", &v)v, ok := m["key"] → runtime.mapaccess2(m, "key", &v, &ok)m["key"] = 9001 → runtime.mapinsert(m, "key", 9001)delete(m, "key") → runtime.mapdelete(m, "key")```值得注意的是,channel 中也做了相同的事,slice 卻沒有。這是因為 channel 是複雜的資料類型。發送,接收和 `select` 操作和調度器之間都有複雜的互動,所以就被委託給了 runtime。相比較而言,slice 就簡單很多了。像 slice 的存取,`len` 和 `cap` 這些操作編譯器就自己做了,而像 `copy` 和 `append` 這種複雜的還是委託給了 runtime。### map 代碼解釋現在我們知道編譯器重寫了 map 的操作去調用了 runtime。我們也知道了在 runtime 內部,有一個叫 `mapaccess1` 的函數,一個叫 `mapaccess2` 的函數等等。所以,編譯器是如何重寫```gov := m["key"]```到```goruntime.mapaccess(m, "key", &v)```卻沒有使用 `interface{}` 的呢?要解釋 Go 中的 map 類型是如何工作的最簡單的函數是給你看一下 `runtime.mapaccess1` 的定義。```gofunc mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer```讓我們來過一下這些參數。* `key` 是指向你提供的作為 key 值的指標。* `h` 是個指向 `runtime.hmap` 結構的指標。`hmap` 是一個持有 buckets 和其他一些值的 runtime 的 hashmap 結構。* `t` 是個指向 `maptype` 的指標。為什麼我們已經有了 `*hmap` 之後還需要一個 `*maptype`?`*maptype` 是個特殊的東西,使得通用的 `*hmap` 可以服務於(幾乎)任意 key 和 value 類型的組合。在你的程式中對於每一個獨立的 map 定義都會有一個特定的 `maptype` 值。例如,有一個 `maptype` 值描述了從 `strings` 到 `ints` 的映射,另一個描述了 `strings` 到 `http.Headers` 的映射,等等。C++ 中,對於每一個獨立的 map 定義都有一個完整的實現。而 Go 並非如此,它在編譯期間建立了一個 `maptype` 並在調用 runtime 的 map 函數的時候使用了它。```gotype maptype struct { typ _type key *_type elem *_type bucket *_type // internal type representing a hash bucket hmap *_type // internal type representing a hmap keysize uint8 // size of key slot indirectkey bool // store ptr to key instead of key itself valuesize uint8 // size of value slot indirectvalue bool // store ptr to value instead of value itself bucketsize uint16 // size of bucket reflexivekey bool // true if k==k for all keys needkeyupdate bool // true if we need to update key on overwrite}```每個 `maptype` 中都包含了特定 map 中從 key 映射到 elem 所需的各種屬性細節。它包含了關於 key 和 element 的資訊。`maptype.key` 包含了指向我們傳入的 key 的指標的資訊。我們稱之為 *類型描述元*。```gotype _type struct { size uintptr ptrdata uintptr // size of memory prefix holding all pointers hash uint32 tflag tflag align uint8 fieldalign uint8 kind uint8 alg *typeAlg // gcdata stores the GC type data for the garbage collector. // If the KindGCProg bit is set in kind, gcdata is a GC program. // Otherwise it is a ptrmask bitmap. See mbitmap.go for details. gcdata *byte str nameOff ptrToThis typeOff}```在 `_type` 類型中,包含了它的大小。這很重要,因為我們只有一個指向 key 的指標,而不知道它實際多大並且是什麼類型。它到底是一個整數,還是一個結構體,等等。我們也需要知道如何比較這種類型的值和如何 hash 這種類型的值,這也就是 `_type.alg` 欄位的意義所在。```gotype typeAlg struct { // function for hashing objects of this type // (ptr to object, seed) -> hash hash func(unsafe.Pointer, uintptr) uintptr // function for comparing objects of this type // (ptr to object A, ptr to object B) -> ==? equal func(unsafe.Pointer, unsafe.Pointer) bool}```在你的程式中這就是一個服務於特定類型的 `typeAlg` 值。放在一起來看,這就是(輕微修改,便於理解) `runtime.mapaccess1` 函數。```go// mapaccess1 returns a pointer to h[key]. Never returns nil, instead// it will return a reference to the zero object for the value type if// the key is not in the map.func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer { if h == nil || h.count == 0 { return unsafe.Pointer(&zeroVal[0]) } alg := t.key.alg hash := alg.hash(key, uintptr(h.hash0)) m := bucketMask(h.B) b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))```值得關注的一點是傳遞給 `alg.hash` 函數的 `h.hash0` 參數。`h.hash0` 是一個在 map 建立時產生的隨機種子,為了防止在 Go runtime 中產生 hash 碰撞。任何人都可以閱讀 Go 語言的源碼,所以可以找到一系列值,使得其使用 Go 語言中的 hash Function Compute後,得到的 hash 值會被放入同一個 bucket 中。種子的存在就為 hash 函數增加了很多隨機性,為碰撞攻擊提供了一些保護措施。## 結論我很高興能在 GoCon 大會上做這個演講。因為 Go 中的 map 實現是一個介於 C++ 和 Java 之間的權衡,汲取了很多優點同時又沒有包含很多缺點。和 Java 不同,你可以直接使用基本類型資料,例如字元和整數,而不需要進行裝箱操作。和 C++ 不同,在最後的二進位檔案中,沒有 N 份 `runtime.hashmap` 的實現,只有 N 份 `runtime.maptype` 的值,顯著減少了程式的體積和編譯時間。現在我想說明的是我不是在試圖告訴你 Go 不應該支援範型。我今天的目的是闡述當前 Go 1 的現狀和在當前情形下 map 類型的工作方式。現今 Go 語言下 map 的實現是非常高效的,提供了很多模版類型的優點,而沒有代碼產生和編譯時間膨脹的缺點。我視之為一次值得學習讚賞的設計案例。1. 你可以在這裡找到更多關於 runtime.hmap 結構的內容。[https://dave.cheney.net/2017/04/30/if-a-map-isnt-a-reference-variable-what-is-it](https://dave.cheney.net/2017/04/30/if-a-map-isnt-a-reference-variable-what-is-it)
via: https://dave.cheney.net/2018/05/29/how-the-go-runtime-implements-maps-efficiently-without-generics
作者:Dave Cheney 譯者:alfred-zhong 校對:polaris1119
本文由 GCTT 原創編譯,Go語言中文網 榮譽推出
本文由 GCTT 原創翻譯,Go語言中文網 首發。也想加入譯者行列,為開源做一些自己的貢獻嗎?歡迎加入 GCTT!
翻譯工作和譯文發表僅用於學習和交流目的,翻譯工作遵照 CC-BY-NC-SA 協議規定,如果我們的工作有侵犯到您的權益,請及時聯絡我們。
歡迎遵照 CC-BY-NC-SA 協議規定 轉載,敬請在本文中標註並保留原文/譯文連結和作者/譯者等資訊。
文章僅代表作者的知識和看法,如有不同觀點,請樓下排隊吐槽
2081 次點擊