【數據結構】Map (映射)的各種實現

In computer science, an associative array, map, symbol table, or dictionary is an abstract data type composed of a collection of (key, value) pairs, such that each possible key appears at most once in the collection. —— wikipedia
在計算機科學中,關聯數組、映射、符號表或者字典是一種由一系列(鍵、值)對組成的集合,且集合中的每個鍵最多出現一次。 —— 維基百科

Map 作爲一種高效的數據存取數據結構,經常會被用到。在不同的場合,其具體實現上會有一定的差異,但總體上是相似的。其思想甚至可以擴展到很多地方。

Map 的分類

Map 實現方式總體上分爲兩類:

  • 哈希表(Hash Table)
  • 紅黑樹(Red-Black Tree)

大多數的實現都會選擇 Hash Table,因爲它的平均時間複雜度爲 O(1),而紅黑樹是 O(log N)。

Hash Table 之所以快,是因爲它通過哈希函數和掩碼將 key 轉化爲一個範圍內的整數,通過將這個整數作爲在數組中的索引,獲取存儲在數組中的碰撞鏈。這個查找過程是通過直接計算數組索引的地址偏移量來得到碰撞鏈的地址,速度極快。

如果哈希函數不是特別差,碰撞鏈平均元素個數在 8 個左右。最壞的情況下是所有 key 在轉化後得到相同的整數,此時只有一條碰撞鏈,平均時間複雜度爲 O(N)。

如果想要知道 Map 具體的不同實現方式,可以看 JAVA 中 java.util.Map 接口的實現。這裏列出其中幾種:

類型 數據結構 遍歷 線程安全
HashMap Hash Table 隨機
LinkedHashMap Hash Table 按添加順序
ConcurrentHashMap Hash Table 隨機
Hashtable Hash Table 隨機
ConcurrentSkipListMap Skip Table 按鍵升序排序的順序
TreeMap Red Black Tree 按鍵升序排序的順序

更多實現可看:
https://docs.oracle.com/javase/8/docs/api/java/util/Map.html

這裏會看到 ConcurrentHashMap 和 Hashtable 似乎是一樣的。它們兩者的主要區別在於, Hashtable 爲了線程安全,所有操作都會鎖住整個 Hash Table;而 ConcurrentHashMap 則僅對沖突鏈(key 有相同的 Hash 值)加鎖,即分段鎖。

各種場景下的實現

其他一些場景下,沒有像 JAVA 一樣提供多種實現。因此這裏將這些實現對應到 JAVA 上來,儘管具體實現細節上會有差異。

場景 類似的 JAVA 實現
PHP5 的數組 LinkedHashMap
PHP7 的數組 LinkedHashMap
Redis 的字典 HashMap
Go 的 map HashMap
Go 的 sync.map Hashtable

這裏特別備註一下, Go 的 sync.map 的實現是在 map 的基礎上加了一層線程安全機制,底層用的還是 map。因此以下說明其他 Map 特性的時候,不再列出 sync.map。
PHP 在 Thread Safe 版本中提供了 Hash Table 的線程安全版本,在讀寫操作前對整個 Hash Table 加鎖。

這些實現都採用 Hash Table,因此以下僅介紹 Hash Table 的幾個重點和不同場景在實現上的區別。

先直觀地看看它們結構上的區別吧!

JAVA 7:

每個 key-value 對都單獨存儲一個節點,並鏈接到下一個節點。

JAVA 8:

JAVA 7 的升級,當 table 的長度大於等於 64 且鏈表的元素個數大於等於 8 時,將鏈表轉換成紅黑樹。

PHP 5:

和 JAVA 7 的基本思路一樣,都是每個 key-value 對都單獨存儲一個節點,並鏈接到下一個節點。但是由於要保證數據按加入時的順序遍歷,因此會多出一套指針用於記錄元素加入的前後順序。

PHP 7:

和 PHP 5 不一樣,除了 hash 數組外,還增加了數據數組,其全局順序按照加入順序放到數據數組中,這樣 Bucket 就可以刪除用於標識全局順序的指針了。

Redis 6:

每個 key-value 對都單獨存儲一個節點,並鏈接到下一個節點。

Go:

與前面幾種都不一樣。Go 把多組 key-value 對放在一個 bmap 裏面,一個 bmap 可容納 8 個對。如果不夠,則再申請一個 bmap 鏈接到原來的 bmap 上。

