詳解 PHP 陣列的底層實現:HashTable

Gtaker發表於2018-10-22

前言

PHP 中的陣列是一種強大且靈活的資料型別。在講解它的底層實現之前,讓我們先來看看它在實際使用中都有哪些重要的特性:

// 可以使用數字下標的形式定義陣列
$arr= ['Mike', 2 => 'JoJo'];
echo $arr[0], $arr[2];

// 也可以使用字串下標定義陣列
$arr = ['name' => 'Mike', 'age' => 22];

// 可以順序讀取陣列中的資料
foreach ($arr as $key => $value) {
    // Do Something
}
echo current($arr);
echo next($arr);

// 也可以隨機讀取陣列中的資料
$arr = ['name' => 'Mike', 'age' => 22];
echo $arr['name'];

// 陣列的長度是可變的
$arr = [1, 2, 3];
$arr[] = 4;
array_push($arr, 5);
複製程式碼

基於這些特性,我們可以很輕易的使用 PHP 中的陣列實現集合、棧、列表、字典等多種資料結構。那麼這些特性在底層是如何實現的呢?且聽我細細道來。

資料結構

PHP 中的陣列實際上是一個有序對映。對映是一種把 values 關聯到 keys 的型別。—— PHP手冊

在 PHP 中,這種對映關係是使用雜湊表(HashTable)實現的,在 C 語言中,只能通過數字下標訪問陣列元素,而通過 HashTable,我們可以使用 String Key 作為下標來訪問陣列元素。簡單地說,HashTable 通過對映函式將一個 Strring Key 轉化為一個普通的數字下標,並將對應的 Value 值儲存到下標對應的陣列元素中。

PHP 中的 HashTable 由 zend_array 定義,它的資料結構如下:

struct _zend_array {
    zend_refcounted_h gc;
    union {
        struct {
            ZEND_ENDIAN_LOHI_4(
                zend_uchar    flags,
                zend_uchar    nApplyCount,
                zend_uchar    nIteratorsCount,
                zend_uchar    reserve)
        } v;
        uint32_t flags;           /* 通過 32 個可用標識,設定雜湊表的屬性 */
    } u;
    uint32_t     nTableMask;       /* 值為 nTableSize 的負數 */
    Bucket      *arData;           /* 用來儲存資料 */
    uint32_t     nNumUsed;         /* arData 中的已用空間大小 */
    uint32_t     nNumOfElements;   /* 陣列中的元素個數 */
    uint32_t     nTableSize;       /* 陣列大小,總是 2 冪次方 */
    uint32_t     nInternalPointer; /* 下一個資料元素的指標,用於迭代(foreach) */
    zend_long    nNextFreeElement; /* 下一個可用的數值索引 */
    dtor_func_t  pDestructor;      /* 資料解構函式(控制程式碼) */
};
複製程式碼

該結構中的 Bucket 即儲存元素的陣列,arData 指向陣列的起始位置,使用對映函式對 key 值進行對映後可以得到偏移值,通過記憶體起始位置 + 偏移值即可在雜湊表中進行定址操作。Bucket 的資料結構如下:

typedef struct _Bucket {
    zval              val; /* 值 */
    zend_ulong        h;   /* 使用 time 33 演算法對 key 進行計算後得到的雜湊值(或為數字索引)   */
    zend_string      *key; /* 當 key 值為字串時,指向該字串對應的 zend_string(使用數字索引時該值為 NULL) */
} Bucket;
複製程式碼

基本實現

雜湊表主要由儲存元素的陣列(Bucket)和雜湊函式兩部分構成。

隨機讀

當指定一個 Key-Value 對映關係時,如果 Key 為 String 型別,則先通過 Time 33 演算法將其轉換為一個 Int 型別的整數,然後再先通過 PHP 中某種特定的雜湊演算法將該 Int 對映為 Bucket 陣列中的一個下標,最終將 Value 儲存到該下標對應的元素中。 通過 Key 訪問陣列時,只需要使用相同的演算法計算出對應下標,然後取出對應元素中的 Value 值,即可實現隨機讀取

雜湊函式隨機讀的基本實現

順序讀

由上面所講可知,儲存在 HashTable 中的元素是無序的,而 PHP 中的陣列是有序的,PHP 是如何解決這個問題的呢?

