PHP核心探索之PHP中的雜湊表

hoohack發表於2016-07-08

在PHP核心中,其中一個很重要的資料結構就是HashTable。我們常用的陣列,在核心中就是用HashTable來實現。那麼,PHP的HashTable是怎麼實現的呢?最近在看HashTable的資料結構,但是演算法書籍裡面沒有具體的實現演算法,剛好最近也在閱讀PHP的原始碼,於是參考PHP的HashTable的實現,自己實現了一個簡易版的HashTable,總結了一些心得,下面給大家分享一下。

筆者github上有一個簡易版的HashTable的實現:HashTable實現

另外,我在github有對PHP原始碼更詳細的註解。感興趣的可以圍觀一下,給個star。PHP5.4原始碼註解。可以通過commit記錄檢視已新增的註解。

HashTable的介紹

雜湊表是實現字典操作的一種有效資料結構。

定義

簡單地說,HashTable(雜湊表)就是一種鍵值對的資料結構。支援插入,查詢,刪除等操作。在一些合理的假設下,在雜湊表中的所有操作的時間複雜度是O(1)(對相關證明感興趣的可以自行查閱)。

實現雜湊表的關鍵

在雜湊表中,不是使用關鍵字做下標,而是通過雜湊函式計算出key的雜湊值作為下標,然後查詢/刪除時再計算出key的雜湊值,從而快速定位元素儲存的位置。

在一個雜湊表中,不同的關鍵字可能會計算得到相同的雜湊值,這叫做“雜湊衝突”,就是處理兩個或多個鍵的雜湊值相同的情況。解決雜湊衝突的方法有很多,開放定址法,拉鍊法等等。

因此,實現一個好的雜湊表的關鍵就是一個好的雜湊函式和處理雜湊衝突的方法。

Hash函式

判斷一個雜湊演算法的好壞有以下四個定義: > * 一致性,等價的鍵必然產生相等的雜湊值; > * 高效性,計算簡便; > * 均勻性,均勻地對所有的鍵進行雜湊。

雜湊函式建立了關鍵值與雜湊值的對應關係,即:h = hash_func(key)。對應關係見下圖:

hash-exam

設計一個完美的雜湊函式就交由專家去做吧,我們只管用已有的較成熟的雜湊函式就好了。PHP核心使用的雜湊函式是time33函式,又叫DJBX33A,其實現如下:

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;
}

注:函式使用了一個8次迴圈+switch來實現,是對for迴圈的優化,減少迴圈的執行次數,然後在switch裡面執行剩下的沒有遍歷到的元素。

拉鍊法

將所有具有相同雜湊值的元素都儲存在一條連結串列中的方法叫拉鍊法。查詢的時候通過先計算key對應的雜湊值,然後根據雜湊值找到對應的連結串列,最後沿著連結串列順序查詢相應的值。 具體儲存後的結構圖如下:

hashtable-exam

PHP的HashTable結構

簡單地介紹了雜湊表的資料結構之後,繼續看看PHP中是如何實現雜湊表的。

(圖片源自網路,侵權即刪)

PHP核心hashtable的定義:

typedef struct _hashtable {
          uint nTableSize;
          uint nTableMask;
          uint nNumOfElements;
          ulong nNextFreeElement;
          Bucket *pInternalPointer;
          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;
  • nTableSize,HashTable的大小,以2的倍數增長
  • nTableMask,用在與雜湊值做與運算獲得該雜湊值的索引取值,arBuckets初始化後永遠是nTableSize-1
  • nNumOfElements,HashTable當前擁有的元素個數,count函式直接返回這個值
  • nNextFreeElement,表示數字鍵值陣列中下一個數字索引的位置
  • pInternalPointer,內部指標,指向當前成員,用於遍歷元素
  • pListHead,指向HashTable的第一個元素,也是陣列的第一個元素
  • pListTail,指向HashTable的最後一個元素,也是陣列的最後一個元素。與上面的指標結合,在遍歷陣列時非常方便,比如reset和endAPI
  • arBuckets,包含bucket組成的雙向連結串列的陣列,索引用key的雜湊值和nTableMask做與運算生成
  • pDestructor,刪除雜湊表中的元素使用的解構函式
  • persistent,標識記憶體分配函式,如果是TRUE,則使用作業系統本身的記憶體分配函式,否則使用PHP的記憶體分配函式
  • nApplyCount,儲存當前bucket被遞迴訪問的次數,防止多次遞迴
  • bApplyProtection,標識雜湊表是否要使用遞迴保護,預設是1,要使用

舉一個雜湊與mask結合的例子:

例如,”foo”真正的雜湊值(使用DJBX33A雜湊函式)是193491849。如果我們現在有64容量的雜湊表,我們明顯不能使用它作為陣列的下標。取而代之的是通過應用雜湊表的mask,然後只取雜湊表的低位。

hash           |        193491849  |     0b1011100010000111001110001001
& mask         | &             63  | &   0b0000000000000000000000111111
----------------------------------------------------------------------
= index        | = 9               | =   0b0000000000000000000000001001

因此,在雜湊表中,foo是儲存在arBuckets中下標為9的bucket向量中。

bucket結構體的定義

typedef struct bucket {
     ulong h;
     uint nKeyLength;
     void *pData;
     void *pDataPtr;
     struct bucket *pListNext;
     struct bucket *pListLast;
     struct bucket *pNext;
     struct bucket *pLast;
     const char *arKey;
} Bucket;
  • h,雜湊值(或數字鍵值的key
  • nKeyLength,key的長度
  • pData,指向資料的指標
  • pDataPtr,指標資料
  • pListNext,指向HashTable中的arBuckets連結串列中的下一個元素
  • pListLast,指向HashTable中的arBuckets連結串列中的上一個元素
  • pNext,指向具有相同hash值的bucket連結串列中的下一個元素
  • pLast,指向具有相同hash值的bucket連結串列中的上一個元素
  • arKey,key的名稱

PHP中的HashTable是採用了向量加雙向連結串列的實現方式,向量在arBuckets變數儲存,向量包含多個bucket的指標,每個指標指向由多個bucket組成的雙向連結串列,新元素的加入使用前插法,即新元素總是在bucket的第一個位置。由上面可以看到,PHP的雜湊表實現相當複雜。這是它使用超靈活的陣列型別要付出的代價。

一個PHP中的HashTable的示例圖如下所示:

php-hash-table-exam

HashTable相關API

  • zend_hash_init
  • zend_hash_add_or_update
  • zend_hash_find
  • zend_hash_del_key_or_index

zend_hash_init

函式執行步驟

  • 設定雜湊表大小
  • 設定結構體其他成員變數的初始值 (包括釋放記憶體用的解構函式pDescructor)

詳細程式碼註解點選:zend_hash_init原始碼

注:

1、pHashFunction在此處並沒有用到,php的雜湊函式使用的是內部的zend_inline_hash_func

2、zend_hash_init執行之後並沒有真正地為arBuckets分配記憶體和計算出nTableMask的大小,真正分配記憶體和計算nTableMask是在插入元素時進行CHECK_INIT檢查初始化時進行。

zend_hash_add_or_update

函式執行步驟

  • 檢查鍵的長度
  • 檢查初始化
  • 計算雜湊值和下標
  • 遍歷雜湊值所在的bucket,如果找到相同的key且值需要更新,則更新資料,否則繼續指向bucket的下一個元素,直到指向bucket的最後一個位置
  • 為新加入的元素分配bucket,設定新的bucket的屬性值,然後新增到雜湊表中
  • 如果雜湊表空間滿了,則重新調整雜湊表的大小

函式執行流程圖

zend_hash_add_or_update

CONNECT_TO_BUCKET_DLLIST是將新元素新增到具有相同hash值的bucket連結串列。

CONNECT_TO_GLOBAL_DLLIST是將新元素新增到HashTable的雙向連結串列。

詳細程式碼和註解請點選:zend_hash_add_or_update程式碼註解

zend_hash_find

函式執行步驟

  • 計算雜湊值和下標
  • 遍歷雜湊值所在的bucket,如果找到key所在的bucket,則返回值,否則,指向下一個bucket,直到指向bucket連結串列中的最後一個位置

詳細程式碼和註解請點選:zend_hash_find程式碼註解

zend_hash_del_key_or_index

函式執行步驟

  • 計算key的雜湊值和下標
  • 遍歷雜湊值所在的bucket,如果找到key所在的bucket,則進行第三步,否則,指向下一個bucket,直到指向bucket連結串列中的最後一個位置
  • 如果要刪除的是第一個元素,直接將arBucket[nIndex]指向第二個元素;其餘的操作是將當前指標的last的next執行當前的next
  • 調整相關指標
  • 釋放資料記憶體和bucket結構體記憶體

詳細程式碼和註解請點選:zend_hash_del_key_or_index程式碼註解

效能分析

PHP的雜湊表的優點:PHP的HashTable為陣列的操作提供了很大的方便,無論是陣列的建立和新增元素或刪除元素等操作,雜湊表都提供了很好的效能,但其不足在資料量大的時候比較明顯,從時間複雜度和空間複雜度看看其不足。

不足如下:

  • 儲存資料的結構體zval需要單獨分配記憶體,需要管理這個額外的記憶體,每個zval佔用了16bytes的記憶體;
  • 在新增bucket時,bucket也是額外分配,也需要16bytes的記憶體;
  • 為了能進行順序遍歷,使用雙向連結串列連線整個HashTable,多出了很多的指標,每個指標也要16bytes的記憶體;
  • 在遍歷時,如果元素位於bucket連結串列的尾部,也需要遍歷完整個bucket連結串列才能找到所要查詢的值

PHP的HashTable的不足主要是其雙向連結串列多出的指標及zval和bucket需要額外分配記憶體,因此導致佔用了很多記憶體空間及查詢時多出了不少時間的消耗。

後續

上面提到的不足,在PHP7中都很好地解決了,PHP7對核心中的資料結構做了一個大改造,使得PHP的效率高了很多,因此,推薦PHP開發者都將開發和部署版本更新吧。看看下面這段PHP程式碼:

<?php
$size = pow(2, 16); 

$startTime = microtime(true);
$array = array();
for ($key = 0, $maxKey = ($size - 1) * $size; $key <= $maxKey; $key += $size) {
    $array[$key] = 0;
}
$endTime = microtime(true);
echo '插入 ', $size, ' 個惡意的元素需要 ', $endTime - $startTime, ' 秒', "\n";

$startTime = microtime(true);
$array = array();
for ($key = 0, $maxKey = $size - 1; $key <= $maxKey; ++$key) {
    $array[$key] = 0;
}
$endTime = microtime(true);
echo '插入 ', $size, ' 個普通元素需要 ', $endTime - $startTime, ' 秒', "\n";

上面這個demo是有多個hash衝突時和無衝突時的時間消耗比較。筆者在PHP5.4下執行這段程式碼,結果如下

插入 65536 個惡意的元素需要 43.72204709053 秒

插入 65536 個普通元素需要 0.009843111038208 秒

而在PHP7上執行的結果:

插入 65536 個惡意的元素需要 4.4028408527374 秒

插入 65536 個普通元素需要 0.0018510818481445 秒

可見不論在有衝突和無衝突的陣列操作,PHP7的效能都提升了不少,當然,有衝突的效能提升更為明顯。至於為什麼PHP7的效能提高了這麼多,值得繼續深究。

參考文章:

PHP陣列的Hash衝突例項

Understanding PHP’s internal array implementation (PHP’s Source Code for PHP Developers – Part 4)

PHP’s new hashtable implementation

相關文章