Hash Table 的四個問題

  • 如何計算哈希值
  • 如何處理哈希碰撞
  • 什麼時候擴容
  • 如何擴容

哈希函數用於獲取 key 的哈希值。最終哈希值會與掩碼執行按位與操作,僅根據數組的長度保留最後幾位。哈希算法的好壞影響了衝突的概率,從而影響 Hash Table 的效率。最壞的情況下會使得 Hash Table 退化爲鏈表,從 O(1) 降到 O(N)。

哈希碰撞是指兩個不同 key 得到後幾位相同的哈希值。碰撞的解決方式就是組織這些 <key, value> 的方式。

擴容指元素達到一定數量時,申請的內存即將不夠用(例如 PHP7 爲鍵值對申請的固定長度數組),或者哈希衝突越來越多導致衝突鏈變長,此時應該執行擴容以獲取更多內存或者減小衝突。

Rehash 是指對所有鍵值對的 key 重新做一次 Hash 操作,然後和新容量對應的掩碼做按位與操作,得到新的衝突鏈索引。這樣在擴容或者其他情況的時候,可以重新組織元素所在的位置。

問題一:如何計算哈希值

以下是這部份的參考:
幾種常見的hash函數
https://www.jianshu.com/p/bb64cd7593ab
常見的哈希算法和用途
https://blog.cyeam.com/hash/2018/05/28/hash-method
漫談非加密哈希算法
https://segmentfault.com/a/1190000010990136

hash 函數用於獲取 key 的哈希值,即 hash = hash_function(key)

hash 函數有兩種分類:

  • 加密型(md5,sha1,sha256,aes256 ...)
  • 非加密型(通常用於查找)

Map 實現所用到的 hash 函數通常是非加密型的,它的速度比加密型快,但也更容易產生 hash 碰撞。

hash 碰撞指的是不同的 key 在經過 hash 函數處理後得到二進制後幾位相同的 hash 值。

hash 函數涉及到安全問題,主要是避免被攻擊者知道 hash 規則,藉以生成大量碰撞的 key 執行攻擊。

Map 的 key 的類型在有些實現中有限制,另外一些沒有限制。這裏只取 key 爲字符串類型的情況來介紹。

這個知識點基本上屬於滿足好奇心。其他情況例如面試的時候,面試官不會問。畢竟像最常用的 times33 算法中的 5381 和 33 兩個參數都是玄學數字,是基於實驗比較出來的,沒什麼好說。

Redis 的哈希函數

Redis 使用的是 SipHash 實現,這個實現的特點是解決 Hash-Flooding Attack 這個安全問題。

什麼是哈希洪水攻擊(Hash-Flooding Attack)? - Gh0u1L5的回答 - 知乎
https://www.zhihu.com/question/286529973/answer/676290355

src/dict.c

/* The default hashing function uses SipHash implementation
 * in siphash.c. */
// ...
uint64_t dictGenHashFunction(const void *key, int len) {
    return siphash(key,len,dict_hash_function_seed);
}

SipHash 的具體實現見 src/siphash.c

除了 SipHash 外,還有一種 Bernstein's Hash(別名 DJB,DJBX33A,times33)。

下面這個是 Redis 的 C 語言客戶端中的:

deps/hiredis/dict.c

/* Generic hash function (a popular one from Bernstein).
 * I tested a few and this was the best. */
static unsigned int dictGenHashFunction(const unsigned char *buf, int len) {
    unsigned int hash = 5381;

    while (len--)
        hash = ((hash << 5) + hash) + (*buf++); /* hash * 33 + c */
    return hash;
}

這裏把 hash * 33 的操作改爲位操作 (hash << 5) + hash,意思是 x*2^5+ x*1 = x*(2^5 + 1),目的是爲了加速。

JAVA 的哈希函數

JAVA 的實現是基於 times33 的改變,它把 33 改成了 31,並且緩存字符串的 hash 值。

java.lang.String

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    // ...
    /** Cache the hash code for the string */
    private int hash; // Default to 0
    // ...
    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }
    // ...
}

如果只是單純地使用這個 hash 函數,那麼會遭受到 Hash-Flooding Attack。從 JAVA 8 開始,對哈希碰撞採取了將過長的哈希鏈轉換爲紅黑樹來解決這個問題。後面會詳細說明。

PHP 5 的哈希函數

