PHP7 中的 Hashtable 的實現

來,見證奇蹟發表於2018-02-03

本文翻譯自 nikic 的一篇博文。

PHP7 中的 Hashtable 的實現

簡要:這篇文章我也並不是按照原文逐字逐句的都翻譯過來,其中略去了一些與本文知識點無關的內容,加入了一些個人理解,不過版權還是歸原作者所有。文章主要討論的是 PHP7.x 中的 新的 HashTable 的實現 。PHP7 中 Zend 引擎的核心程式碼中很大部分使用了佔用記憶體更小效率更高的資料結構進行了重寫!本篇文章就新版本中 HashTable 的實現以及為什麼比之前的版本更加高效了進行研究。

本文中所有的知識介紹和內容總結都基於下面的實際案例。

構造一個含有100000個不重複的整型元素的陣列並且測量其佔用的記憶體數量,實現程式碼如下:

// 記錄開始記憶體狀態
$startMemory = memory_get_usage();
// 生成包含1-100000為資料元素的陣列
$array = range(1, 100000);
// 獲取陣列佔用的記憶體空間大小(單位位元組:bytes)
echo memory_get_usage() - $startMemory, ' bytes';

下面是上面的程式碼分別使用PHP5.6 和 PHP7 在 32位系統和64位系統下的測試對比 :

版本    |   32 bit |    64 bit
------------------------------
PHP 5.6 | 7.37 MiB | 13.97 MiB
------------------------------
PHP 7.0 | 3.00 MiB |  4.00 MiB

上面的資料顯示,PHP5中的記憶體佔用是新版本PHP7的 2.5 倍!這個資料令人印象深刻!

hashtable 簡介

其實PHP中的陣列就是使用 HashTable 實現的! PHP中的陣列是有序的字典,換言之,PHP中的陣列是使用了 hashtable 實現的有序的 key/value 對。

雜湊表是一個無處不在的資料結構,主要解決計算機只能直接表示連續的整型陣列的問題,而程式設計師經常要使用字串或其他複雜型別作為鍵(key)。

其實雜湊表(hashtable)背後的實現邏輯並不複雜 : 可以通過雜湊函式將字串型別的鍵(key)和一個整型值對應起來或者說轉化成一個整型值。然後用這個整型值作為一個“普通”陣列的索引。 但是有一個問題,在雜湊表中有可能兩個或者更多的字串通過雜湊函式的轉化可能對應著同一個整型值,這種現象叫做“衝突”。實際上一定會產生“衝突”,因為字串的數量是無限多的,但是雜湊表的長度是有限的。所以對於雜湊表而言就需要有能夠解決衝突的一些機制。

主要有兩種"衝突"解決機制 :

  • 公開地址法 : 也稱為 “再雜湊法”,這種方法會把發生衝突的元素儲存到不同索引中!
  • 鏈地址法 : 發生衝突的元素儲存到同一個索引上,然後在該索引下建立一個連結串列來儲存這些元素。

PHP 使用的是 鏈地址法。

典型的 hashtable 的元素順序並不是明確固定的 : 元素在陣列中的順序通過雜湊函式計算隨機確定的!這就導致了其實對於雜湊表而言並不像陣列一樣儲存在確定的位置訪問也會有確定的位置!所以雜湊表需要一種特殊的機制來記憶資料到底放在什麼地方了!

老版本 hashtable 的實現

這裡我們首先對來把版本的 hashtable 的實現做一個簡單的回顧,更加詳細的內容可以參考 這篇文章。下面的圖片已經高度概括了 PHP5 中的 hashtable 的實現。

enter image description here

上圖中 “衝突解決” 部分是連結串列,其中的元素稱為 "buckets"。每一個 "bucket" 的空間都獨立分配。圖中 "buckets" 部分的展示省略了值的儲存,僅僅展示了 key 的儲存。 真正的值實際上要儲存在 zval 結構中,32位機器會分配 16 bytes 大小,64位機器會分配 24 bytes 空間大小。

