深入理解雜湊表(JAVA和Redis雜湊表實現)

FreeeLinux發表於2017-02-01

有兩個字典,分別存有 100 條資料和 10000 條資料,如果用一個不存在的 key 去查詢資料,在哪個字典中速度更快?

有些計算機常識的讀者都會立刻回答: “一樣快,底層都用了雜湊表,查詢的時間複雜度為 O(1)”。然而實際情況真的是這樣麼?

答案是否定的,存在少部分情況兩者速度不一致,本文首先對雜湊表做一個簡短的總結,然後思考 Java 和 Redis 中對雜湊表的實現,最後再得出結論,如果對某個話題已經很熟悉,可以直接跳到文章末尾的對比和總結部分。

雜湊表概述

Objective-C 中的字典 NSDictionary 底層其實是一個雜湊表,實際上絕大多數語言中字典都通過雜湊表實現,比如我曾經分析過的 Swift 字典的實現原理

在討論雜湊表之前,先規範幾個接下來會用到的概念。雜湊表的本質是一個陣列,陣列中每一個元素稱為一個箱子(bin),箱子中存放的是鍵值對。

雜湊表的儲存過程如下:

  1. 根據 key 計算出它的雜湊值 h。
  2. 假設箱子的個數為 n,那麼這個鍵值對應該放在第 (h % n) 個箱子中。
  3. 如果該箱子中已經有了鍵值對,就使用開放定址法或者拉鍊法解決衝突。

在使用拉鍊法解決雜湊衝突時,每個箱子其實是一個連結串列,屬於同一個箱子的所有鍵值對都會排列在連結串列中。

雜湊表還有一個重要的屬性: 負載因子(load factor),它用來衡量雜湊表的 空/滿 程度,一定程度上也可以體現查詢的效率,計算公式為:

負載因子 = 總鍵值對數 / 箱子個數

負載因子越大,意味著雜湊表越滿,越容易導致衝突,效能也就越低。因此,一般來說,當負載因子大於某個常數(可能是 1,或者 0.75 等)時,雜湊表將自動擴容。

雜湊表在自動擴容時,一般會建立兩倍於原來個數的箱子,因此即使 key 的雜湊值不變,對箱子個數取餘的結果也會發生改變,因此所有鍵值對的存放位置都有可能發生改變,這個過程也稱為重雜湊(rehash)。

雜湊表的擴容並不總是能夠有效解決負載因子過大的問題。假設所有 key 的雜湊值都一樣,那麼即使擴容以後他們的位置也不會變化。雖然負載因子會降低,但實際儲存在每個箱子中的連結串列長度並不發生改變,因此也就不能提高雜湊表的查詢效能。

基於以上總結,細心的讀者可能會發現雜湊表的兩個問題:

  1. 如果雜湊表中本來箱子就比較多,擴容時需要重新雜湊並移動資料,效能影響較大。
  2. 如果雜湊函式設計不合理,雜湊表在極端情況下會變成線性表,效能極低。

我們分別通過 Java 和 Redis 的原始碼來理解以上問題,並看看他們的解決方案。

Java 8 中的雜湊表

JDK 的程式碼是開源的,可以從這裡下載到,我們要找的 HashMap.java 檔案的目錄在 openjdk/jdk/src/share/classes/java/util/HashMap.java

HashMap 是基於 HashTable 的一種資料結構,在普通雜湊表的基礎上,它支援多執行緒操作以及空的 key 和 value。

在 HashMap 中定義了幾個常量:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;

依次解釋以上常量:

  1. DEFAULT_INITIAL_CAPACITY: 初始容量,也就是預設會建立 16 個箱子,箱子的個數不能太多或太少。如果太少,很容易觸發擴容,如果太多,遍歷雜湊表會比較慢。
  2. MAXIMUM_CAPACITY: 雜湊表最大容量,一般情況下只要記憶體夠用,雜湊表不會出現問題。
  3. DEFAULT_LOAD_FACTOR: 預設的負載因子。因此初始情況下,當鍵值對的數量大於 16 * 0.75 = 12 時,就會觸發擴容。
  4. TREEIFY_THRESHOLD: 上文說過,如果雜湊函式不合理,即使擴容也無法減少箱子中連結串列的長度,因此 Java 的處理方案是當連結串列太長時,轉換成紅黑樹。這個值表示當某個箱子中,連結串列長度大於 8 時,有可能會轉化成樹。
  5. UNTREEIFY_THRESHOLD: 在雜湊表擴容時,如果發現連結串列長度小於 6,則會由樹重新退化為連結串列。
  6. MIN_TREEIFY_CAPACITY: 在轉變成樹之前,還會有一次判斷,只有鍵值對數量大於 64 才會發生轉換。這是為了避免在雜湊表建立初期,多個鍵值對恰好被放入了同一個連結串列中而導致不必要的轉化。