PHP 5 也是基於 times33 的改變,它主要是減少了循環的次數。存在 Hash-Flooding Attack 安全問題。

src/Zend/zend_string.h

static inline ulong zend_inline_hash_func(const char *arKey, uint nKeyLength)
{
    register ulong hash = 5381;

    /* variant with the hash unrolled eight times */
    for (; nKeyLength >= 8; nKeyLength -= 8) {
        hash = ((hash << 5) + hash) + *arKey++;
        hash = ((hash << 5) + hash) + *arKey++;
        hash = ((hash << 5) + hash) + *arKey++;
        hash = ((hash << 5) + hash) + *arKey++;
        hash = ((hash << 5) + hash) + *arKey++;
        hash = ((hash << 5) + hash) + *arKey++;
        hash = ((hash << 5) + hash) + *arKey++;
        hash = ((hash << 5) + hash) + *arKey++;
    }
    switch (nKeyLength) {
        case 7: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
        case 6: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
        case 5: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
        case 4: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
        case 3: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
        case 2: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
        case 1: hash = ((hash << 5) + hash) + *arKey++; break;
        case 0: break;
EMPTY_SWITCH_DEFAULT_CASE()
    }
    return hash;
}

PHP 7 的哈希函數

PHP 7 的實現比 PHP 5 複雜一些,它針對了不同操作系統以及處理其做了優化,但本質上還是使用 times33。存在 Hash-Flooding Attack 安全問題。

src/Zend/string.h

static zend_always_inline zend_ulong zend_inline_hash_func(const char *str, size_t len)
{
    zend_ulong hash = Z_UL(5381);

#if defined(_WIN32) || defined(__i386__) || defined(__x86_64__) || defined(__aarch64__)
    /* Version with multiplication works better on modern CPU */
    for (; len >= 8; len -= 8, str += 8) {
# if defined(__aarch64__) && !defined(WORDS_BIGENDIAN)
        // ...
# else
        // ...
# endif
    }
        // ...
#else
        // ... 這裏和 PHP 5 一樣
#endif
}

Go 的哈希函數

Go 在 1.17 版本之前使用的是基於 xxhash 和 cityhash 改進的算法。爲了解決 Hash-Flooding Attack 這個安全問題,初始化 map 時隨機 hash seed,使得不同 map 對相同 key 的 hash 結果不一致。

隨機數:

// A header for a Go map.
type hmap struct {
    // ... 省略
    hash0     uint32 // hash seed
    // ... 省略
}

hash 算法位置:

src/runtime/hash64.go

// Hashing algorithm inspired by
//   xxhash: https://code.google.com/p/xxhash/
// cityhash: https://code.google.com/p/cityhash/

具體實現太長就不貼出來了。

go 1.17 把換成了 wyhash

// Hashing algorithm inspired by
// wyhash: https://github.com/wangyi-fudan/wyhash

問題二:如何處理哈希碰撞

hash 碰撞的處理方式有幾種:

  • 鏈地址法
  • 開放地址法
    • 線性探測法
    • 二次探測法(平方探測法)
    • 雙散列法
    • 僞隨機探測法
  • 再哈希法(Rehashing)
  • 公共溢出區法

下表展示了各種 Map 實現使用的碰撞處理方式:

場景 碰撞處理方式
JAVA 的 HashMap 鏈地址法
PHP5 的數組 鏈地址法
PHP7 的數組 鏈地址法
Go 的 map 鏈地址法
Redis 的 hash 鏈地址法

儘管都是使用鏈地址法,但鏈地址法的具體實現卻不相同。

JAVA HashMap 的衝突鏈

鏈表存儲在 table 數組中。

每當添加一個元素,就 new 一個 Node,把 key 和 value 存進去。接着鏈接到衝突鏈上。

對於 Node 插入到衝突鏈的位置,JAVA 8 之前和 JAVA 8 開始,有兩種不同的方式。

JAVA 8 之前,將 Node 插入到鏈表頭部(頭插法)。

JAVA 8 開始,將 Node 插入到鏈表尾部(尾插法)。

使用尾插法的原因是頭插法在擴容的時候有可能會出現環。因爲頭插法會在擴容時使得鏈表前後兩個節點的位置對調。如果衝突鏈前後兩個節點在擴容後仍然位於同一條衝突鏈,就有可能出現這種情況。