圖中還略去了一個很重要的內容 : “碰撞解決” 部分的連結串列實際上是一個雙向連結串列!除了依賴“碰撞解決”連結串列外還要用到另外一個雙向連結串列,裡面儲存著有序的陣列元素。正如下圖展示的一樣,這裡儲存的是有序的 "a" , "b", "c":

enter image description here

仔細觀察上面的兩張圖,可以分析一下為什麼老版本的 hashtable 效率低下?為什麼記憶體佔用高?主要的原因如下:

  • Buckets 需要單獨的分配空間。分配過程效率很低並且每一個還需要 8/16 bytes 的分配頭資訊。而且獨立的空間也意味著 buckets 需要更多的記憶體空間並且快取效率也會降低!
  • Zvals 也需要獨立分配空間。繼續拖慢了效率,而且也需要分配頭資訊!更過分的是每一個 bucket 裡面都要儲存一個指向 zval 的指標。所以對於老版本的 hashtable 而言,要實現這些功能需要的是兩個指標而不是一個!
  • 由於使用的是雙向連結串列,所以每一個 bucket 裡面就需要四個指標!光指標就消耗掉了 16/32 bytes。更糟糕的是 遍歷連結串列本身 也是一個耗費快取的的操作!

所以基於以上問題,新版本已經(至少基本上)解決了它們!

新版本 zval 的實現。

詳細研究前,先讓我們看看新版本 zval 的實現以及指出與老版本的不同。新版本的 zval 結構定義如下:

struct _zval_struct {
    //變數實際的value
    zend_value value;
    union {
        struct {
            //這個是為了相容大小位元組序,小位元組序就是下面的順序,大位元組序則下面4個順序翻轉
            ZEND_ENDIAN_LOHI_4( 
                //變數型別
                zend_uchar    type, 
                //型別掩碼,不同的型別會有不同的幾種屬性,記憶體管理會用到
                zend_uchar    type_flags,  
                zend_uchar    const_flags,
                //call info,zend執行流程會用到
                zend_uchar    reserved)     
        } v;
        //上面4個值的組合值,可以直接根據type_info取到4個對應位置的值
        uint32_t type_info; 
    } u1;
    union {
        uint32_t     var_flags;
        uint32_t     next;                 //雜湊表中解決雜湊衝突時用到
        uint32_t     cache_slot;           /* literal cache slot */
        uint32_t     lineno;               /* line number (for ast nodes) */
        uint32_t     num_args;             /* arguments number for EX(This) */
        uint32_t     fe_pos;               /* foreach position */
        uint32_t     fe_iter_idx;          /* foreach iterator index */
    } u2; //一些輔助值
};

上述定義中你可以放心的忽略 ZEND_ENDIAN_LOHI_4 部分的巨集定義。它是為了實現跨平臺相容大小位元組序而設定的!

zval包含3大部分 :

  • value : 是 zend_value 型別, 長度是 8 bytes,可以儲存不同型別的值,包含整型資料、字串、陣列等等,具體的型別由 zval 的 型別決定!
  • type_info : 是 unimt32_t 型別,長度是 4 byte,包含了預定義的型別資訊值 (例如 _STRING 或者 IS_ARRAY), 也包含一系列的關於型別的附加資訊。例如如果 zval 儲存了一個 物件型別的值(object), 那麼型別標誌就會標誌它是 非常量(non-constant), 引用型別(refcounted),可垃圾回收(garbage-collectible), 非複製(non-copying) 型別.
  • zval 的最後 4 個位元組通常情況下並不會使用!值純粹是個輔助值,假如zval只有:value、u1兩個值,整個zval的大小也會對齊到16byte,既然不管有沒有u2大小都是16byte,把多餘的4byte拿出來用於一些特殊用途還是很划算的,比如next在雜湊表解決雜湊衝突時會用到,還有fe_pos在foreach會用到.

如果和老的實現方式對比,會發現一個明顯的不同 : 新版的 hashtable 不再儲存 引用計數器(refcount)資訊。原因是 zval 不再單獨分配空間,無論儲存什麼資訊 zval 都會直接嵌入到裡面,例如 hashtable bucket。