學過概率論的讀者也許知道,理想狀態下雜湊表的每個箱子中,元素的數量遵守泊松分佈:


 

當負載因子為 0.75 時,上述公式中 λ 約等於 0.5,因此箱子中元素個數和概率的關係如下:

數量 概率
0 0.60653066
1 0.30326533
2 0.07581633
3 0.01263606
4 0.00157952
5 0.00015795
6 0.00001316
7 0.00000094
8 0.00000006

這就是為什麼箱子中連結串列長度超過 8 以後要變成紅黑樹,因為在正常情況下出現這種現象的機率小到忽略不計。一旦出現,幾乎可以認為是雜湊函式設計有問題導致的。

Java 對雜湊表的設計一定程度上避免了不恰當的雜湊函式導致的效能問題,每一個箱子中的連結串列可以與紅黑樹切換。

Redis

Redis 是一個高效的 key-value 快取系統,也可以理解為基於鍵值對的資料庫。它對雜湊表的設計有非常多值得學習的地方,在不影響原始碼邏輯的前提下我會盡可能簡化,突出重點。

資料結構

在 Redis 中,字典是一個 dict 型別的結構體,定義在 src/dict.h 中:

typedef struct dict {
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;

這裡的 dictht 是用於儲存資料的結構體。注意到我們定義了一個長度為 2 的陣列,它是為了解決擴容時速度較慢而引入的,其原理後面會詳細介紹,rehashidx 也是在擴容時需要用到。先看一下 dictht 的定義:

typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long used;
} dictht;

可見結構體中有一個二維陣列 table,元素型別是 dictEntry,對應著儲存的一個鍵值對:

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

從 next 指標以及二維陣列可以看出,Redis 的雜湊表採用拉鍊法解決衝突。

整個字典的層次結構如下圖所示:


 

新增元素

向字典中新增鍵值對的底層實現如下:

dictEntry *dictAddRaw(dict *d, void *key) {
    int index;
    dictEntry *entry;
    dictht *ht;

    if (dictIsRehashing(d)) _dictRehashStep(d);
    if ((index = _dictKeyIndex(d, key)) == -1)
        return NULL;

    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
    entry = zmalloc(sizeof(*entry));
    entry->next = ht->table[index];
    ht->table[index] = entry;
    ht->used++;

    dictSetKey(d, entry, key);
    return entry;
}

dictIsRehashing 函式用來判斷雜湊表是否正在重新雜湊。所謂的重新雜湊是指在擴容時,原來的鍵值對需要改變位置。為了優化重雜湊的體驗,Redis 每次只會移動一個箱子中的內容,下一節會做詳細解釋。

仔細閱讀指標操作部分就會發現,新插入的鍵值對會放在箱子中連結串列的頭部,而不是在尾部繼續插入。這個細節上的改動至少帶來兩個好處:

  1. 找到連結串列尾部的時間複雜度是 O(n),或者需要使用額外的記憶體地址來儲存連結串列尾部的位置。頭插法可以節省插入耗時。
  2. 對於一個資料庫系統來說,最新插入的資料往往更有可能頻繁的被獲取。頭插法可以節省查詢耗時。

增量式擴容

所謂的增量式擴容是指,當需要重雜湊時,每次只遷移一個箱子裡的連結串列,這樣擴容時不會出現效能的大幅度下降。

為了標記雜湊表正處於擴容階段,我們在 dict 結構體中使用 rehashidx 來表示當前正在遷移哪個箱子裡的資料。由於在結構體中實際上有兩個雜湊表,如果新增新的鍵值對時雜湊表正在擴容,我們首先從第一個雜湊表中遷移一個箱子的資料到第二個雜湊表中,然後鍵值對會被插入到第二個雜湊表中。

在上面給出的 dictAddRaw 方法的實現中,有兩句程式碼:

if (dictIsRehashing(d)) _dictRehashStep(d);
// ...
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];

第二句就是用來選擇插入到哪個雜湊表中,第一句話則是遷移 rehashidx 位置上的連結串列。它實際上會呼叫 dictRehash(d,1),也就是說是單步長的遷移。dictRehash 函式的實現如下:

int dictRehash(dict *d, int n) {
    int empty_visits = n*10; /* Max number of empty buckets to visit. */

    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;

        while(d->ht[0].table[d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        de = d->ht[0].table[d->rehashidx];
        /* Move all the keys in this bucket from the old to the new hash HT */
        while(de) {
            unsigned int h;

            nextde = de->next;
            /* Get the index in the new hash table */
            h = dictHashKey(d, de->key) & d->ht[1].sizemask;
            de->next = d->ht[1].table[h];
            d->ht[1].table[h] = de;
            d->ht[0].used--;
            d->ht[1].used++;
            de = nextde;
        }
        d->ht[0].table[d->rehashidx] = NULL;
        d->rehashidx++;
    }

    /* Check if we already rehashed the whole table... */
    if (d->ht[0].used == 0) {
        zfree(d->ht[0].table);
        d->ht[0] = d->ht[1];
        _dictReset(&d->ht[1]);
        d->rehashidx = -1;
        return 0;
    }

    return 1;
}

這段程式碼比較長,但是並不難理解。它由一個 while 迴圈和 if 語句組成。在單步遷移的情況下,最外層的 while 迴圈沒有意義,而它內部又可以分為兩個 while 迴圈。

第一個迴圈用來更新 rehashidx 的值,因為有些箱子為空,所以 rehashidx 並非每次都比原來前進一個位置,而是有可能前進幾個位置,但最多不超過 10。第二個迴圈則用來複制連結串列資料。

最外面的 if 判斷中,如果發現舊雜湊表已經全部完成遷移,就會釋放舊雜湊表的記憶體,同時把新的雜湊表賦值給舊的雜湊表,最後把 rehashidx 重新設定為 -1,表示重雜湊過程結束。

預設雜湊函式

與 Java 不同的是,Redis 提供了 void * 型別 key 的雜湊函式,也就是通過任何型別的 key 的指標都可以求出雜湊值。具體演算法定義在 dictGenHashFunction 函式中,由於程式碼過長,而且都是一些位運算,就不展示了。

它的實現原理是根據指標地址和這一塊記憶體的長度,獲取記憶體中的值,並且放入到一個陣列當中,可見這個陣列僅由 0 和 1 構成。然後再對這些數字做雜湊運算。因此即使兩個指標指向的地址不同,但只要其中內容相同,就可以得到相同的雜湊值。

歸納對比

首先我們回顧一下 Java 和 Redis 的解決方案。

Java 的長處在於當雜湊函式不合理導致連結串列過長時,會使用紅黑樹來保證插入和查詢的效率。缺點是當雜湊表比較大時,如果擴容會導致瞬時效率降低。

Redis 通過增量式擴容解決了這個缺點,同時拉鍊法的實現(放在連結串列頭部)值得我們學習。Redis 還提供了一個經過嚴格測試,表現良好的預設雜湊函式,避免了連結串列過長的問題。

Objective-C 的實現和 Java 比較類似,當我們需要重寫 isEqual() 方法時,還需要重寫 hash 方法。這兩種語言並沒有提供一個通用的、預設的雜湊函式,主要是考慮到 isEqual() 方法可能會被重寫,兩個記憶體資料不同的物件可能在語義上被認為是相同的。如果使用預設的雜湊函式就會得到不同的雜湊值,這兩個物件就會同時被新增到 NSSet 集合中,這可能違揹我們的期望結果。

根據我的瞭解,Redis 並不支援重寫雜湊方法,難道 Redis 就沒有考慮到這個問題麼?實際上還要從 Redis 的定位說起。由於它是一個高效的,Key-Value 儲存系統,它的 key 並不會是一個物件,而是一個用來唯一確定物件的標記。

一般情況下,如果要儲存某個使用者的資訊,key 的值可能是這樣: user:100001。Redis 只關心 key 的記憶體中的資料,因此只要是可以用二進位制表示的內容都可以作為 key,比如一張圖片。Redis 支援的資料結構包括雜湊表和集合(Set),但是其中的資料型別只能是字串。因此 Redis 並不存在物件等同性的考慮,也就可以提供預設的雜湊函式了。

Redis、Java、Objective-C 之間的異同再次證明了一點:

沒有完美的架構,只有滿足需求的架構。

總結

回到文章開頭的問題中來,有兩個字典,分別存有 100 條資料和 10000 條資料,如果用一個不存在的 key 去查詢資料,在哪個字典中速度更快?

完整的答案是:

在 Redis 中,得益於自動擴容和預設雜湊函式,兩者查詢速度一樣快。在 Java 和 Objective-C 中,如果雜湊函式不合理,返回值過於集中,會導致大字典更慢。Java 由於存在連結串列和紅黑樹互換機制,搜尋時間呈對數級增長,而非線性增長。在理想的雜湊函式下,無論字典多大,搜尋速度都是一樣快。

最後,整理了一下本文提到的知識點,希望大家讀完文章後對以下問題有比較清楚透徹的理解:

  1. 雜湊表中負載因子的概念
  2. 雜湊表擴容的過程,以及對查詢效能的影響
  3. 雜湊表擴容速度的優化,拉鍊法插入新元素的優化,連結串列過長時的優化
  4. 不同語言、使用場景下的取捨



文/bestswifter(簡書作者)
原文連結:http://www.jianshu.com/p/138ccbc75803
著作權歸作者所有,轉載請聯絡作者獲得授權,並標註“簡書作者”。

相關文章