詳解 PHP 陣列的底層實現:HashTable
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_ucharflags, zend_ucharnApplyCount, zend_ucharnIteratorsCount, zend_ucharreserve) } v; uint32_t flags;/* 通過 32 個可用標識,設定散列表的屬性 */ } u; uint32_tnTableMask;/* 值為 nTableSize 的負數 */ Bucket*arData;/* 用來儲存資料 */ uint32_tnNumUsed;/* arData 中的已用空間大小 */ uint32_tnNumOfElements;/* 陣列中的元素個數 */ uint32_tnTableSize;/* 陣列大小,總是 2 冪次方 */ uint32_tnInternalPointer; /* 下一個資料元素的指標,用於迭代(foreach) */ zend_longnNextFreeElement; /* 下一個可用的數值索引 */ dtor_func_tpDestructor;/* 資料解構函式(控制代碼) */ }; 複製程式碼
該結構中的 Bucket
即儲存元素的陣列, arData
指向陣列的起始位置,使用 對映函式
對 key 值進行對映後可以得到 偏移值 ,通過 記憶體起始位置 + 偏移值 即可在散列表中進行定址操作。Bucket 的資料結構如下:
typedef struct _Bucket { zvalval; /* 值 */ zend_ulongh;/* 使用 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 大小的記憶體,同時還會分配相同大小空間的資料來作為中間對映表,其實現方式如圖:

雜湊函式
由上一節可知,雜湊函式實際上是先將 hash code
對映到中間對映表中,再由中間對映表指向實際儲存 Value 的元素。
PHP 中採用如下方式對 hash code 進行雜湊:
nIndex = key->h | nTableMask; 複製程式碼
因為散列表的大小恆為 2 的冪次方,所以雜湊後的值會位於 [nTableMask, -1] 之間,即中間對映表之中。
Hash 衝突
任何雜湊函式都會出現雜湊衝突的問題,常見的解決雜湊衝突的方法有以下幾種:
- 開放定址法
- 鏈地址法
- 重雜湊法
PHP 採用的是其中的 鏈地址法
,將衝突的 Bucket 串成連結串列,這樣中間對映表映射出的就不是某一個元素,而是一個 Bucket 連結串列,通過雜湊函式定位到對應的 Bucket 連結串列時,需要遍歷連結串列,逐個對比 Key 值,繼而找到目標元素。
新元素 Hash 衝突時的插入分為以下兩步:
next
可以看出,PHP 在 Bucket 原有的陣列結構上,實現了 靜態連結串列
,從而解決了雜湊衝突的問題。
查詢
HashTable 中的查詢過程其實已經在上面說完了:
- 使用
time 33
演算法對 key 值計算得到hash code
- 使用雜湊函式計算 hash code 得到雜湊值
nIndex
,即元素在中間對映表的下標 - 通過 nIndex 從中間對映表中取出元素在 Bucket 中的下標
idx
- 通過 idx 訪問 Bucket 中對應的陣列元素,該元素同時也是一個
靜態連結串列
的頭結點 - 遍歷連結串列,分別判斷每個元素中的 key 值是否與我們要查詢的 key 值相同
- 如果相同,終止遍歷
擴容
在 C 語言中,陣列的長度是定長的,那麼如果空間已滿還需繼續插入的時候怎麼辦呢?PHP 的陣列在底層實現了自動擴容機制,當插入一個元素且沒有空閒空間時,就會觸發 自動擴容 機制,擴容後再執行插入。
需要提出的一點是,當刪除某一個數組元素時,會先使用標誌位對該元素進行 邏輯刪除 ,而不會立即刪除該元素所在的 Bucket,因為後者在每次刪除時進行一次排列操作,從而造成不必要的效能開銷。
擴容的過程為:
- 如果已刪除元素所佔比例達到閾值,則會移除已被 邏輯刪除 的 Bucket,然後將後面的 Bucket 向前補上空缺的 Bucket,因為 Bucket 的下標發生了變動,所以還需要更改每個元素在中間對映表中儲存的實際下標值。
- 如果未達到
閾值
,PHP 則會申請一個大小是原陣列兩倍的新陣列,並將舊陣列中的資料複製到新陣列中,因為陣列長度發生了改變,所以 key-value 的對映關係需要重新計算,這個步驟為 重建索引 。
注:因為在重建索引時需要重新計算對映關係,所以將舊陣列複製到新陣列中時,中間對映表的資料是無需複製的。