上面說到 JAVA 8 的 HashMap 解決 Hash-Flooding Attack 的安全問題,是在鏈表同時滿足兩個條件的情況下轉化爲紅黑樹。

  1. table 的長度大於等於 64
    如果不滿足該條件,則 table 雙倍擴容。
  2. 鏈表的元素個數大於等於 8

在紅黑樹的情況下,如果元素個數小於等於 6,則將紅黑樹還原爲鏈表。使用 6 而不是 7 是爲了避免頻繁地在紅黑樹和鏈表之間轉換。

JAVA LinkedHashMap 的衝突鏈

LinkedHashMap 繼承了 HashMap。並且添加了 head 和 tail 用於存儲雙向鏈表。

每當添加一個元素,除了 HashMap 的處理,還會額外地將該元素放到 tail 後面。遍歷的時候按添加順序遍歷。

PHP 5 數組的衝突鏈

鏈表存儲在 arBuckets 中。每個 Bucket 存儲一個元素。

每當添加一個元素,先申請一個 Bucket 內存,然後再把 key 和 value 拷貝進去,最後把 Bucket 鏈接到鏈表的頭部(頭插法)。

所有元素的內存不是一整塊連續的內存。

Bucket 自身維護了全局添加順序的上下元素的指針。

PHP 7 數組的衝突鏈

鏈表存儲在 arData 中。每個 Bucket 存儲一個元素。

內存會預先申請好連續的 nTableSize 個 Bucket 的空間(數組)。

每當添加一個元素,會使用 nNextFreeElement 指向的 Bucket,把 key 和 value 拷貝進去,最後把 Bucket 鏈接到鏈表的頭部(頭插法)。圖中 Bucket 是包含了 zval,應看做一個整體而不是衝突鏈的兩個元素。

用於存儲元素值的 zval 維護了衝突鏈下一個 Bucket 的指針。

由於申請連續的空間用於按順序存儲 Bucket,無需爲了存儲元素添加順序而加入指針。遍歷時直接遍歷數組即可。

Redis Hash 的衝突鏈

鏈表存儲在 table 中。每個 dictEntry 存儲一個元素。

每當添加一個元素,會先申請一個 dictEntry 內存,然後把 dictEntry 鏈接到鏈表的頭部(頭插法),最後把 key 和 value 存儲進去。

另一個 dictht 在擴容的時候使用。

Go map 的衝突鏈

鏈表存儲在 buckets 中。初始化時,會申請 buckets 數量的 bmap。每個 bmap 由 8 個 bucket 組成。

注意 8 這個數字,JAVA 裏面轉紅黑樹也是用 8 作爲分界點。

每當添加一個元素,會在 bmap 裏面按順序找到一個空位置,把 key 和 value 複製進去。

需要注意的是,key 和 value 是分內存區塊存儲的。所有 key 在內存上按順序緊挨着,而不是每個鍵值對的 value 都在其 key 的內存位置之後。

如果 bmap 存滿了,則申請新的 bmap,並鏈接到已有 bmap 的 overflow 上面。

問題二擴展:根據 hash 值找目標元素的過程

獲取 key 的 hash 只是第一步,還需要其他步驟才能找到目標元素。

大致涉及三個步驟:

  1. 最終 hash 值的獲取
  2. 對 hash 值做運算獲取碰撞鏈首個 Bucket 在數組中的索引
  3. 遍歷碰撞鏈,通過比較找到元素

JAVA

JAVA 在獲取到 hash 值之後,會使用擾動函數對 hash 值再做一次轉化。JAVA 7 和 JAVA 8 的擾動函數不一致。JAVA 7 執行了 5 次異或操作, JAVA 8 僅做了一次。

JAVA 7:

java.util.HashMap

final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }

    h ^= k.hashCode();

    // This function ensures that hashCodes that differ only by 
    // constant multiples at each bit position have a bounded 
    // number of collisions (approximately 8 at default load factor). 
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

JAVA 7 的就不詳細解釋了。

JAVA 8:

java.util.HashMap

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    // ...
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    // ...
}

將 hash 值按位右移 16 位,再跟 hash 值做異或操作。這使得 hash 值在保持高 16 位不變的同時,讓低 16 位帶上了高 16 位的信息。在字符串隨機且 table 數組爲 512 個元素的情況下,能減少 10% 的碰撞。

JAVA 的 int 類型佔 32 位