雖然 zval 不再記錄引用計數器了,但是PHP中的複合資料型別(例如:字串,陣列,物件和資源型別)依然使用它!但是zval已經把引用計數器(以及垃圾回收器的相關資訊)剔除掉並且移交給 array/object 等等這些資料結構了。這樣做喲很多好處,這裡列舉了一些供大家參考:

  • Zval 僅儲存簡單的值(像 布林值,整型值,浮點數)就不用需要請求分配獨立空間了。這樣就節省了配置請求頭的開銷, 通過避免不必要的請求和釋放快取提高了效能!
  • Zval 僅儲存簡單的值就不用儲存引用計數器和垃圾回收收集器的緩衝資訊。
  • 避免了重複引用計數。例如,以前的物件都使用變數引用計數和一個額外的物件的引用計數,這是必要的,物件之間要傳遞語義。
  • 所有複合型別的值現在嵌入一個引用計數器,這意味著它們可以不依賴於 zval 實現分享了!尤其是現在也可以實現字串的共享了。這對雜湊表實現意義非凡,因為它不再需要複製非限定字串鍵值。

新版 hashtable 的實現

上面的內容只是一些鋪墊,下面我們就可以正式開始討論新版的 hashtable 的實現了!首先還是從 bucket 結構的實現開始!

bucket 結構定義:

typedef struct _Bucket {
    zend_ulong        h;
    zend_string      *key;
    zval              val;
} Bucket;

hashtabe 中一個 bucket 就是一個容器!裡面包含了所有你想要的內容 : 一個 hash 型別的 h, 一個字串型別的鍵 key,一個 zval 型別的 val。但是如果 h 中儲存的是整型的鍵的話, key 將設為 NULL。

並且可以發現 zval 被直接嵌入到了 bucket 中,所以就不用單獨分配空間了也節約了分配的開銷。

主 hashtable 結構更有意思 :

typedef struct _HashTable {
    uint32_t          nTableSize;
    uint32_t          nTableMask;
    uint32_t          nNumUsed;
    uint32_t          nNumOfElements;
    zend_long         nNextFreeElement;
    Bucket           *arData;           // 陣列元素的排序
    uint32_t         *arHash;           // hashtable 查詢
    dtor_func_t       pDestructor;
    uint32_t          nInternalPointer;
    union {
        struct {
            ZEND_ENDIAN_LOHI_3(
                zend_uchar    flags,
                zend_uchar    nApplyCount,
                uint16_t      reserve)
        } v;
        uint32_t flags;
    } u;
} HashTable;

buckets (也就是陣列的元素) 被儲存在一個 arData 陣列中。 陣列包含了兩個強大的部分!第一個部分是 nTableSize (最小值是 8)儲存了陣列的容量。另一部分是 nNumOfElements 儲存了真實的資料。 再就是要注意,陣列中已經直接包含了 Bucket 結構。這就意味著,在我們使用指標陣列分別分配 bucket 空間之前,我們需要更多的做分配/釋放空間的事,需要跟多的開銷和額外的指標。

元素排序

arData 陣列按照元素插入的順序儲存元素,所以第一個素組元素要插入到 arData[0] , 第二個元素插入到 arData1 ,依次排序。再次強調的是這個不是由使用順序決定的,僅僅由插入順序決定!

所以如果你插入 hashtable 內五個元素,那麼就意味著從 arData[0] 到 arData[4] 都會被使用,下一個空位置是 arData[5]. 我們需要知道的是空位置會儲存在 nNumUsed 中。你可能會想:為什麼非要單獨儲存這個值呢?難道和 nNumOfElements 不一樣嗎?

如果只有插入操做的話這兩個是一樣的!但是如果一個元素從 hashtable 中刪除的話,我們總不能把 arData 中所有的元素都刪掉再重新索引一個有序陣列吧?所以,我們分別儲存 nNumUsed 和 nNumOfElements,並且使用一個 avzl型別的 IS_UNDEF 來標記刪除元素!

空說無憑,舉例說明:

$array = [
    'foo' => 0,
    'bar' => 1,
    0     => 2,
    'xyz' => 3,
    2     => 4
];
unset($array[0]);
unset($array['xyz']);

arData 的結果如下:

nTableSize     = 8
nNumOfElements = 3
nNumUsed       = 5

[0]: key="foo", val=int(0)
[1]: key="bar", val=int(1)
[2]: val=UNDEF
[3]: val=UNDEF
[4]: h=2, val=int(4)
[5]: NOT INITIALIZED
[6]: NOT INITIALIZED
[7]: NOT INITIALIZED

上面的陣列中的前五個元素被使用了,但是第二個位置(下標為 0) 和 第三個位置(下標是 'xyz') 的元素由於被刪除了,所以已經打上了 UNDEF 的標記。那麼這些空間現在就只剩下浪費空間了!然而,一旦 nNumUsed裡面的值達到 nTableSize裡面的值 PHP 就會嘗試幹掉所有打了 UNDEF 標記的元素來壓縮 arData 陣列。只有所有的 buckets 包含的資料真的達到了臨界點,arData 才會真的把陣列的容量擴充套件成原來的兩倍。

這種維護陣列元素順序的新方法比起 PHP5.x 中使用雙向連結串列的方法有幾個優點。一個明顯的優點是每個 bucket 中儲存兩個指標,對應著 8/16 bytes。另外我們又多了一種遍歷陣列的新方法:

uint32_t i;
for (i = 0; i < ht->nNumUsed; ++i) {
    Bucket *b = &ht->arData[i];
    if (Z_ISUNDEF(b->val)) continue;
               // do stuff with bucket
}

這相當於一個記憶體的線性掃描,它比連結串列遍歷效率高得多 (在相對隨機的記憶體地址之間來回往返)。

但是新的實現方法也有缺點,其中一個問題是 arData 從不自動釋放空間(除非顯示宣告)。所以假如你首先建立了一個包含百萬條元素的陣列並且之後將這些元素釋放掉,陣列仍然佔用大量的記憶體。所以我們如果陣列的利用率低於某一水平我們應該減少一半的 arData 容量。

雜湊表的查詢實現

目前為止我一已經討論了 PHP 陣列排序的實現。下面我們一起來討論一下 hashtable 查詢的相關實現。hashtable 的查詢要用到 HashTable 結構中的 unit32_t 型別的 arHash。arHash 陣列的大小和 arData 一樣,並且兩個都在記憶體中分配一整塊空間。

PHP中 hashtable 使用雜湊演算法(例如著名的 DJBX33A 演算法)得到的雜湊值是一個 32位 或者 64位 的無符號整型數值,這樣的數值直接作為 hash 陣列的索引實在太大了! 所以我們首先要把它變小!小到 nTableSize 的長度!可以通過 hash & (ht->nTableSize - 1) 來求到值, 並把 ht->nTableSize - 1 存入 ht->nTableMask。

接下來我們要在 hashtable 中查詢索引 :

idx = ht->arHash[hash & ht->nTableMask] 

該索引對應著衝突解決連結串列的頭資訊。所以 ht->arData[idx] 是我們要檢查的第一個元素。如果我們恰好是我們要找的鍵,那就完成了查詢。

否則,我們要繼續檢測 衝突檢測連結串列的下一個元素。下一個元素的索引儲存在 bucket->val.u2.next 中。我們要一直遍歷單錢連結串列,但是會有兩種結果,要麼可以找到正確的 結果bucket,要麼一直走到最後碰到 INVALID_IDX 結束查詢,也就是意味著沒有我們要找的內容。

程式碼實現如下:

zend_ulong h = zend_string_hash_val(key);
uint32_t idx = ht->arHash[h & ht->nTableMask];
while (idx != INVALID_IDX) {
    Bucket *b = &ht->arData[idx];
    if (b->h == h && zend_string_equals(b->key, key)) {
        return b;
    }
    idx = Z_NEXT(b->val); // b->val.u2.next
}
return NULL;