為了實現 HashTable 的有序性,PHP 為其增加了一張中間對映表,該表是一個大小與 Bucket 相同的陣列,陣列中儲存整形資料,用於儲存元素實際儲存的 Value 在 Bucekt 中的下標。注意,加入了中間對映表後,Bucekt 中的資料是有序的,而中間對映表中的資料是無序的。這樣順序讀取時只需要訪問 Bucket 中的資料即可。

雜湊函式順序讀的基本實現

zend_array 中並沒有單獨定義中間對映表,而是將其與 arData 放在一起,陣列初始化時並不只分配 Bucket 大小的記憶體,同時還會分配相同大小空間的資料來作為中間對映表,其實現方式如圖:

中間對映表在 PHP 中的實現

雜湊函式

由上一節可知,雜湊函式實際上是先將 hash code 對映到中間對映表中,再由中間對映表指向實際儲存 Value 的元素。

PHP 中採用如下方式對 hash code 進行雜湊:

nIndex = key->h | nTableMask;
複製程式碼

因為雜湊表的大小恆為 2 的冪次方,所以雜湊後的值會位於 [nTableMask, -1] 之間,即中間對映表之中。

Hash 衝突

任何雜湊函式都會出現雜湊衝突的問題,常見的解決雜湊衝突的方法有以下幾種:

  • 開放定址法
  • 鏈地址法
  • 重雜湊法

PHP 採用的是其中的鏈地址法,將衝突的 Bucket 串成連結串列,這樣中間對映表對映出的就不是某一個元素,而是一個 Bucket 連結串列,通過雜湊函式定位到對應的 Bucket 連結串列時,需要遍歷連結串列,逐個對比 Key 值,繼而找到目標元素。

新元素 Hash 衝突時的插入分為以下兩步:

  • 將舊元素的下標儲存到新元素的 next
  • 將新元素的下標儲存到中間對映表中

可以看出,PHP 在 Bucket 原有的陣列結構上,實現了靜態連結串列,從而解決了雜湊衝突的問題。

查詢

HashTable 中的查詢過程其實已經在上面說完了:

  1. 使用 time 33 演算法對 key 值計算得到 hash code
  2. 使用雜湊函式計算 hash code 得到雜湊值 nIndex,即元素在中間對映表的下標
  3. 通過 nIndex 從中間對映表中取出元素在 Bucket 中的下標 idx
  4. 通過 idx 訪問 Bucket 中對應的陣列元素,該元素同時也是一個靜態連結串列的頭結點
  5. 遍歷連結串列,分別判斷每個元素中的 key 值是否與我們要查詢的 key 值相同
  6. 如果相同,終止遍歷

擴容

在 C 語言中,陣列的長度是定長的,那麼如果空間已滿還需繼續插入的時候怎麼辦呢?PHP 的陣列在底層實現了自動擴容機制,當插入一個元素且沒有空閒空間時,就會觸發自動擴容機制,擴容後再執行插入。

需要提出的一點是,當刪除某一個陣列元素時,會先使用標誌位對該元素進行邏輯刪除,而不會立即刪除該元素所在的 Bucket,因為後者在每次刪除時進行一次排列操作,從而造成不必要的效能開銷。

擴容的過程為:

  1. 如果已刪除元素所佔比例達到閾值,則會移除已被邏輯刪除的 Bucket,然後將後面的 Bucket 向前補上空缺的 Bucket,因為 Bucket 的下標發生了變動,所以還需要更改每個元素在中間對映表中儲存的實際下標值。
  2. 如果未達到閾值,PHP 則會申請一個大小是原陣列兩倍的新陣列,並將舊陣列中的資料複製到新陣列中,因為陣列長度發生了改變,所以 key-value 的對映關係需要重新計算,這個步驟為重建索引

注:因為在重建索引時需要重新計算對映關係,所以將舊陣列複製到新陣列中時,中間對映表的資料是無需複製的。

總結

  • PHP 中的陣列是使用 HashTable 實現的
  • HashTable 的佔用空間是 2 的冪次方
  • HashTable 通過 Key-Value 對映關係實現隨機讀取
  • HashTable 通過中間對映表實現順序讀取,中間對映表和元素陣列(Bucket)使用連續的記憶體空間
  • PHP 通過鏈地址法解決 HashTable 中的雜湊衝突
  • 在空間已滿時,會觸發自動擴容機制,導致重建索引

參考資料

相關文章