經過擾動函數的處理後,最終的 hash 值會用 (table.length - 1) 作爲掩碼取 hash 值的低位。這個低位的值就是碰撞鏈的位置。

length = 8
                       hash: 1111 1111 1111 1111 1111 0000 1110 1010
                hash >>> 16: 0000 0000 0000 0000 1111 1111 1111 1111
  hash = hash^(hash >>> 16): 1111 1111 1111 1111 0000 1111 0001 0101

                 length - 1: 0000 0000 0000 0000 0000 0000 0000 0111
index = hash & (length - 1): 0000 0000 0000 0000 0000 0000 0000 0101

最終得到的 index = 5,於是從 table[index] 中取出碰撞鏈的頭節點。

接着遍歷該鏈表做比較。比較的時候要依次滿足兩個條件:

  • 經過擾動函數處理後的 hash 值相同
  • 字符串 key 比較結果相等

PHP 5 的數組

由於 PHP 5 和 PHP 7 的數組內存結構不一樣,因此要分開說。

src/Zend/zend_hash.h

typedef struct bucket {
    ulong h;                        /* Used for numeric indexing */
    uint nKeyLength;
    void *pData;
    void *pDataPtr;
    struct bucket *pListNext;
    struct bucket *pListLast;
    struct bucket *pNext;
    struct bucket *pLast;
    const char *arKey;
} Bucket;

typedef struct _hashtable {
    uint nTableSize;
    uint nTableMask;
    uint nNumOfElements;
    ulong nNextFreeElement;
    Bucket *pInternalPointer;    /* Used for element traversal */
    Bucket *pListHead;
    Bucket *pListTail;
    Bucket **arBuckets;          /* <--- */
    dtor_func_t pDestructor;
    zend_bool persistent;
    unsigned char nApplyCount;
    zend_bool bApplyProtection;
#if ZEND_DEBUG
    int inconsistent;
#endif
} HashTable;

PHP 5 的 arBuckets 是一個數組,它存儲碰撞鏈的首元素。

獲取 hash 值之後,不會像 JAVA 那樣經過擾動函數的處理。

用掩碼 nTableMask = (nTableSize - 1) 和 hash 值做按位與運算,取得 hash 值的低位作爲索引 index。

arBuckets[index] 獲取碰撞鏈的首元素。接着遍歷該鏈表比較。比較的時候分爲兩種類型:

  • 如果字符串 key 是同一個指針,則表示相同
  • 如果不是同一個指針,則比較 hash 值,再比較 key 的長度,最後比較字符串是否完全一致

PHP 7 的數組

typedef struct _zval_struct     zval;
struct _zval_struct {
    zend_value        value;
    // ... 省略
    union {
        uint32_t     next;              /* 指向 hash 衝突鏈的下一個元素 */
        // ... 省略
    } u2;
};

typedef struct _Bucket {
    zval              val;
    zend_ulong        h;                /* hash value (or numeric index)   */
    zend_string      *key;              /* string key or NULL for numerics */
} Bucket;

typedef struct _zend_array HashTable;

struct _zend_array {
    zend_refcounted_h gc;
    union {
        struct {
            ZEND_ENDIAN_LOHI_4(
                zend_uchar    flags,
                zend_uchar    _unused,
                zend_uchar    nIteratorsCount,
                zend_uchar    _unused2)
        } v;
        uint32_t flags;
    } u;
    uint32_t          nTableMask;
    Bucket           *arData;            /* <--- */
    uint32_t          nNumUsed;
    uint32_t          nNumOfElements;
    uint32_t          nTableSize;
    uint32_t          nInternalPointer;
    zend_long         nNextFreeElement;
    dtor_func_t       pDestructor;
};

/*
 * HashTable Data Layout
 * =====================
 *
 *                 +=============================+
 *                 | HT_HASH(ht, ht->nTableMask) |
 *                 | ...                         |
 *                 | HT_HASH(ht, -1)             |
 *                 +-----------------------------+
 * ht->arData ---> | Bucket[0]                   |
 *                 | ...                         |
 *                 | Bucket[ht->nTableSize-1]    |
 *                 +=============================+
 */

PHP 7 的 arData 存儲兩部分數據,從上面源碼畫的 Layout 可以看出, arData 指向 Bucket 數組的首元素。畫面上面的部分,也就是內存中相對 arData 爲低地址的位置,存儲 hash 值及其對應碰撞鏈頭部在 Bucket 數組中的下標。