當然,我們一起看一下這種實現方式到底比之前的好在哪兒 : PHP5.x 中的衝突解決使用了雙向連結串列,新版本中使用了 uint32_t 索引來代替指標,這樣在64位系統中可以節約一半的空間。另外 uint32_t 索引大小是4個位元組,這就意味著可以直接存入到未使用的 zval slot 中,所以基本上我們就可以“免費”使用它了。

我們現在使用了單連結串列,也就沒有了指向前一個元素的 “pre” 指標!但是知道前一個元素對於刪除操作而言非常的重要,因為在連結串列中要刪除一個元素我們必須調整刪除元素的前一個元素的 “next” 指標!但是其實還可以按照鍵值刪除,如果按照鍵值刪除,在遍歷連結串列的過程中我們也可以知道上一個元素是誰!當然也有一些特殊的情況有可能會發生,例如刪除某個元素前所有的元素,那麼就有可能要產生重複的遍歷,不過這種情況並不多,所以還是優先考慮記憶體節約吧!

包裝化雜湊表 : Packed hashtables

PHP的陣列是使用PHP內建的 hashtable 實現的。但是對於類似C語言的純整型陣列--PHP中的索引陣列--而言,hashtable 並沒有什麼作用。所以PHP7 提出了 “packed hashtables” 的概念!我們就暫且翻譯成 “包裝化雜湊表”。

在 經過包裝的 hashtable 裡 arHash 陣列的會被設定為 NULL,並且搜尋會直接索引到 arData。所以,如果arData[5]不為空,那麼如果你想查詢下標是 5 的值會直接定位到 arData[5] 而不是要去遍歷衝突檢測的連結串列結構。

不過需要注意的是即使是整型陣列也需要按照插入順序保持有序!相信大家都懂得 [0 => 1, 1 => 2] 和 [1 => 2, 0 => 1] 並不相同! 所以,包裝化雜湊表僅發生在預設是升序的整型陣列中!當然,允許下標有間隔,但是必須是升序的!

還有一點就是 包裝化的 hashtable 會儲存很多沒有的資訊!例如我們可以通過記憶體資訊確定一個 bucket 的索引,但是 bucket->h 仍然會被使用!再比如 bucket->key 的值是 NULL,會浪費大力量記憶體!

但是不論是否使用 包裝化雜湊表,為了使 bucket 的結構保持一致便於統一遍歷還是要保留這些沒用的值。當然,如果可能的話,有可能會使用純粹的 zval 陣列。

空hashtable

無論是PHP5.x 還是 PHP7.x 對於 空 hashtable 的處理都有些特殊。如果你僅僅建立了一個空陣列但沒有插入任何元素,arData/arHash 是不會被分配空間的!只有當 hashtable 插入第一個元素時它們才會被分配空間!

當然,PHP提供了更好的標記空hashtabel的方法,不用處處來檢測一個hashtable是不是空的。這裡用了一個小小的技巧 : 當 nTableSize 內的值設定為一個預定義好的標誌值或者預設值8時,nTableMask 內的值會被設定為 0。那麼有意思的事兒就發生了 : 如果hashtable是空的,那麼 hash & ht->nTableMask 的值也就永遠是 0。

所以當 hashtable 是空的時,整個 arHash 陣列只需要包含一個索引為0的元素並且值是 INVALID_IDX。所以如果要查詢某個值時,一旦找到 INVALID_IDX 值,就意味著當前 hashtable 是空的。

記憶體利用率

這些內容將涵蓋PHP7的hashtable實現中非常重要的部分!首先讓我們總結一下為什麼PHP7的hashtable可以大幅降低記憶體佔用!當然,我們僅討論 64位系統下的每個元素資料,整個過程也忽略掉 hashtable的結構!