由於結構的關係,碰撞鏈頭部相對於 arData 的位置(索引值)存儲在上半部分 Hash 區。Hash 區也是一個數組,數組的索引是碰撞鏈頭部的鍵值對 key 的 hash 值經過掩碼處理後的數字。對於 arData 來說,經過掩碼處理的 hash 值應該爲負整數才能用 arData 去獲取。

這就影響到了作爲掩碼的 nTableMask 的值,其值爲 (-2 * nTableSize),類型爲 uint32_t。

用 nTableMask 與 hash 值做按位或操作,得到負整數。該負整數的範圍是 [-2 * nTableSize, -1]

用該負整數到 arData 獲取到碰撞鏈首個 Bucket 在 Bucket 數組中的正整數下標 index。然後通過 arData[index] 獲取到碰撞鏈首個 Bucket 。

接着遍歷該鏈表比較。和 JAVA 差不多,比較的時候要依次滿足兩個條件:

  • hash 值相同
  • 字符串 key 比較結果相等

Redis 的 Hash Table

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

// ... 省略

/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;

Redis 的 hash 值也沒有經過擾動函數的處理。

其掩碼 sizemask 是正整數,值爲 (size - 1)。

注意 dict 結構裏面的 dictht,它是一個擁有兩個元素的數組。數組第二個元素存儲正在執行 Rehash 的 Hash Table。

在使用 sizemask 對 hash 值做按位與得到 idx,用 table[idx] 獲取到碰撞鏈首個 dictEntry 。

接着遍歷該鏈表比較。與 PHP 5 相似,比較的時候分爲兩種:

  • 如果 key 的指針如果是同一個指針,則表示相同
  • 如果不是同一個指針,則調用函數比較兩個 key 是否相等

Go 的 map

Go 的 map 的 hash 值也沒經過擾動函數處理。

bucketMask 是 buckets 數組容量 - 1。

用 bucketMast 對 hash 值做按位與獲得 index,然後用類似於 buckets[index] 的方式取得碰撞鏈首個 bmap 。

這裏的 bmap 可存儲 8 個 Bucket。

獲取首個 bmap 之後,開始遍歷查找。

與其他實現不同的地方在於, Go 引入了 Top Hash 用空間換時間,快速比較確定衝突鏈中目標值的位置。Top Hash 取 key 的 hash 值的高 8 位。

+-----------+
|   bmap    |
+-----------+
|  tophash  |
+-----------+
|   keys    |
+-----------+
|  values   |
+-----------+
|   pad     |
+-----------+
| *overflow |
+-----------+

其中 tophash 是一個數組,每個 Top Hash 對應一個 key。

遍歷的時候,先比較 Top Hash 是否相等。如果相等,則根據 Top Hash 所在的位置找到 keys 數組中對應位置的 key。然後比較 key 是否相等。如果不相等則繼續遍歷。一個 Bucket 遍歷完後,取 overflow 繼續遍歷。

問題三:什麼時候擴容

隨着元素的數量增長,衝突鏈會變得越來越長。由於查詢時會一個個遍歷衝突鏈,因此衝突鏈變長意味着查詢效率降低。

擴容是增加存儲衝突鏈頭部的數組的容量,同時改變掩碼,使得原先同一個衝突鏈上的元素分散開來,減少衝突鏈長度。

由於各種 Hash Table 的實現不同,它們的擴容條件儘管大致相同,但細節上有差異。

JAVA HashMap 的擴容條件

// TODO

PHP5 數組的擴容條件

// TODO

PHP7 數組的擴容條件

// TODO

Redis 的擴容條件

// TODO

Go 的擴容條件

// TODO

擴容與 Rehash 有很強的關聯。做雙倍擴容之後,會執行 Rehash 重新計算舊元素的 key 的 hash,然後放到新的 Hash Table 裏面。

不過有些情況下不必觸發擴容,只需執行 Rehash 來釋放部分空間即可。例如 PHP 7 使用數組存儲 bucket。如果中間的元素被刪除很多,在後續數組空間不夠用的時候,會執行 Rehash 將元素往前移動。

問題四:如何擴容

使用 名稱 方式
JAVA HashMap 一次性
PHP 5 數組、關聯數組 一次性
PHP 7 數組、關聯數組 一次性
Go Map 漸進式
Redis hash 漸進式

JAVA HashMap