在PHP5.x 中一個元素的大小是144 bytes,而PHP7每個元素只佔36 bytes,如果對hashtable進行包裝化的話每個元素僅佔34 bytes。下面列出來早就如此巨大差異的主要原因:

  • Zvals 不再獨立分配空間,節約了 16 bytes 空間開銷.
  • Buckets 不再獨立分配空間, 再次節約了 16 bytes 空間.
  • 對於普通值,Zvals 減小了 16 bytes.
  • 隱含有序,不再需要雙向列表保持有序,節約了 16 bytes.
  • 衝突解決使用單連結串列代替雙向連結串列,省下了 8 bytes. 再就是它現在是一個索引列表並且下標直接內嵌入zval中,再次節約了 8 bytes.
  • zval 直接存入 bucket, 所以不再需要指向zval的指標. 節約了 16 bytes.
  • 鍵的長度不再需要存入到 bucket, 節約了 8 bytes. 但是如果鍵值的型別是字串型別的,長度仍然要儲存到 zend_string 結構中。所以這裡的存並不能確切的統計出來,因為 zend_string 結構是共用的 。
  • 儲存 衝突解決列表 的陣列現在是索引型別的,所以每個元素可以節約 4 bytes。如果是包裝化的陣列連這個也不要了,又節約了 4 bytes。

需要說明的是我們在下面的總結比實際結果可能要更理想一些。

首先新版本的 hashtable 的實現中使用了更多的嵌入結構代替了需要獨立分配記憶體的結構,這樣做也會帶來一些壞處。

如果你仔細觀察了本文開頭所展示的數字,你會發現在64位機器上新版本的PHP7實現的包含100000個元素的陣列佔用了4MB 記憶體空間。但是如果按照理想狀態,這個陣列應該佔用 32 * 100000 = 3.05 MB 大小記憶體。多出來的這部分記憶體原因就出在我們處理的任何元素都包含了兩種能力 : 主要功能即自身能力和輔助功能例如索引等。這會使 nTableSize 的大小為 2^17 = 131072 , 所以我們就有了 32 * 131072 bytes 即 is 4.00 MB 記憶體空間.

當然老版本的 hashtable 也會有兩種能力,但是僅僅是回宣告一個陣列的 bucket 指標。其他東西都按需分配。所以在 PHP7 中我們浪費了 32 * 31072 (0.95 MB) 空間,在 5.x 只能夠僅僅浪費了 8 * 31072 (0.24 MB) 空間。

另一個要考慮的事兒是假設陣列中所有元素都儲存一樣的值會有什麼結果。所以我們可以把開頭的案例變成如下所示:

$startMemory = memory_get_usage();
$array = array_fill(0, 100000, 42);
echo memory_get_usage() - $startMemory, " bytes\n";

結果如下:

版本    |   32 bit |    64 bit
------------------------------
PHP 5.6 | 4.70 MiB |  9.39 MiB
------------------------------
PHP 7.0 | 3.00 MiB |  4.00 MiB

從上面的結果可以看出,PHP7中無論儲存的是不重複的值還是相同的值,佔用記憶體大小不會變化。其實這不奇怪,畢竟在PHP7中所有的 zval 都是獨立的!但是在PHP5中,記憶體佔用明顯下降了,原因是所有的元素都會公用一個 zval !所以在這種情況下,新老版本的差異就小一些。

如果我們繼續更加深入考慮其它更多資訊例如字串鍵或者更復雜的值情況就變得更復雜了。但無論如何,PHP7 總是比 PHP5 佔用更少的記憶體,只不過不同情況它們的差距也會不同。

效能 我們已經討論了很多記憶體相關的內容,讓我繼續研究下一個關鍵內容:效能。新版本PHP的目標是提高效能而不是僅僅降低記憶體使用率。當然正因為降低了記憶體,從而提高了CPU 快取使用率,進而提高了效能。

當然效能的提高還應該歸功於其它一些原因 :

  • 首先是記憶體分配操作減少了。其實記憶體分配操分配相當的耗費資源!然而我們每一個元素都可以節約兩次記憶體的分配操作。
  • 資料遍歷過程對快取更友好。因為新版本中記憶體遍歷不再是隨機的而是線性的。
  • ......

當然還有更多的因素和理由,但是我們本篇文章重點是“記憶體”,所以在此對於效能問題就不多聊了。

相關文章