JAVA 會執行以下函數做雙倍擴容:

final Node<K,V>[] resize()

雙倍擴容的時候,會申請加倍後的容量的 Node 數組。

然後依次遍歷舊 Node 數組,及每條碰撞鏈,重新計算 key 的 hash 值,依次放入到新的數組裏面。

PHP 5

PHP 5 會執行以下函數來觸發擴容:

static void zend_hash_do_resize(HashTable *ht)

首先加倍可容納元素數量,並申請新的空間掛載到數據區。

接着按添加順序遍歷元素,重新計算元素 key 的 hash 值,然後加入到新的空間。

PHP 7

由於 PHP 7 使用數組存儲 Bucket 指針,因此在擴容的時候會一次性申請雙倍的數組內存。接着執行 Rehash。

Rehash 分爲兩種情況:

  • 存放 Bucket 的數組中間沒有元素被刪除掉
  • 存放 Bucket 的數組中間有元素被刪除掉

第一種情況很簡單,就是遍歷舊數組,然後將元素賦值到新數組。

第二種情況也是遍歷舊數組,但是如果碰到元素是無效元素,則會跳過。

Redis

Redis 的擴容類型是漸進式擴容。

進入擴容狀態時,會啓用第二個 hash table(即 ht[1])。

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;

觸發搬遷的場景是:獲取、修改、添加、刪除。

每對一個 key 操作,就搬遷一條碰撞鏈。

實際執行搬遷的邏輯是在:

int dictRehash(dict *d, int n)

第二個參數表示要搬遷的碰撞鏈的條數。

搬遷進度記錄在 rehashidx,每次都會搬遷 rehashidx 指向的碰撞鏈。搬遷完, rehashidx 前進一步。

搬遷的具體過程:

  1. 遍歷碰撞鏈,重新計算元素 key 的 hash 值。經過新的掩碼處理後放到新 hash table 裏面。
  2. 搬遷完一條碰撞鏈後,會把舊的 hash table(即 ht[0])對應的碰撞鏈置爲 NULL。
  3. 判斷是否全部搬遷完畢。如果是,則把 ht[1] 覆蓋到 ht[0] 上,然後重置 ht[1]

需要注意的是,如果正在遍歷 hash table(即 iterators 不爲 0),則不會執行搬遷。

Go 的 map

map 的擴容類型是漸進式擴容。

觸發搬遷的場景有兩個:

  • mapassign:添加或更新
  • mapdelete:刪除

實際執行搬遷的邏輯是在:

func evacuate(t *maptype, h *hmap, oldbucket uintptr)

每次搬遷一條碰撞鏈。其中第三個參數 oldbucket 表示待搬遷的碰撞鏈所在的位置。

在觸發搬遷時,會執行 growWork() 函數。這個函數會先執行一次 evacuate(),如果執行完發現還有其他碰撞鏈沒有搬遷,則再執行一次 evacuate()

但兩次搬遷的衝突鏈不一樣。第一次搬遷的是即將訪問的碰撞鏈,而第二次搬遷的碰撞鏈所在的位置由一個 nevacuate 參數確定,該參數從 0 逐漸增長。

搬遷的具體過程(假設雙倍擴容):

  1. 找到碰撞鏈,判斷該鏈是否已搬遷。
    • 如果已搬遷則跳過搬遷操作。
    • 如果未搬遷,則創建兩條碰撞鏈。遍歷現有碰撞鏈,將元素分配到這兩條碰撞鏈裏面。
      除非出現複雜情況,否則判斷掩碼後的 hash 最高位是否爲 1。如果爲 1 則放到第二條碰撞鏈;否則放到第一條碰撞鏈。
  2. 如果搬遷的正好和 nevacuate 相等,則 nevacuate 前進一步。
    如果 nevacuate 所指碰撞鏈已經搬遷,則繼續前進一步。直到碰到未搬遷的碰撞鏈,或者結束。
    如果 nevacuate 已經指向最後一條碰撞鏈,則結束搬遷。

問題四擴展:擴容過程中的數據訪問

// TODO

參考鏈接

https://www.cnblogs.com/laipimei/p/11282055.html

https://www.cnblogs.com/laipimei/p/11275235.html

https://blog.csdn.net/g5zhu5896/article/details/82968287

https://mp.weixin.qq.com/s/UWhn1uu401GlbJ0jmpRZmA

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章